From 3085eb993a7c4a755bd81b8afb7e974565b9e7dd Mon Sep 17 00:00:00 2001 From: netscrawler Date: Thu, 4 Jul 2024 00:45:11 +0300 Subject: [PATCH] Squashed commit of the following: commit 5b11756af6702e364ecba24c7d4a2ebf3794dc7a Author: netscrawler Date: Thu Jul 4 00:44:15 2024 +0300 v0.1 commit c99f477e355d93adb65b57738f7ebda0fc36593c Author: netscrawler Date: Sun Jun 30 20:44:53 2024 +0300 v0.0.6 --- .idea/.gitignore | 8 + .idea/changeAPI.iml | 9 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Taskfile.yaml | 9 + cmd/converter/main.go | 95 ++++++++++ config/local.yaml | 9 + go.mod | 28 +++ go.sum | 45 +++++ internal/app/app.go | 24 +++ internal/app/grpc/app.go | 63 +++++++ internal/config/config.go | 59 ++++++ internal/domain/models/vunitRate.go | 6 + internal/grpc/cnvrt/server.go | 59 ++++++ .../handlers/slogdiscard/slogdiscard.go | 36 ++++ .../logger/handlers/slogpretty/slogpretty.go | 98 ++++++++++ internal/lib/logger/sl/sl.go | 12 ++ internal/lib/rateExtract/rateExtract.go | 177 ++++++++++++++++++ internal/services/converter/converter.go | 48 +++++ 19 files changed, 799 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/changeAPI.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Taskfile.yaml create mode 100644 cmd/converter/main.go create mode 100644 config/local.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/grpc/app.go create mode 100644 internal/config/config.go create mode 100644 internal/domain/models/vunitRate.go create mode 100644 internal/grpc/cnvrt/server.go create mode 100644 internal/lib/logger/handlers/slogdiscard/slogdiscard.go create mode 100644 internal/lib/logger/handlers/slogpretty/slogpretty.go create mode 100644 internal/lib/logger/sl/sl.go create mode 100644 internal/lib/rateExtract/rateExtract.go create mode 100644 internal/services/converter/converter.go diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/changeAPI.iml b/.idea/changeAPI.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/changeAPI.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..33a3860 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..466ad7f --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,9 @@ +version: "3" + +tasks: + runServer: + aliases: + - run + desc: "Run grpc service" + cmds: + - go run cmd/converter/main.go --config .\config\local.yaml \ No newline at end of file diff --git a/cmd/converter/main.go b/cmd/converter/main.go new file mode 100644 index 0000000..04024c0 --- /dev/null +++ b/cmd/converter/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "converter/internal/app" + "converter/internal/config" + "converter/internal/lib/logger/handlers/slogpretty" + "fmt" + "github.com/redis/go-redis/v9" + "log/slog" + "os" + "os/signal" + "syscall" +) + +const ( + envlocal = "local" + envdev = "dev" + envprod = "prod" +) + +func main() { + + cfg := config.MustLoad() + + fmt.Println(cfg) + + log := setupLogger(cfg.Env) + log.Info("starting application", slog.Any("cfg", cfg)) + applicaton := app.New(log, cfg.GRPC.Port, cfg.TokenTTL) + go applicaton.GRPCSrv.MustRun() + + // TODO: инициализировать приложение app + + //TODO:redis + client := redis.NewClient(&redis.Options{ + Addr: cfg.Redis.Addr, + Password: cfg.Redis.Password, + DB: cfg.Redis.DB, + }) + + ctx := context.Background() + + err := client.Set(ctx, "key", "value1", 0).Err() + if err != nil { + log.Info("err") + } + + val, err := client.Get(ctx, "key").Result() + if err != nil { + log.Info("err") + } + log.Info("key", slog.Any("val", val)) + + // TODO: запустить gRPC сервер приложения + + // Graceful shutdown + stop := make(chan os.Signal, 1) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) + + sign := <-stop + log.Info("stoping application", slog.String("signal", sign.String())) + applicaton.GRPCSrv.Stop() + log.Info("application stopped") +} + +func setupLogger(env string) *slog.Logger { + var log *slog.Logger + + switch env { + case envlocal: + log = setupPrettySlog() + case envdev: + log = slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + ) + case envprod: + log = slog.New( + slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}), + ) + } + return log +} + +func setupPrettySlog() *slog.Logger { + opts := slogpretty.PrettyHandlerOptions{ + SlogOpts: &slog.HandlerOptions{ + Level: slog.LevelDebug, + }, + } + + handler := opts.NewPrettyHandler(os.Stdout) + + return slog.New(handler) +} diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..374baaf --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,9 @@ +env: "local" +token_ttl: 1h +grpc: + port: 44044 + timeout: 10h +redis: + addr: "localhost:6379" + password: "1234" + db: 0 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e56c53c --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module converter + +go 1.22 + +require ( + github.com/fatih/color v1.17.0 + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/netscrawler/protoss v0.0.0-20240630182512-36e5b935e6b4 + golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 + google.golang.org/grpc v1.64.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/redis/go-redis/v9 v9.5.3 // indirect + golang.org/x/net v0.22.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3c7551d --- /dev/null +++ b/go.sum @@ -0,0 +1,45 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +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/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= +github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/netscrawler/protoss v0.0.0-20240630182512-36e5b935e6b4 h1:V/zWems1vjc1Ob0p0APeL+tKiSHeOW4Kc1yo3xP0IMQ= +github.com/netscrawler/protoss v0.0.0-20240630182512-36e5b935e6b4/go.mod h1:+Ms2tgnXO0rzzVXLn61kltLnEOA/CuSKSv62L+cacdQ= +github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= +github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= +golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= +olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..b4c6a74 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,24 @@ +package app + +import ( + grpcapp "converter/internal/app/grpc" + "log/slog" + "time" +) + +type App struct { + GRPCSrv *grpcapp.App +} + +func New( + log *slog.Logger, + grpcPort int, + tokenTTl time.Duration) *App { + //TODO: инициализировать хранилище + //TODO: init convert service + + grpcApp := grpcapp.New(log, grpcPort) + return &App{ + GRPCSrv: grpcApp, + } +} diff --git a/internal/app/grpc/app.go b/internal/app/grpc/app.go new file mode 100644 index 0000000..6606e5a --- /dev/null +++ b/internal/app/grpc/app.go @@ -0,0 +1,63 @@ +package grpcapp + +import ( + convertgrpc "converter/internal/grpc/cnvrt" + "converter/internal/services/converter" + "fmt" + "google.golang.org/grpc" + "log/slog" + "net" +) + +type App struct { + log *slog.Logger + gRPCServer *grpc.Server + port int +} + +func New( + log *slog.Logger, + port int) *App { + gRPCServer := grpc.NewServer() + convert := converter.New(log) + convertgrpc.Register(gRPCServer, convert) + + return &App{ + log: log, + gRPCServer: gRPCServer, + port: port, + } + +} + +func (a *App) MustRun() { + if err := a.Run(); err != nil { + panic(err) + } +} + +func (a *App) Run() error { + const op = "grpcapp.Run" + log := a.log.With( + slog.String("op", op), + slog.Int("port", a.port)) + + l, err := net.Listen("tcp", fmt.Sprintf(":%d", a.port)) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + log.Info("gRPC server is running", slog.String("addr", l.Addr().String())) + + if err := a.gRPCServer.Serve(l); err != nil { + return fmt.Errorf("%s: %w", op, err) + } + return nil +} + +func (a *App) Stop() { + const op = "grpcapp.Stop" + + a.log.With(slog.String("op", op)). + Info("stopping gRPC server", slog.Int("port", a.port)) + a.gRPCServer.GracefulStop() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b801a0a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,59 @@ +package config + +import ( + "flag" + "github.com/ilyakaznacheev/cleanenv" + "os" + "time" +) + +type Config struct { + Env string `yaml:"env" env-default:"local"` + TokenTTL time.Duration `yaml:"token_ttl" env-required:"true"` + GRPC GRPCConfig `yaml:"grpc"` + Redis RedisConfig `yaml:"redis"` +} + +type GRPCConfig struct { + Port int `yaml:"port"` + Timeout time.Duration `yaml:"timeout"` +} + +type RedisConfig struct { + Addr string `yaml:"addr"` + Password string `yaml:"password"` + DB int `yaml:"db"` +} + +func MustLoad() *Config { + path := fetchConfigPath() + if path == "" { + panic("config path is empty") + + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + panic("config file not found" + path) + } + + var cfg Config + + if err := cleanenv.ReadConfig(path, &cfg); err != nil { + panic("failed to read config" + err.Error()) + } + + return &cfg +} + +func fetchConfigPath() string { + var res string + + flag.StringVar(&res, "config", "", "path to config file") + flag.Parse() + + if res == "" { + res = os.Getenv("CONFIG_PATH") + } + + return res +} diff --git a/internal/domain/models/vunitRate.go b/internal/domain/models/vunitRate.go new file mode 100644 index 0000000..d1d9a3d --- /dev/null +++ b/internal/domain/models/vunitRate.go @@ -0,0 +1,6 @@ +package models + +type VunitRate struct { + Currency string + Rate float32 +} diff --git a/internal/grpc/cnvrt/server.go b/internal/grpc/cnvrt/server.go new file mode 100644 index 0000000..b5d94dd --- /dev/null +++ b/internal/grpc/cnvrt/server.go @@ -0,0 +1,59 @@ +package cnvrt + +import ( + "context" + cnvrtv1 "github.com/netscrawler/protoss/gen/go/changeAPI" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type Converter interface { + Convert(ctx context.Context, + amount uint32, + targetCurrency string, + ) (convertedAmount uint32, rate float32, err error) +} + +type serverAPI struct { + cnvrtv1.UnimplementedConverterServer + convert Converter +} + +func Register(gRPC *grpc.Server, convert Converter) { + cnvrtv1.RegisterConverterServer(gRPC, &serverAPI{convert: convert}) +} + +func (s *serverAPI) Convert( + ctx context.Context, + req *cnvrtv1.ConvertRequest) ( + *cnvrtv1.ConvertResponse, error) { + if !isValidCurrency(req.GetTargetCurrency()) { + return nil, status.Error(codes.InvalidArgument, "Invalid target currency") + } + + convertedAmount, rate, err := s.convert.Convert(ctx, req.GetAmount(), req.GetTargetCurrency()) + if err != nil { + //todo error handler + return nil, status.Error(codes.Internal, "Internal error") + } + return &cnvrtv1.ConvertResponse{ + BaseAmount: req.GetAmount(), + ConvertedAmount: convertedAmount, + ConvertedCurrency: req.GetTargetCurrency(), + Rate: rate, + }, nil +} + +func isValidCurrency(currency string) bool { + + currencies := map[string]bool{ + "AUD": true, "GBP": true, "BYR": true, "DKK": true, "USD": true, "EUR": true, + "ISK": true, "KZT": true, "CAD": true, "NOK": true, "XDR": true, "SGD": true, + "TRL": true, "UAH": true, "SEK": true, "CHF": true, "JPY": true, + } + if currencies[currency] { + return true + } + return false +} diff --git a/internal/lib/logger/handlers/slogdiscard/slogdiscard.go b/internal/lib/logger/handlers/slogdiscard/slogdiscard.go new file mode 100644 index 0000000..1488376 --- /dev/null +++ b/internal/lib/logger/handlers/slogdiscard/slogdiscard.go @@ -0,0 +1,36 @@ +package slogdiscard + +import ( + "context" + "golang.org/x/exp/slog" +) + +func NewDiscardLogger() *slog.Logger { + return slog.New(NewDiscardHandler()) +} + +type DiscardHandler struct{} + +func NewDiscardHandler() *DiscardHandler { + return &DiscardHandler{} +} + +func (h *DiscardHandler) Handle(_ context.Context, _ slog.Record) error { + // Просто игнорируем запись журнала + return nil +} + +func (h *DiscardHandler) WithAttrs(_ []slog.Attr) slog.Handler { + // Возвращает тот же обработчик, так как нет атрибутов для сохранения + return h +} + +func (h *DiscardHandler) WithGroup(_ string) slog.Handler { + // Возвращает тот же обработчик, так как нет группы для сохранения + return h +} + +func (h *DiscardHandler) Enabled(_ context.Context, _ slog.Level) bool { + // Всегда возвращает false, так как запись журнала игнорируется + return false +} diff --git a/internal/lib/logger/handlers/slogpretty/slogpretty.go b/internal/lib/logger/handlers/slogpretty/slogpretty.go new file mode 100644 index 0000000..f298f2f --- /dev/null +++ b/internal/lib/logger/handlers/slogpretty/slogpretty.go @@ -0,0 +1,98 @@ +package slogpretty + +import ( + "context" + "encoding/json" + "io" + stdLog "log" + "log/slog" + + "github.com/fatih/color" +) + +type PrettyHandlerOptions struct { + SlogOpts *slog.HandlerOptions +} + +type PrettyHandler struct { + opts PrettyHandlerOptions + slog.Handler + l *stdLog.Logger + attrs []slog.Attr +} + +func (opts PrettyHandlerOptions) NewPrettyHandler( + out io.Writer, +) *PrettyHandler { + h := &PrettyHandler{ + Handler: slog.NewJSONHandler(out, opts.SlogOpts), + l: stdLog.New(out, "", 0), + } + + return h +} + +func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error { + level := r.Level.String() + ":" + + switch r.Level { + case slog.LevelDebug: + level = color.MagentaString(level) + case slog.LevelInfo: + level = color.BlueString(level) + case slog.LevelWarn: + level = color.YellowString(level) + case slog.LevelError: + level = color.RedString(level) + } + + fields := make(map[string]interface{}, r.NumAttrs()) + + r.Attrs(func(a slog.Attr) bool { + fields[a.Key] = a.Value.Any() + + return true + }) + + for _, a := range h.attrs { + fields[a.Key] = a.Value.Any() + } + + var b []byte + var err error + + if len(fields) > 0 { + b, err = json.MarshalIndent(fields, "", " ") + if err != nil { + return err + } + } + + timeStr := r.Time.Format("[15:05:05.000]") + msg := color.CyanString(r.Message) + + h.l.Println( + timeStr, + level, + msg, + color.WhiteString(string(b)), + ) + + return nil +} + +func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &PrettyHandler{ + Handler: h.Handler, + l: h.l, + attrs: attrs, + } +} + +func (h *PrettyHandler) WithGroup(name string) slog.Handler { + // TODO: implement + return &PrettyHandler{ + Handler: h.Handler.WithGroup(name), + l: h.l, + } +} diff --git a/internal/lib/logger/sl/sl.go b/internal/lib/logger/sl/sl.go new file mode 100644 index 0000000..b9fa762 --- /dev/null +++ b/internal/lib/logger/sl/sl.go @@ -0,0 +1,12 @@ +package sl + +import ( + "log/slog" +) + +func Err(err error) slog.Attr { + return slog.Attr{ + Key: "error", + Value: slog.StringValue(err.Error()), + } +} diff --git a/internal/lib/rateExtract/rateExtract.go b/internal/lib/rateExtract/rateExtract.go new file mode 100644 index 0000000..314ddc2 --- /dev/null +++ b/internal/lib/rateExtract/rateExtract.go @@ -0,0 +1,177 @@ +package rateExtract + +import ( + "compress/gzip" + "converter/internal/domain/models" + "encoding/xml" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "golang.org/x/text/encoding/charmap" +) + +type resultType struct { + XMLName xml.Name `xml:"ValCurs"` + Valute []struct { + NumCode string `xml:"NumCode"` + CharCode string `xml:"CharCode"` + Nominal string `xml:"Nominal"` + Name string `xml:"Name"` + Value string `xml:"Value"` + } `xml:"Valute"` +} + +type cacheKeyType struct { + CurrencyId string + Date string +} + +type cacheResultType struct { + Rate float64 +} + +var urlTemplate string = "https://www.cbr.ru/scripts/XML_daily.asp?date_req=%s" + +var cache map[cacheKeyType]*cacheResultType + +func GetExchangeRate(currencyId string) (models.VunitRate, error) { + date := time.Now() + if cache == nil { + cache = map[cacheKeyType]*cacheResultType{} + } + + reqDate := fmt.Sprintf("%02d/%02d/%d", date.Day(), date.Month(), date.Year()) + + cacheKey := cacheKeyType{CurrencyId: currencyId, Date: reqDate} + + cacheResult, exists := cache[cacheKey] + + if !exists { + + url := fmt.Sprintf(urlTemplate, reqDate) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return models.VunitRate{}, err + } + + req.Header.Add("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7") + req.Header.Add("accept-encoding", "gzip, deflate, br") + req.Header.Add("accept-language", "en-US,en;q=0.9,ru;q=0.8") + req.Header.Add("cache-control", "max-age=0") + req.Header.Add("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36") + + client := &http.Client{} + + resp, err := client.Do(req) + if err != nil { + return models.VunitRate{Currency: currencyId, Rate: 0}, err + } + + defer resp.Body.Close() + + var reader io.ReadCloser + + switch resp.Header.Get("Content-Encoding") { + case "gzip": + reader, err = gzip.NewReader(resp.Body) + if err != nil { + return models.VunitRate{Currency: currencyId, Rate: 0}, err + } + defer reader.Close() + default: + reader = resp.Body + } + + xml := xml.NewDecoder(reader) + + xml.CharsetReader = func(charset string, input io.Reader) (io.Reader, error) { + switch charset { + case "windows-1251": + return charmap.Windows1251.NewDecoder().Reader(input), nil + default: + return nil, fmt.Errorf("unknown charset: %s", charset) + } + } + + result := &resultType{} + + err = xml.Decode(result) + if err != nil { + return models.VunitRate{Currency: currencyId, Rate: 0}, err + } + + for _, resultRow := range result.Valute { + + if resultRow.CharCode != currencyId { + continue + } + + resultRow.Value = strings.Replace(resultRow.Value, ",", ".", 1) + + rate, err := strconv.ParseFloat(resultRow.Value, 64) + if err != nil { + return models.VunitRate{Currency: currencyId, Rate: 0}, err + } + + nominal, err := strconv.ParseInt(resultRow.Nominal, 10, 64) + if err != nil { + return models.VunitRate{Currency: currencyId, Rate: 0}, err + } + + cacheResult = &cacheResultType{Rate: rate / float64(nominal)} + + cache[cacheKey] = cacheResult + + } + + } + return models.VunitRate{Currency: currencyId, Rate: float32(cacheResult.Rate)}, nil + +} + +//func Convert(from string, to string, value float64, date time.Time) (float64, error) { +// +// if from == to { +// return value, nil +// } +// +// if value == 0 { +// return 0, nil +// } +// +// result := value +// +// if from != CurrencyRUB { +// +// exchangeRate, err := GetExchangeRate(from, date) +// if err != nil { +// return 0, err +// } +// +// result = result * exchangeRate +// +// } +// +// if to != CurrencyRUB { +// +// exchangeRate, err := GetExchangeRate(to, date) +// if err != nil { +// return 0, err +// } +// +// result = result / exchangeRate +// +// } +// +// return (math.Floor(result*100) / 100), nil +// +//} +// +//func main() { +// fmt.Println(GetExchangeRate(CurrencyUSD, time.Now())) +//} diff --git a/internal/services/converter/converter.go b/internal/services/converter/converter.go new file mode 100644 index 0000000..305f05d --- /dev/null +++ b/internal/services/converter/converter.go @@ -0,0 +1,48 @@ +package converter + +import ( + "context" + "converter/internal/lib/logger/sl" + "converter/internal/lib/rateExtract" + "fmt" + "log/slog" +) + +type Converter struct { + log *slog.Logger +} + +// New returns a new instance of the Converter service. +func New( + log *slog.Logger, + +) *Converter { + return &Converter{ + log: log, + } +} + +func (c *Converter) Convert( + ctx context.Context, + amount uint32, + currency string, +) (uint32, float32, error) { + const op = "converter.convert" + log := c.log.With( + slog.String("op", op), + slog.String("currency", currency), + slog.Any("amount", amount), + ) + log.Info("convertation") + var convertedAmount uint32 + //TODO extract rate + rate, err := rateExtract.GetExchangeRate(currency) + if err != nil { + log.Error("Error extracting rate", sl.Err(err)) + return 0, 0, fmt.Errorf("%s: %w", op, err) + } + + convertedAmount = uint32(float32(amount) * rate.Rate) + + return convertedAmount, rate.Rate, nil +}