diff --git a/.env.sample b/.env.sample index 2d92b41..06c6c8c 100644 --- a/.env.sample +++ b/.env.sample @@ -3,3 +3,5 @@ ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban ZARINHUB_TIMEOUT=5s DATABASE_PATH=./card-to-iban.db +SERVER_ADDR=127.0.0.1:8080 +FRONTEND_ORIGIN=http://127.0.0.1:3000 diff --git a/backend/cmd/api/config.go b/backend/cmd/api/config.go new file mode 100644 index 0000000..d0e298c --- /dev/null +++ b/backend/cmd/api/config.go @@ -0,0 +1,68 @@ +package main + +import ( + "fmt" + "os" + "strings" + "time" +) + +type config struct { + ServerAddr string + FrontendOrigin string + ZarinHubEndpoint string + ZarinHubToken string + ZarinHubTimeout time.Duration + DatabasePath string +} + +func loadConfig() (config, error) { + return readConfig(os.Getenv) +} + +func readConfig(getenv func(string) string) (config, error) { + token := strings.TrimSpace(getenv("ZARINHUB_ACCESS_TOKEN")) + if token == "" { + return config{}, fmt.Errorf("ZARINHUB_ACCESS_TOKEN is required") + } + + timeoutText := valueOrDefault( + getenv("ZARINHUB_TIMEOUT"), + "5s", + ) + timeout, err := time.ParseDuration(timeoutText) + if err != nil || timeout <= 0 { + return config{}, fmt.Errorf( + "ZARINHUB_TIMEOUT must be a positive duration", + ) + } + + return config{ + ServerAddr: valueOrDefault( + getenv("SERVER_ADDR"), + "127.0.0.1:8080", + ), + FrontendOrigin: valueOrDefault( + getenv("FRONTEND_ORIGIN"), + "http://127.0.0.1:3000", + ), + ZarinHubEndpoint: valueOrDefault( + getenv("ZARINHUB_ENDPOINT"), + "https://zarin-hub.com/api/v5/KYC/CardToIban", + ), + ZarinHubToken: token, + ZarinHubTimeout: timeout, + DatabasePath: valueOrDefault( + getenv("DATABASE_PATH"), + "./card-to-iban.db", + ), + }, nil +} + +func valueOrDefault(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + + return strings.TrimSpace(value) +} diff --git a/backend/cmd/api/config_test.go b/backend/cmd/api/config_test.go new file mode 100644 index 0000000..5237d42 --- /dev/null +++ b/backend/cmd/api/config_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestReadConfigUsesDefaults(t *testing.T) { + values := map[string]string{ + "ZARINHUB_ACCESS_TOKEN": "test-token", + } + + config, err := readConfig(func(key string) string { + return values[key] + }) + if err != nil { + t.Fatalf("readConfig() error = %v", err) + } + + if config.ServerAddr != "127.0.0.1:8080" { + t.Errorf("ServerAddr = %q", config.ServerAddr) + } + if config.FrontendOrigin != "http://127.0.0.1:3000" { + t.Errorf("FrontendOrigin = %q", config.FrontendOrigin) + } + if config.ZarinHubTimeout != 5*time.Second { + t.Errorf("ZarinHubTimeout = %v", config.ZarinHubTimeout) + } + if config.DatabasePath != "./card-to-iban.db" { + t.Errorf("DatabasePath = %q", config.DatabasePath) + } +} + +func TestReadConfigRejectsMissingAccessToken(t *testing.T) { + _, err := readConfig(func(key string) string { return "" }) + if err == nil || !strings.Contains(err.Error(), "ZARINHUB_ACCESS_TOKEN") { + t.Fatalf("readConfig() error = %v, want missing token error", err) + } +} + +func TestReadConfigRejectsInvalidTimeout(t *testing.T) { + values := map[string]string{ + "ZARINHUB_ACCESS_TOKEN": "test-token", + "ZARINHUB_TIMEOUT": "later", + } + + _, err := readConfig(func(key string) string { + return values[key] + }) + if err == nil || !strings.Contains(err.Error(), "ZARINHUB_TIMEOUT") { + t.Fatalf("readConfig() error = %v, want timeout error", err) + } +} diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 986bf88..69cd978 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,18 +1,103 @@ package main import ( + "context" "encoding/json" - "log" + "errors" + "fmt" + "log/slog" "net/http" + "os" + "time" + + "card-to-iban/internal/httpapi" + "card-to-iban/internal/repository" + "card-to-iban/internal/service" + "card-to-iban/internal/storage" + "card-to-iban/internal/zarinhub" + + "github.com/joho/godotenv" ) func main() { - http.HandleFunc("/health", healthHandler) + logger := slog.New( + slog.NewJSONHandler(os.Stdout, nil), + ) - log.Println("server listening on http://localhost:8080") + if err := run(logger); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} - if err := http.ListenAndServe(":8080", nil); err != nil { - log.Fatal(err) +func run(logger *slog.Logger) error { + loadLocalEnvironment() + + config, err := loadConfig() + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + + db, err := storage.OpenSQLite(config.DatabasePath) + if err != nil { + return err + } + + migrationContext, cancelMigration := context.WithTimeout( + context.Background(), + 5*time.Second, + ) + defer cancelMigration() + + if err := storage.Migrate(migrationContext, db); err != nil { + if closeErr := db.Close(); closeErr != nil { + return errors.Join(err, closeErr) + } + + return err + } + + zarinHubClient := zarinhub.NewClient( + &http.Client{Timeout: config.ZarinHubTimeout}, + config.ZarinHubEndpoint, + config.ZarinHubToken, + ) + cardService := service.NewCardService(zarinHubClient) + auditRepository := repository.NewAuditRepository(db) + handler := httpapi.NewHandler( + cardService, + auditRepository, + logger, + ) + + mux := http.NewServeMux() + mux.HandleFunc("/health", healthHandler) + mux.HandleFunc("/api/v1/card-to-iban", handler.Convert) + + server := &http.Server{ + Addr: config.ServerAddr, + Handler: httpapi.AllowOrigin( + config.FrontendOrigin, + mux, + ), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + logger.Info("server listening", "address", config.ServerAddr) + serverErr := server.ListenAndServe() + closeErr := db.Close() + + return errors.Join(serverErr, closeErr) +} + +func loadLocalEnvironment() { + for _, path := range []string{".env", "../.env"} { + if err := godotenv.Load(path); err == nil { + return + } } } @@ -29,6 +114,10 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { } if err := json.NewEncoder(w).Encode(response); err != nil { - log.Println("failed to encode health response:", err) + slog.ErrorContext( + r.Context(), + "health response encoding failed", + "error", err, + ) } } diff --git a/backend/go.mod b/backend/go.mod index 6a67be3..2a260e0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,10 @@ module card-to-iban go 1.27.1 -require modernc.org/sqlite v1.59.0 +require ( + github.com/joho/godotenv v1.5.1 + modernc.org/sqlite v1.59.0 +) require ( github.com/dustin/go-humanize v1.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 83ad0ca..8726afd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +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-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=