diff --git a/.gitignore b/.gitignore index 113de184..247c67bd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ profiling/ *_stringer.go *_enumer.go *_enginer.go +*_exchanger.go # test dump testdump* diff --git a/generate/exchanger/exchanger.go b/generate/exchanger/exchanger.go new file mode 100644 index 00000000..4cdeb3da --- /dev/null +++ b/generate/exchanger/exchanger.go @@ -0,0 +1,333 @@ +package main + +import ( + "flag" + "fmt" + "go/ast" + "go/constant" + "go/format" + "go/token" + "go/types" + "log" + "os" + "path" + "strings" + + "golang.org/x/tools/go/packages" +) + +var ( + typeName = flag.String("type", "", "type name; must be set") + output = flag.String("output", "", "output file name; default srcdir/_exchanger.go") + trimprefix = flag.String("trimprefix", "", "trim the `prefix` from the generated constant names") + buildTags = flag.String("tags", "", "comma-separated list of build tags to apply") + packageName = flag.String("packagename", "", "name of the package for generated code; default current package") + interfacesPackage = flag.String("interfacespackage", "engines", "name of the package for the interfaces; default engines") + interfaceExchanger = flag.String("interfaceexchanger", "Exchanger", "name of the nginer interface; default engines.Exchanger") + enginesImport = flag.String("enginesimport", "github.com/hearchco/agent/src/exchange/engines", "source of the engines import, which is prefixed to imports for engines; default github.com/hearchco/agent/src/exchange/engines") +) + +// Usage is a replacement usage function for the flags package. +func Usage() { + fmt.Fprintf(os.Stderr, "Usage of exchanger:\n") + fmt.Fprintf(os.Stderr, "\texchanger [flags] -type T [directory]\n") + fmt.Fprintf(os.Stderr, "\texchanger [flags] -type T files... # Must be a single package\n") + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() +} + +func main() { + log.SetFlags(0) + log.SetPrefix("exchanger: ") + flag.Usage = Usage + flag.Parse() + if len(*typeName) == 0 { + flag.Usage() + os.Exit(2) + } + /* ---------------------------------- + //! Should be comma seperated list of type names, currently is only the first type name + ---------------------------------- */ + types := strings.Split(*typeName, ",") + var tags []string + if len(*buildTags) > 0 { + tags = strings.Split(*buildTags, ",") + } + + // We accept either one directory or a list of files. Which do we have? + args := flag.Args() + if len(args) == 0 { + // Default: process whole package in current directory. + args = []string{"."} + } + + // Parse the package once. + var dir string + g := Generator{ + trimPrefix: *trimprefix, + } + + if len(args) == 1 && isDirectoryFatal(args[0]) { + dir = args[0] + } else { + if len(tags) != 0 { + log.Fatal("-tags option applies only to directories, not when files are specified") + // ^FATAL + } + dir = path.Dir(args[0]) + } + + g.parsePackage(args, tags) + + // Print the header and package clause. + g.Printf("// Code generated by \"exchanger %s\"; DO NOT EDIT.\n", strings.Join(os.Args[1:], " ")) + g.Printf("\n") + var pkgName string + if *packageName == "" { + pkgName = g.pkg.name + } else { + pkgName = *packageName + } + g.Printf("package %s", pkgName) + g.Printf("\n") + g.Printf("import \"%s\"\n", *enginesImport) + + // Run generate for each type. + for _, typeName := range types { + g.generate(typeName) + } + + // Format the output. + src := g.format() + + // Write to file. + outputName := *output + if outputName == "" { + baseName := fmt.Sprintf("%s_exchanger.go", types[0]) + outputName = path.Join(dir, strings.ToLower(baseName)) + } + err := os.WriteFile(outputName, src, 0644) + if err != nil { + log.Fatalf("writing output: %s", err) + // ^FATAL + } +} + +func (g *Generator) Printf(format string, args ...interface{}) { + fmt.Fprintf(&g.buf, format, args...) +} + +// parsePackage analyzes the single package constructed from the patterns and tags. +// parsePackage exits if there is an error. +func (g *Generator) parsePackage(patterns []string, tags []string) { + cfg := &packages.Config{ + Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax, + Tests: false, + BuildFlags: []string{fmt.Sprintf("-tags=%s", strings.Join(tags, " "))}, + Logf: g.logf, + } + pkgs, err := packages.Load(cfg, patterns...) + if err != nil { + log.Fatal(err) + // ^FATAL + } + if len(pkgs) != 1 { + log.Fatalf("error: %d packages matching %v", len(pkgs), strings.Join(patterns, " ")) + // ^FATAL + } + g.addPackage(pkgs[0]) +} + +// addPackage adds a type checked Package and its syntax files to the generator. +func (g *Generator) addPackage(pkg *packages.Package) { + g.pkg = &Package{ + name: pkg.Name, + defs: pkg.TypesInfo.Defs, + files: make([]*File, len(pkg.Syntax)), + } + + for i, file := range pkg.Syntax { + g.pkg.files[i] = &File{ + file: file, + pkg: g.pkg, + trimPrefix: g.trimPrefix, + } + } +} + +// generate produces imports and the NewEngineStarter method for the named type. +func (g *Generator) generate(typeName string) { + values := make([]Value, 0, 100) + for _, file := range g.pkg.files { + // Set the state for this run of the walker. + file.typeName = typeName + file.values = nil + if file.file != nil { + ast.Inspect(file.file, file.genDecl) + values = append(values, file.values...) + } + } + + if len(values) == 0 { + log.Fatalf("no values defined for type %s", typeName) + // ^FATAL + } + + // Generate code for importing engines + for _, v := range values { + if validConst(v) { + g.Printf("import \"%s/%s\"\n", *enginesImport, strings.ToLower(v.name)) + } + } + + // Generate code that will fail if the constants change value. + g.Printf("func _() {\n") + g.Printf("\t// An \"invalid array index\" compiler error signifies that the constant values have changed.\n") + g.Printf("\t// Re-run the exchanger command to generate them again.\n") + g.Printf("\tvar x [1]struct{}\n") + for _, v := range values { + origName := v.originalName + if *packageName != "" { + origName = fmt.Sprintf("%s.%s", g.pkg.name, v.originalName) + } + g.Printf("\t_ = x[%s - (%s)]\n", origName, v.str) + } + g.Printf("}\n") + + g.printExchangerLen(values) + g.printInterfaces(values, *interfaceExchanger) +} + +// format returns the gofmt-ed contents of the Generator's buffer. +func (g *Generator) format() []byte { + src, err := format.Source(g.buf.Bytes()) + if err != nil { + // Should never happen, but can arise when developing this code. + // The user can compile the output to see the error. + log.Printf("warning: internal error: invalid Go generated: %s", err) + log.Printf("warning: compile the package to analyze the error") + return g.buf.Bytes() + } + return src +} + +func (v *Value) String() string { + return v.str +} + +// genDecl processes one declaration clause. +func (f *File) genDecl(node ast.Node) bool { + decl, ok := node.(*ast.GenDecl) + if !ok || decl.Tok != token.CONST { + // We only care about const declarations. + return true + } + // The name of the type of the constants we are declaring. + // Can change if this is a multi-element declaration. + typ := "" + // Loop over the elements of the declaration. Each element is a ValueSpec: + // a list of names possibly followed by a type, possibly followed by values. + // If the type and value are both missing, we carry down the type (and value, + // but the "go/types" package takes care of that). + for _, spec := range decl.Specs { + vspec := spec.(*ast.ValueSpec) // Guaranteed to succeed as this is CONST. + if vspec.Type == nil && len(vspec.Values) > 0 { + // "X = 1". With no type but a value. If the constant is untyped, + // skip this vspec and reset the remembered type. + typ = "" + + // If this is a simple type conversion, remember the type. + // We don't mind if this is actually a call; a qualified call won't + // be matched (that will be SelectorExpr, not Ident), and only unusual + // situations will result in a function call that appears to be + // a type conversion. + ce, ok := vspec.Values[0].(*ast.CallExpr) + if !ok { + continue + } + id, ok := ce.Fun.(*ast.Ident) + if !ok { + continue + } + typ = id.Name + } + if vspec.Type != nil { + // "X T". We have a type. Remember it. + ident, ok := vspec.Type.(*ast.Ident) + if !ok { + continue + } + typ = ident.Name + } + if typ != f.typeName { + // This is not the type we're looking for. + continue + } + // We now have a list of names (from one line of source code) all being + // declared with the desired type. + // Grab their names and actual values and store them in f.values. + for _, name := range vspec.Names { + if name.Name == "_" { + continue + } + // This dance lets the type checker find the values for us. It's a + // bit tricky: look up the object declared by the name, find its + // types.Const, and extract its value. + obj, ok := f.pkg.defs[name] + if !ok { + log.Fatalf("no value for constant %s", name) + // ^FATAL + } + info := obj.Type().Underlying().(*types.Basic).Info() + if info&types.IsInteger == 0 { + log.Fatalf("can't handle non-integer constant type %s", typ) + // ^FATAL + } + value := obj.(*types.Const).Val() // Guaranteed to succeed as this is CONST. + if value.Kind() != constant.Int { + log.Fatalf("can't happen: constant is not an integer %s", name) + // ^FATAL + } + i64, isInt := constant.Int64Val(value) + u64, isUint := constant.Uint64Val(value) + if !isInt && !isUint { + log.Fatalf("internal error: value of %s is not an integer: %s", name, value.String()) + // ^FATAL + } + if !isInt { + u64 = uint64(i64) + } + v := Value{ + originalName: name.Name, + value: u64, + signed: info&types.IsUnsigned == 0, + str: value.String(), + } + v.name = strings.TrimPrefix(v.originalName, f.trimPrefix) + if c := vspec.Comment; c != nil && len(c.List) == 1 { + v.interfaces = strings.Split(strings.TrimSpace(c.Text()), ",") + } + f.values = append(f.values, v) + } + } + return false +} + +func (g *Generator) printExchangerLen(values []Value) { + g.Printf("\n") + g.Printf("\nconst exchangerLen = %d", len(values)) + g.Printf("\n") +} + +func (g *Generator) printInterfaces(values []Value, interfaceName string) { + g.Printf("\n") + g.Printf("\nfunc %sArray() [exchangerLen]%s.%s {", strings.ToLower(interfaceName), *interfacesPackage, interfaceName) + g.Printf("\n\tvar engineArray [exchangerLen]%s.%s", *interfacesPackage, interfaceName) + for _, v := range values { + if validConst(v) { + g.Printf("\n\tengineArray[%s.%s] = %s.New()", g.pkg.name, v.name, strings.ToLower(v.name)) + } + } + g.Printf("\n\treturn engineArray") + g.Printf("\n}") +} diff --git a/generate/exchanger/structs.go b/generate/exchanger/structs.go new file mode 100644 index 00000000..766749d9 --- /dev/null +++ b/generate/exchanger/structs.go @@ -0,0 +1,50 @@ +package main + +import ( + "bytes" + "go/ast" + "go/types" +) + +// Value represents a declared constant. +type Value struct { + originalName string // The name of the constant. + name string // The name with trimmed prefix. + // The value is stored as a bit pattern alone. The boolean tells us + // whether to interpret it as an int64 or a uint64; the only place + // this matters is when sorting. + // Much of the time the str field is all we need; it is printed + // by Value.String. + value uint64 // Will be converted to int64 when needed. + signed bool // Whether the constant is a signed type. + str string // The string representation given by the "go/constant" package. + interfaces []string // The interfaces that the constant implements. +} + +// Generator holds the state of the analysis. Primarily used to buffer +// the output for format.Source. +type Generator struct { + buf bytes.Buffer // Accumulated output. + pkg *Package // Package we are scanning. + + trimPrefix string + + logf func(format string, args ...interface{}) // test logging hook; nil when not testing +} + +// File holds a single parsed file and associated data. +type File struct { + pkg *Package // Package to which this file belongs. + file *ast.File // Parsed AST. + // These fields are reset for each type being generated. + typeName string // Name of the constant type. + values []Value // Accumulator for constant values of that type. + + trimPrefix string +} + +type Package struct { + name string + defs map[*ast.Ident]types.Object + files []*File +} diff --git a/generate/exchanger/util.go b/generate/exchanger/util.go new file mode 100644 index 00000000..d953fb38 --- /dev/null +++ b/generate/exchanger/util.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "os" + "strings" +) + +func validConst(v Value) bool { + lowerName := strings.ToLower(v.name) + return lowerName != "undefined" && isDirectory(lowerName) +} + +// isDirectory reports whether the named file is a directory. +func isDirectory(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.IsDir() +} + +func isDirectoryFatal(path string) bool { + info, err := os.Stat(path) + if err != nil { + log.Fatal(err) + // ^FATAL + } + return info.IsDir() +} diff --git a/go.mod b/go.mod index 0735278d..1e4a3612 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 // indirect + github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kennygrant/sanitize v1.2.4 // indirect github.com/knadh/koanf/maps v0.1.1 // indirect diff --git a/go.sum b/go.sum index 0defe55c..f9b3dcc2 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= -github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0 h1:e+8XbKB6IMn8A4OAyZccO4pYfB3s7bt6azNIPE7AnPg= -github.com/google/pprof v0.0.0-20240625030939-27f56978b8b0/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da h1:xRmpO92tb8y+Z85iUOMOicpCfaYcv7o3Cg3wKrIpg8g= +github.com/google/pprof v0.0.0-20240711041743-f6c9dda6c6da/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= diff --git a/src/cache/actions_currencies.go b/src/cache/actions_currencies.go new file mode 100644 index 00000000..01e69d8d --- /dev/null +++ b/src/cache/actions_currencies.go @@ -0,0 +1,42 @@ +package cache + +import ( + "fmt" + "time" + + "github.com/hearchco/agent/src/exchange/currency" + "github.com/hearchco/agent/src/exchange/engines" +) + +func (db DB) SetCurrencies(base currency.Currency, engs []engines.Name, currencies currency.Currencies, ttl ...time.Duration) error { + key := combineBaseWithExchangeEnginesNames(base, engs) + return db.driver.Set(key, currencies, ttl...) +} + +func (db DB) GetCurrencies(base currency.Currency, engs []engines.Name) (currency.Currencies, error) { + key := combineBaseWithExchangeEnginesNames(base, engs) + var currencies currency.Currencies + err := db.driver.Get(key, ¤cies) + return currencies, err +} + +func (db DB) GetCurrenciesTTL(base currency.Currency, engs []engines.Name) (time.Duration, error) { + key := combineBaseWithExchangeEnginesNames(base, engs) + return db.driver.GetTTL(key) +} + +func combineBaseWithExchangeEnginesNames(base currency.Currency, engs []engines.Name) string { + return fmt.Sprintf("%v_%v", base.String(), combineExchangeEnginesNames(engs)) +} + +func combineExchangeEnginesNames(engs []engines.Name) string { + var key string + for i, eng := range engs { + if i == 0 { + key = fmt.Sprintf("%v", eng.String()) + } else { + key = fmt.Sprintf("%v_%v", key, eng.String()) + } + } + return key +} diff --git a/src/config/defaults.go b/src/config/defaults.go index b7342610..14becfbf 100644 --- a/src/config/defaults.go +++ b/src/config/defaults.go @@ -17,7 +17,8 @@ func New() Config { Type: "none", KeyPrefix: "HEARCHCO_", TTL: TTL{ - Time: moretime.Week, + Results: moretime.Week, + Currencies: moretime.Day, }, Redis: Redis{ Host: "localhost", @@ -78,5 +79,10 @@ func New() Config { Timings: thoroughTimings, }, }, + Exchange: Exchange{ + BaseCurrency: "EUR", + Engines: exchangeEngines, + Timings: exchangeTimings, + }, } } diff --git a/src/config/defaults_exchange.go b/src/config/defaults_exchange.go new file mode 100644 index 00000000..8e6dd4d8 --- /dev/null +++ b/src/config/defaults_exchange.go @@ -0,0 +1,17 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/exchange/engines" +) + +var exchangeEngines = []engines.Name{ + engines.CURRENCYAPI, + engines.EXCHANGERATEAPI, + engines.FRANKFURTER, +} + +var exchangeTimings = ExchangeTimings{ + HardTimeout: 500 * time.Millisecond, +} diff --git a/src/config/load.go b/src/config/load.go index fc3fad91..c2770382 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -12,6 +12,8 @@ import ( "github.com/knadh/koanf/v2" "github.com/rs/zerolog/log" + "github.com/hearchco/agent/src/exchange/currency" + exchengines "github.com/hearchco/agent/src/exchange/engines" "github.com/hearchco/agent/src/search/category" "github.com/hearchco/agent/src/search/engines" "github.com/hearchco/agent/src/utils/moretime" @@ -80,7 +82,8 @@ func (c Config) getReader() ReaderConfig { Cache: ReaderCache{ Type: c.Server.Cache.Type, TTL: ReaderTTL{ - Time: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Time), + Results: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Results), + Currencies: moretime.ConvertToFancyTime(c.Server.Cache.TTL.Currencies), }, Redis: c.Server.Cache.Redis, }, @@ -91,6 +94,14 @@ func (c Config) getReader() ReaderConfig { }, // Initialize the categories map. RCategories: map[category.Name]ReaderCategory{}, + // Exchange config. + RExchange: ReaderExchange{ + BaseCurrency: c.Exchange.BaseCurrency.String(), + REngines: map[string]ReaderExchangeEngine{}, + RTimings: ReaderExchangeTimings{ + HardTimeout: moretime.ConvertToFancyTime(c.Exchange.Timings.HardTimeout), + }, + }, } // Set the categories map config. @@ -124,6 +135,13 @@ func (c Config) getReader() ReaderConfig { } } + // Set the exchange engines. + for _, eng := range c.Exchange.Engines { + rc.RExchange.REngines[eng.ToLower()] = ReaderExchangeEngine{ + Enabled: true, + } + } + return rc } @@ -144,7 +162,8 @@ func (c *Config) fromReader(rc ReaderConfig) { Cache: Cache{ Type: rc.Server.Cache.Type, TTL: TTL{ - Time: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Time), + Results: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Results), + Currencies: moretime.ConvertFromFancyTime(rc.Server.Cache.TTL.Currencies), }, Redis: rc.Server.Cache.Redis, }, @@ -155,6 +174,14 @@ func (c *Config) fromReader(rc ReaderConfig) { }, // Initialize the categories map. Categories: map[category.Name]Category{}, + // Exchange config. + Exchange: Exchange{ + BaseCurrency: currency.ConvertBase(rc.RExchange.BaseCurrency), + Engines: []exchengines.Name{}, + Timings: ExchangeTimings{ + HardTimeout: moretime.ConvertFromFancyTime(rc.RExchange.RTimings.HardTimeout), + }, + }, } // Set the categories map config. @@ -213,6 +240,22 @@ func (c *Config) fromReader(rc ReaderConfig) { } } + // Set the exchange engines. + for engS, engRConf := range rc.RExchange.REngines { + engName, err := exchengines.NameString(engS) + if err != nil { + log.Panic(). + Caller(). + Err(err). + Str("engine", engS). + Msg("Failed converting string to engine name") + // ^PANIC + } + if engRConf.Enabled { + nc.Exchange.Engines = append(nc.Exchange.Engines, engName) + } + } + // Set the new config. *c = nc } diff --git a/src/config/structs_config.go b/src/config/structs_config.go index a7d69ac9..21f511c9 100644 --- a/src/config/structs_config.go +++ b/src/config/structs_config.go @@ -8,8 +8,10 @@ import ( type ReaderConfig struct { Server ReaderServer `koanf:"server"` RCategories map[category.Name]ReaderCategory `koanf:"categories"` + RExchange ReaderExchange `koanf:"exchange"` } type Config struct { Server Server Categories map[category.Name]Category + Exchange Exchange } diff --git a/src/config/structs_exchange.go b/src/config/structs_exchange.go new file mode 100644 index 00000000..e4a09dbc --- /dev/null +++ b/src/config/structs_exchange.go @@ -0,0 +1,39 @@ +package config + +import ( + "time" + + "github.com/hearchco/agent/src/exchange/currency" + "github.com/hearchco/agent/src/exchange/engines" +) + +// ReaderCategory is format in which the config is read from the config file and environment variables. +type ReaderExchange struct { + BaseCurrency string `koanf:"basecurrency"` + REngines map[string]ReaderExchangeEngine `koanf:"engines"` + RTimings ReaderExchangeTimings `koanf:"timings"` +} +type Exchange struct { + BaseCurrency currency.Currency + Engines []engines.Name + Timings ExchangeTimings +} + +// ReaderEngine is format in which the config is read from the config file and environment variables. +type ReaderExchangeEngine struct { + // If false, the engine will not be used. + Enabled bool `koanf:"enabled"` +} + +// ReaderTimings is format in which the config is read from the config file and environment variables. +// In format. +// Example: 1s, 1m, 1h, 1d, 1w, 1M, 1y. +// If unit is not specified, it is assumed to be milliseconds. +type ReaderExchangeTimings struct { + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). + HardTimeout string `koanf:"hardtimeout"` +} +type ExchangeTimings struct { + // Hard timeout after which the search is forcefully stopped (even if the engines didn't respond). + HardTimeout time.Duration +} diff --git a/src/config/structs_server.go b/src/config/structs_server.go index 1282197f..27ad9b88 100644 --- a/src/config/structs_server.go +++ b/src/config/structs_server.go @@ -62,12 +62,18 @@ type Cache struct { type ReaderTTL struct { // How long to store the results in cache. // Setting this to 0 caches the results forever. - Time string `koanf:"time"` + Results string `koanf:"time"` + // How long to store the currencies in cache. + // Setting this to 0 caches the currencies forever. + Currencies string `koanf:"currencies"` } type TTL struct { // How long to store the results in cache. // Setting this to 0 caches the results forever. - Time time.Duration + Results time.Duration + // How long to store the currencies in cache. + // Setting this to 0 caches the currencies forever. + Currencies time.Duration } type Redis struct { diff --git a/src/exchange/currency/currency.go b/src/exchange/currency/currency.go new file mode 100644 index 00000000..4415b360 --- /dev/null +++ b/src/exchange/currency/currency.go @@ -0,0 +1,44 @@ +package currency + +import ( + "fmt" + "slices" + "strings" + + "github.com/rs/zerolog/log" +) + +// Format: ISO 4217 (3-letter code) e.g. CHF, EUR, GBP, USD. +type Currency string + +func (c Currency) String() string { + return string(c) +} + +func (c Currency) Lower() string { + return strings.ToLower(c.String()) +} + +func Convert(curr string) (Currency, error) { + if len(curr) != 3 { + return "", fmt.Errorf("currency code must be 3 characters long") + } + + upperCurr := strings.ToUpper(curr) + return Currency(upperCurr), nil +} + +func ConvertBase(curr string) Currency { + // Hardcoded to ensure all APIs include these currencies and therefore work as expected. + supportedBaseCurrencies := [...]string{"CHF", "EUR", "GBP", "USD"} + + upperCurr := strings.ToUpper(curr) + if !slices.Contains(supportedBaseCurrencies[:], upperCurr) { + log.Panic(). + Str("currency", upperCurr). + Msg("unsupported base currency") + // ^PANIC + } + + return Currency(upperCurr) +} diff --git a/src/exchange/currency/map.go b/src/exchange/currency/map.go new file mode 100644 index 00000000..866092cb --- /dev/null +++ b/src/exchange/currency/map.go @@ -0,0 +1,42 @@ +package currency + +import ( + "sync" +) + +type Currencies map[Currency]float64 + +type CurrencyMap struct { + currs map[Currency][]float64 + lock sync.RWMutex +} + +func NewCurrencyMap() CurrencyMap { + return CurrencyMap{ + currs: make(map[Currency][]float64), + } +} + +func (c *CurrencyMap) Append(currs Currencies) { + c.lock.Lock() + defer c.lock.Unlock() + + for curr, rate := range currs { + c.currs[curr] = append(c.currs[curr], rate) + } +} + +func (c *CurrencyMap) Extract() Currencies { + c.lock.RLock() + defer c.lock.RUnlock() + + avg := make(Currencies) + for curr, rates := range c.currs { + var sum float64 + for _, rate := range rates { + sum += rate + } + avg[curr] = sum / float64(len(rates)) + } + return avg +} diff --git a/src/exchange/engines/currencyapi/exchange.go b/src/exchange/engines/currencyapi/exchange.go new file mode 100644 index 00000000..b2e70abf --- /dev/null +++ b/src/exchange/engines/currencyapi/exchange.go @@ -0,0 +1,57 @@ +package currencyapi + +import ( + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + dataRates, err := e.extractRates(string(body), base) + if err != nil { + return nil, fmt.Errorf("failed to extract rates from response: %w", err) + } + + // Check if no rates were found. + if len(dataRates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(currency.Currencies, len(dataRates)) + for currS, rate := range dataRates { + curr, err := currency.Convert(currS) + if err != nil { + // Non-ISO currencies are expected from this engine. + log.Trace(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/currencyapi/info.go b/src/exchange/engines/currencyapi/info.go new file mode 100644 index 00000000..dd6fb83c --- /dev/null +++ b/src/exchange/engines/currencyapi/info.go @@ -0,0 +1,6 @@ +package currencyapi + +const ( + // Needs to have /.json at the end + apiUrl = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@2024-03-06/v1/currencies" +) diff --git a/src/exchange/engines/currencyapi/json.go b/src/exchange/engines/currencyapi/json.go new file mode 100644 index 00000000..fc21e8c4 --- /dev/null +++ b/src/exchange/engines/currencyapi/json.go @@ -0,0 +1,30 @@ +package currencyapi + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/hearchco/agent/src/exchange/currency" +) + +// Rates field is named the same as base currency. +func (e Exchange) extractRates(resp string, base currency.Currency) (map[string]float64, error) { + pattern := `"` + base.Lower() + `":\s*{[^}]*}` + regexp := regexp.MustCompile(pattern) + match := regexp.FindString(resp) + if match == "" { + return nil, fmt.Errorf("could not find JSON field for base currency %s", base) + } + + // Remove `"":`` from the match + jsonRates := strings.TrimSpace((match[len(base.Lower())+3:])) + + var rates map[string]float64 + if err := json.Unmarshal([]byte(jsonRates), &rates); err != nil { + return nil, fmt.Errorf("could not unmarshal JSON field for base currency %s: %w", base, err) + } + + return rates, nil +} diff --git a/src/exchange/engines/currencyapi/new.go b/src/exchange/engines/currencyapi/new.go new file mode 100644 index 00000000..23e62ff7 --- /dev/null +++ b/src/exchange/engines/currencyapi/new.go @@ -0,0 +1,15 @@ +package currencyapi + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct{} + +func New() Exchange { + return Exchange{} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return apiUrl + "/" + base.Lower() + ".json" +} diff --git a/src/exchange/engines/currencyapi/note.md b/src/exchange/engines/currencyapi/note.md new file mode 100644 index 00000000..b6897ebb --- /dev/null +++ b/src/exchange/engines/currencyapi/note.md @@ -0,0 +1 @@ +Includes a lot of currencies (and crypto) that aren's in ISO format so errors in logs are to be expected. diff --git a/src/exchange/engines/exchanger.go b/src/exchange/engines/exchanger.go new file mode 100644 index 00000000..8a9f83eb --- /dev/null +++ b/src/exchange/engines/exchanger.go @@ -0,0 +1,9 @@ +package engines + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchanger interface { + Exchange(base currency.Currency) (currency.Currencies, error) +} diff --git a/src/exchange/engines/exchangerateapi/exchange.go b/src/exchange/engines/exchangerateapi/exchange.go new file mode 100644 index 00000000..d9b1afc2 --- /dev/null +++ b/src/exchange/engines/exchangerateapi/exchange.go @@ -0,0 +1,57 @@ +package exchangerateapi + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + var data response + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check if no rates were found. + if len(data.Rates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(currency.Currencies, len(data.Rates)) + for currS, rate := range data.Rates { + curr, err := currency.Convert(currS) + if err != nil { + log.Error(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/exchangerateapi/info.go b/src/exchange/engines/exchangerateapi/info.go new file mode 100644 index 00000000..50a01ac4 --- /dev/null +++ b/src/exchange/engines/exchangerateapi/info.go @@ -0,0 +1,6 @@ +package exchangerateapi + +const ( + // Needs to have / at the end + apiUrl = "https://open.er-api.com/v6/latest" +) diff --git a/src/exchange/engines/exchangerateapi/json.go b/src/exchange/engines/exchangerateapi/json.go new file mode 100644 index 00000000..bc3fce0b --- /dev/null +++ b/src/exchange/engines/exchangerateapi/json.go @@ -0,0 +1,5 @@ +package exchangerateapi + +type response struct { + Rates map[string]float64 `json:"rates"` +} diff --git a/src/exchange/engines/exchangerateapi/new.go b/src/exchange/engines/exchangerateapi/new.go new file mode 100644 index 00000000..e699fab8 --- /dev/null +++ b/src/exchange/engines/exchangerateapi/new.go @@ -0,0 +1,15 @@ +package exchangerateapi + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct{} + +func New() Exchange { + return Exchange{} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return apiUrl + "/" + base.String() +} diff --git a/src/exchange/engines/frankfurter/exchange.go b/src/exchange/engines/frankfurter/exchange.go new file mode 100644 index 00000000..5362cabe --- /dev/null +++ b/src/exchange/engines/frankfurter/exchange.go @@ -0,0 +1,57 @@ +package frankfurter + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/hearchco/agent/src/exchange/currency" +) + +func (e Exchange) Exchange(base currency.Currency) (currency.Currencies, error) { + // Get data from the API. + api := e.apiUrlWithBaseCurrency(base) + resp, err := http.Get(api) + if err != nil { + return nil, fmt.Errorf("failed to get data from %s: %w", api, err) + } + + // Read the response body. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Unmarshal the response. + var data response + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + // Check if no rates were found. + if len(data.Rates) == 0 { + return nil, fmt.Errorf("no rates found for %s", base) + } + + // Convert the rates to proper currency types with their rates. + rates := make(currency.Currencies, len(data.Rates)) + for currS, rate := range data.Rates { + curr, err := currency.Convert(currS) + if err != nil { + log.Error(). + Err(err). + Str("currency", currS). + Msg("failed to convert currency") + continue + } + rates[curr] = rate + } + + // Set the base currency rate to 1. + rates[base] = 1 + + return rates, nil +} diff --git a/src/exchange/engines/frankfurter/info.go b/src/exchange/engines/frankfurter/info.go new file mode 100644 index 00000000..c4ada286 --- /dev/null +++ b/src/exchange/engines/frankfurter/info.go @@ -0,0 +1,6 @@ +package frankfurter + +const ( + // Needs to have ?from= at the end + apiUrl = "https://api.frankfurter.app/latest" +) diff --git a/src/exchange/engines/frankfurter/json.go b/src/exchange/engines/frankfurter/json.go new file mode 100644 index 00000000..c9bbb49c --- /dev/null +++ b/src/exchange/engines/frankfurter/json.go @@ -0,0 +1,6 @@ +package frankfurter + +// Rates doesn't include the base currency. +type response struct { + Rates map[string]float64 `json:"rates"` +} diff --git a/src/exchange/engines/frankfurter/new.go b/src/exchange/engines/frankfurter/new.go new file mode 100644 index 00000000..9cd21476 --- /dev/null +++ b/src/exchange/engines/frankfurter/new.go @@ -0,0 +1,15 @@ +package frankfurter + +import ( + "github.com/hearchco/agent/src/exchange/currency" +) + +type Exchange struct{} + +func New() Exchange { + return Exchange{} +} + +func (e Exchange) apiUrlWithBaseCurrency(base currency.Currency) string { + return apiUrl + "?from=" + base.String() +} diff --git a/src/exchange/engines/name.go b/src/exchange/engines/name.go new file mode 100644 index 00000000..c47e15d8 --- /dev/null +++ b/src/exchange/engines/name.go @@ -0,0 +1,25 @@ +package engines + +import ( + "strings" +) + +type Name int + +//go:generate enumer -type=Name -json -text -sql +//go:generate go run github.com/hearchco/agent/generate/exchanger -type=Name -packagename exchange -output ../engine_exchanger.go +const ( + UNDEFINED Name = iota + CURRENCYAPI + EXCHANGERATEAPI + FRANKFURTER +) + +// Returns engine names without UNDEFINED. +func Names() []Name { + return _NameValues[1:] +} + +func (n Name) ToLower() string { + return strings.ToLower(n.String()) +} diff --git a/src/exchange/exchange.go b/src/exchange/exchange.go new file mode 100644 index 00000000..6ee79b5e --- /dev/null +++ b/src/exchange/exchange.go @@ -0,0 +1,109 @@ +package exchange + +import ( + "context" + "fmt" + "sync" + + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/exchange/currency" + "github.com/rs/zerolog/log" +) + +type Exchange struct { + base currency.Currency + currencies currency.Currencies +} + +func NewExchange(conf config.Exchange, currencies ...currency.Currencies) Exchange { + // If currencies are provided, use them. + if len(currencies) > 0 { + return Exchange{ + conf.BaseCurrency, + currencies[0], + } + } + + // Otherwise, fetch the currencies from the enabled engines. + exchangers := exchangerArray() + currencyMap := currency.NewCurrencyMap() + + // Create context with HardTimeout. + ctxHardTimeout, cancelHardTimeoutFunc := context.WithTimeout(context.Background(), conf.Timings.HardTimeout) + defer cancelHardTimeoutFunc() + + // Create a WaitGroup for all engines. + var wg sync.WaitGroup + wg.Add(len(conf.Engines)) + + // Create a context that cancels when the WaitGroup is done. + exchangeCtx, cancelExchange := context.WithCancel(context.Background()) + defer cancelExchange() + go func() { + wg.Wait() + cancelExchange() + }() + + for _, eng := range conf.Engines { + exch := exchangers[eng] + go func() { + defer wg.Done() + currs, err := exch.Exchange(conf.BaseCurrency) + if err != nil { + log.Error(). + Err(err). + Str("engine", eng.String()). + Msg("Error while exchanging") + return + } + currencyMap.Append(currs) + }() + } + + // Wait for either all engines to finish or the HardTimeout. + select { + case <-exchangeCtx.Done(): + log.Trace(). + Dur("timeout", conf.Timings.HardTimeout). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("All engines finished") + case <-ctxHardTimeout.Done(): + log.Trace(). + Dur("timeout", conf.Timings.HardTimeout). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("HardTimeout reached") + } + + return Exchange{ + conf.BaseCurrency, + currencyMap.Extract(), + } +} + +func (e Exchange) Currencies() currency.Currencies { + return e.currencies +} + +func (e Exchange) SupportsCurrency(curr currency.Currency) bool { + _, ok := e.currencies[curr] + return ok +} + +func (e Exchange) Convert(from currency.Currency, to currency.Currency, amount float64) float64 { + // Check if FROM and TO are supported currencies. + if !e.SupportsCurrency(from) || !e.SupportsCurrency(to) { + log.Panic(). + Str("from", from.String()). + Str("to", to.String()). + Msg("Unsupported currencies") + // ^PANIC - This should never happen. + } + + // Convert the amount in FROM currency to base currency. + basedAmount := amount / e.currencies[from] + + // Convert the amount in base currency to TO currency. + convertedAmount := basedAmount * e.currencies[to] + + return convertedAmount +} diff --git a/src/router/middlewares/setup.go b/src/router/middlewares/setup.go index eef7fd33..29180ba5 100644 --- a/src/router/middlewares/setup.go +++ b/src/router/middlewares/setup.go @@ -13,7 +13,6 @@ import ( func Setup(mux *chi.Mux, lgr zerolog.Logger, frontendUrls []string, serveProfiler bool) { // Use custom zerolog middleware. - // TODO: Make skipped paths configurable. skipPaths := []string{"/healthz", "/versionz"} mux.Use(zerologMiddleware(lgr, skipPaths)...) diff --git a/src/router/routes/responses.go b/src/router/routes/responses.go index 4fead5d9..2a3e239c 100644 --- a/src/router/routes/responses.go +++ b/src/router/routes/responses.go @@ -1,6 +1,7 @@ package routes import ( + "github.com/hearchco/agent/src/exchange/currency" "github.com/hearchco/agent/src/search/result" ) @@ -25,3 +26,13 @@ type SuggestionsResponse struct { Suggestions []result.Suggestion `json:"suggestions"` } + +type ExchangeResponse struct { + responseBase + + Base currency.Currency `json:"base"` + From currency.Currency `json:"from"` + To currency.Currency `json:"to"` + Amount float64 `json:"amount"` + Result float64 `json:"result"` +} diff --git a/src/router/routes/route_exchange.go b/src/router/routes/route_exchange.go new file mode 100644 index 00000000..74c9f2e6 --- /dev/null +++ b/src/router/routes/route_exchange.go @@ -0,0 +1,155 @@ +package routes + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/hearchco/agent/src/cache" + "github.com/hearchco/agent/src/config" + "github.com/hearchco/agent/src/exchange" + "github.com/hearchco/agent/src/exchange/currency" + "github.com/rs/zerolog/log" +) + +func routeExchange(w http.ResponseWriter, r *http.Request, ver string, conf config.Exchange, db cache.DB, ttl time.Duration) error { + // Capture start time. + startTime := time.Now() + + // Parse form data (including query params). + if err := r.ParseForm(); err != nil { + // Server error. + werr := writeResponseJSON(w, http.StatusInternalServerError, ErrorResponse{ + Message: "failed to parse form", + Value: fmt.Sprintf("%v", err), + }) + if werr != nil { + return fmt.Errorf("%w: %w", werr, err) + } + return err + } + + // FROM is required. + fromS := strings.TrimSpace(getParamOrDefault(r.Form, "from")) + if fromS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "from cannot be empty or whitespace", + Value: "empty from", + }) + } + + // Parse FROM currency. + from, err := currency.Convert(fromS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid from currency", + Value: fmt.Sprintf("%v", err), + }) + } + + // TO is required. + toS := strings.TrimSpace(getParamOrDefault(r.Form, "to")) + if toS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "to cannot be empty or whitespace", + Value: "empty to", + }) + } + + // Parse TO currency. + to, err := currency.Convert(toS) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid to currency", + Value: fmt.Sprintf("%v", err), + }) + } + + // AMOUNT is required. + amountS := strings.TrimSpace(getParamOrDefault(r.Form, "amount")) + if amountS == "" { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "amount cannot be empty or whitespace", + Value: "empty amount", + }) + } + + // Parse amount. + amount, err := strconv.ParseFloat(amountS, 64) + if err != nil { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "invalid amount value", + Value: fmt.Sprintf("%v", err), + }) + } + + // Get the cached currencies. + currencies, err := db.GetCurrencies(conf.BaseCurrency, conf.Engines) + if err != nil { + log.Error(). + Err(err). + Str("base", conf.BaseCurrency.String()). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("Error while getting currencies from cache") + } + + // Create the exchange. + var exch exchange.Exchange + if currencies == nil { + // Fetch the currencies from the enabled engines. + exch = exchange.NewExchange(conf) + // Cache the currencies if any have been fetched. + if len(exch.Currencies()) > 0 { + err := db.SetCurrencies(conf.BaseCurrency, conf.Engines, exch.Currencies(), ttl) + if err != nil { + log.Error(). + Err(err). + Str("base", conf.BaseCurrency.String()). + Str("engines", fmt.Sprintf("%v", conf.Engines)). + Msg("Error while setting currencies in cache") + } + } + } else { + // Use the cached currencies. + exch = exchange.NewExchange(conf, currencies) + } + + // Check if FROM and TO are supported currencies. + if !exch.SupportsCurrency(from) { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "unsupported FROM currency", + Value: from.String(), + }) + } + if !exch.SupportsCurrency(to) { + // User error. + return writeResponseJSON(w, http.StatusBadRequest, ErrorResponse{ + Message: "unsupported TO currency", + Value: to.String(), + }) + } + + // Convert the amount. + convAmount := exch.Convert(from, to, amount) + + return writeResponseJSON(w, http.StatusOK, ExchangeResponse{ + responseBase{ + ver, + time.Since(startTime).Milliseconds(), + }, + conf.BaseCurrency, + from, + to, + amount, + convAmount, + }) +} diff --git a/src/router/routes/route_search.go b/src/router/routes/route_search.go index d6a80f7a..aec30d77 100644 --- a/src/router/routes/route_search.go +++ b/src/router/routes/route_search.go @@ -21,7 +21,7 @@ import ( "github.com/hearchco/agent/src/utils/gotypelimits" ) -func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf map[category.Name]config.Category, ttlConf config.TTL, db cache.DB, salt string) error { +func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf map[category.Name]config.Category, db cache.DB, ttl time.Duration, salt string) error { // Capture start time. startTime := time.Now() @@ -173,7 +173,7 @@ func routeSearch(w http.ResponseWriter, r *http.Request, ver string, catsConf ma rankedRes.Rank(catsConf[categoryName].Ranking) // Store the results in cache. - if err := db.SetResults(query, categoryName, opts, rankedRes, ttlConf.Time); err != nil { + if err := db.SetResults(query, categoryName, opts, rankedRes, ttl); err != nil { log.Error(). Err(err). Str("query", anonymize.String(query)). diff --git a/src/router/routes/setup.go b/src/router/routes/setup.go index 05f8e1b3..02edc03c 100644 --- a/src/router/routes/setup.go +++ b/src/router/routes/setup.go @@ -38,7 +38,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { // /search mux.Get("/search", func(w http.ResponseWriter, r *http.Request) { - err := routeSearch(w, r, ver, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + err := routeSearch(w, r, ver, conf.Categories, db, conf.Server.Cache.TTL.Results, conf.Server.ImageProxy.Salt) if err != nil { log.Error(). Err(err). @@ -48,7 +48,7 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) mux.Post("/search", func(w http.ResponseWriter, r *http.Request) { - err := routeSearch(w, r, ver, conf.Categories, conf.Server.Cache.TTL, db, conf.Server.ImageProxy.Salt) + err := routeSearch(w, r, ver, conf.Categories, db, conf.Server.Cache.TTL.Results, conf.Server.ImageProxy.Salt) if err != nil { log.Error(). Err(err). @@ -80,6 +80,28 @@ func Setup(mux *chi.Mux, ver string, db cache.DB, conf config.Config) { } }) + // /exchange + mux.Get("/exchange", func(w http.ResponseWriter, r *http.Request) { + err := routeExchange(w, r, ver, conf.Exchange, db, conf.Server.Cache.TTL.Currencies) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + mux.Post("/exchange", func(w http.ResponseWriter, r *http.Request) { + err := routeExchange(w, r, ver, conf.Exchange, db, conf.Server.Cache.TTL.Currencies) + if err != nil { + log.Error(). + Err(err). + Str("path", r.URL.Path). + Str("method", r.Method). + Msg("Failed to send response") + } + }) + // /proxy mux.Get("/proxy", func(w http.ResponseWriter, r *http.Request) { err := routeProxy(w, r, conf.Server.ImageProxy.Salt, conf.Server.ImageProxy.Timeout)