From c63d3dbe54fe1bea4ae1b84f910168f8bdd3e48e Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 09:34:38 +0330 Subject: [PATCH 01/20] refactor(http): extract Swagger UI to dedicated module - Move swaggerUIHTML constant from gateway.go to new swagger_ui.go file - Update Swagger UI CDN from jsdelivr to unpkg with version pinning (@5) - Add responsive styling for Swagger UI container with max-width and centering - Replace SwaggerUIBundle.SwaggerUIStandalonePreset with imported SwaggerUIStandalonePreset - Add window.onload wrapper for improved initialization handling - Enable deepLinking and DownloadUrl plugin for enhanced API documentation experience - Improves code organization by separating concerns between gateway setup and UI presentation --- internal/transport/http/gateway.go | 22 ----------------- internal/transport/http/swagger_ui.go | 35 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 22 deletions(-) create mode 100644 internal/transport/http/swagger_ui.go diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index 3053d44..2bd47c6 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -64,28 +64,6 @@ func healthHandler(ping PingFunc) http.HandlerFunc { } } -const swaggerUIHTML = ` - - - go-ledger API - - - - - -
- - - -` func writeResponse(w http.ResponseWriter, data []byte) { _, err := w.Write(data) if err != nil { diff --git a/internal/transport/http/swagger_ui.go b/internal/transport/http/swagger_ui.go new file mode 100644 index 0000000..516e4d9 --- /dev/null +++ b/internal/transport/http/swagger_ui.go @@ -0,0 +1,35 @@ +package http + +const swaggerUIHTML = ` + + + go-ledger API + + + + + + +
+ + + + +` From bb6e2791ce4edf8f959305fe815de99aac6bfcf9 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 09:55:28 +0330 Subject: [PATCH 02/20] ci: enable automated CI/CD pipeline with database migrations - Enable pull_request and push triggers on develop and main branches - Replace manual workflow_dispatch-only trigger with automated events - Add database migration step using golang-migrate before running tests - Rename DB_DSN to DB_DSN_TEST environment variable for test clarity - Ensure test database schema is up-to-date before test execution --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f39c19b..a017563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,10 @@ name: CI on: - workflow_dispatch: # manual trigger only — re-add push/pull_request when team grows + pull_request: + branches: [develop, main] + push: + branches: [develop] jobs: lint: @@ -42,9 +45,13 @@ jobs: go-version: '1.26' cache: true - run: go mod download + - name: Run migrations on test DB + run: | + curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xvz + ./migrate -path migrations -database "postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable" up - run: go test -race -count=1 -timeout 120s ./... env: - DB_DSN: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable + DB_DSN_TEST: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable build: name: Build From 19ce52d1ed76ad0f35102fe9ad647b5c70c995ff Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 09:55:47 +0330 Subject: [PATCH 03/20] refactor(audit): migrate to outbox pattern for reliable event delivery - Replace inline Auditor interface with OutboxStore-based event persistence - Remove NoOp and WebhookAuditor implementations in favor of worker-based delivery - Add Worker type to handle asynchronous audit event processing and retry logic - Introduce AuditOutboxEntry and OutboxStore interface for durable event storage - Remove audit logging from Service.CreateTransaction, delegating to outbox worker - Simplify Service initialization by removing Auditor dependency - Implement polling-based delivery with configurable retry handling and status tracking - Improves reliability and decouples event production from delivery concerns --- internal/audit/noop.go | 15 ----- internal/audit/webhook.go | 88 ---------------------------- internal/audit/worker.go | 115 +++++++++++++++++++++++++++++++++++++ internal/ledger/auditor.go | 12 ---- internal/ledger/outbox.go | 21 +++++++ internal/ledger/service.go | 25 +------- 6 files changed, 138 insertions(+), 138 deletions(-) delete mode 100644 internal/audit/noop.go delete mode 100644 internal/audit/webhook.go create mode 100644 internal/audit/worker.go delete mode 100644 internal/ledger/auditor.go create mode 100644 internal/ledger/outbox.go diff --git a/internal/audit/noop.go b/internal/audit/noop.go deleted file mode 100644 index b3a7100..0000000 --- a/internal/audit/noop.go +++ /dev/null @@ -1,15 +0,0 @@ -package audit - -import ( - "context" - - "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" -) - -type NoOp struct{} - -func (n *NoOp) Log(_ context.Context, _ ledger.AuditEntry) error { - return nil -} - -var _ ledger.Auditor = (*NoOp)(nil) diff --git a/internal/audit/webhook.go b/internal/audit/webhook.go deleted file mode 100644 index dac4952..0000000 --- a/internal/audit/webhook.go +++ /dev/null @@ -1,88 +0,0 @@ -package audit - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "time" - - "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" -) - -var ( - WebhookAuditorSyncMode = "sync" - WebhookAuditorAsyncMode = "async" -) - -type WebhookAuditor struct { - hookURL string - mode string - client *http.Client -} - -func NewWebhookAuditor(hookURL, mode string) *WebhookAuditor { - return &WebhookAuditor{ - hookURL: hookURL, - mode: mode, - client: &http.Client{ - Timeout: 5 * time.Second, - }, - } -} - -func (w *WebhookAuditor) Log(ctx context.Context, entry ledger.AuditEntry) error { - if w.mode == "async" { - go func() { - if err := w.post(context.Background(), entry); err != nil { - slog.Error("audit webhook failed", - "action", entry.ActionType, - "error", err, - ) - } - }() - return nil - } - - return w.post(ctx, entry) -} - -func (w *WebhookAuditor) post(ctx context.Context, entry ledger.AuditEntry) error { - body, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("audit: marshal entry: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.hookURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("audit: build request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := w.client.Do(req) - if err != nil { - return fmt.Errorf("audit: post to hook: %w", err) - } - defer closeResponseBody(resp) - - if resp.StatusCode >= 300 { - return fmt.Errorf("audit: hook returned status %d", resp.StatusCode) - } - - return nil -} - -func closeResponseBody(resp *http.Response) { - if resp == nil || resp.Body == nil { - return - } - err := resp.Body.Close() - if err != nil { - slog.Error("audit: close response body", "error", err) - } -} - -var _ ledger.Auditor = (*WebhookAuditor)(nil) diff --git a/internal/audit/worker.go b/internal/audit/worker.go new file mode 100644 index 0000000..4e65249 --- /dev/null +++ b/internal/audit/worker.go @@ -0,0 +1,115 @@ +package audit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +type Worker struct { + store ledger.OutboxStore + hookURL string + client *http.Client + interval time.Duration +} + +func NewWorker(store ledger.OutboxStore, hookURL string, interval time.Duration) *Worker { + return &Worker{ + store: store, + hookURL: hookURL, + client: &http.Client{Timeout: 5 * time.Second}, + interval: interval, + } +} + +func (w *Worker) Run(ctx context.Context) { + slog.Info("audit outbox worker started", "interval", w.interval, "hook_url", w.hookURL) + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("audit outbox worker stopping") + return + case <-ticker.C: + w.flush(ctx) + } + } +} + +func (w *Worker) flush(ctx context.Context) { + entries, err := w.store.PollAuditOutbox(ctx, 100) + if err != nil { + slog.Error("audit outbox: poll failed", "error", err) + return + } + + for _, entry := range entries { + if err = w.deliver(ctx, entry); err != nil { + slog.Warn("audit outbox: delivery failed", + "id", entry.ID, + "action", entry.ActionType, + "retry_count", entry.RetryCount, + "error", err, + ) + if markErr := w.store.MarkAuditEntryFailed(ctx, entry.ID, err.Error()); markErr != nil { + slog.Error("audit outbox: mark failed error", "error", markErr) + } + continue + } + + slog.Debug("audit outbox: delivered", "id", entry.ID, "action", entry.ActionType) + if markErr := w.store.MarkAuditEntrySent(ctx, entry.ID); markErr != nil { + slog.Error("audit outbox: mark sent error", "error", markErr) + } + } +} + +func (w *Worker) deliver(ctx context.Context, entry ledger.AuditOutboxEntry) error { + body, err := json.Marshal(map[string]any{ + "action_type": entry.ActionType, + "payload": entry.Payload, + "created_at": entry.CreatedAt, + }) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.hookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := w.client.Do(req) + if err != nil { + return fmt.Errorf("post: %w", err) + } + + closeResponseBody(resp) + + if resp.StatusCode >= 300 { + return fmt.Errorf("hook returned %d", resp.StatusCode) + } + + return nil +} + +func closeResponseBody(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + err := resp.Body.Close() + if err != nil { + slog.Error("audit: close response body", "error", err) + } +} diff --git a/internal/ledger/auditor.go b/internal/ledger/auditor.go deleted file mode 100644 index d3c5b6d..0000000 --- a/internal/ledger/auditor.go +++ /dev/null @@ -1,12 +0,0 @@ -package ledger - -import "context" - -type AuditEntry struct { - ActionType string - Payload map[string]any -} - -type Auditor interface { - Log(ctx context.Context, entry AuditEntry) error -} diff --git a/internal/ledger/outbox.go b/internal/ledger/outbox.go new file mode 100644 index 0000000..d767afc --- /dev/null +++ b/internal/ledger/outbox.go @@ -0,0 +1,21 @@ +package ledger + +import ( + "context" + "time" +) + +type AuditOutboxEntry struct { + ID string + ActionType string + Payload map[string]any + RetryCount int + MaxRetries int + CreatedAt time.Time +} + +type OutboxStore interface { + PollAuditOutbox(ctx context.Context, limit int) ([]AuditOutboxEntry, error) + MarkAuditEntrySent(ctx context.Context, id string) error + MarkAuditEntryFailed(ctx context.Context, id, errMsg string) error +} diff --git a/internal/ledger/service.go b/internal/ledger/service.go index 0c30c32..c31dd60 100644 --- a/internal/ledger/service.go +++ b/internal/ledger/service.go @@ -3,8 +3,6 @@ package ledger import ( "context" "strings" - - "log/slog" ) type ( @@ -24,12 +22,11 @@ type ( } Service struct { store Store - auditor Auditor } ) -func NewService(s Store, a Auditor) *Service { - return &Service{store: s, auditor: a} +func NewService(s Store) *Service { + return &Service{store: s} } func (s *Service) CreateAccount(ctx context.Context, name string, accountType AccountType, currencyCode string) (Account, error) { @@ -95,7 +92,6 @@ func (s *Service) CreateTransaction(ctx context.Context, params CreateTransactio if err != nil { return Transaction{}, err } - s.logAudit(ctx, tx) return tx, nil } @@ -123,23 +119,6 @@ func (s *Service) GetWalletHistory(ctx context.Context, accountID string, limit, }) } -func (s *Service) logAudit(ctx context.Context, tx Transaction) { - err := s.auditor.Log(ctx, AuditEntry{ - ActionType: "transaction.created", - Payload: map[string]any{ - "transaction_id": tx.ID, - "from_account_id": tx.FromAccountID, - "to_account_id": tx.ToAccountID, - "amount": tx.Amount, - "currency_code": tx.CurrencyCode, - "status": string(tx.Status), - }, - }) - if err != nil { - slog.Error("failed to log audit entry", "error", err) - } -} - func validateCurrencyCode(code string) error { upper := strings.ToUpper(code) if len(upper) != 3 { From dc1c91702458247c41e8290d3937e4676f1f7413 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 09:55:56 +0330 Subject: [PATCH 04/20] feat(migrations): add audit outbox table with transactional outbox pattern - Create audit_outbox table to implement transactional outbox pattern for reliable audit event delivery - Add audit_status ENUM type with states: PENDING, SENT, FAILED, DEAD_LETTERED - Include retry logic with configurable max_retries and retry_count tracking - Add partial index on status='PENDING' for efficient polling of pending events - Store action_type and JSONB payload for flexible audit event representation - Track processing metadata: created_at, processed_at, and last_error for observability - Ensures audit records are created atomically within the same transaction as ledger operations, preventing data loss during webhook failures --- .../000004_create_audit_outbox_table.down.sql | 2 ++ .../000004_create_audit_outbox_table.up.sql | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 migrations/000004_create_audit_outbox_table.down.sql create mode 100644 migrations/000004_create_audit_outbox_table.up.sql diff --git a/migrations/000004_create_audit_outbox_table.down.sql b/migrations/000004_create_audit_outbox_table.down.sql new file mode 100644 index 0000000..ba67b98 --- /dev/null +++ b/migrations/000004_create_audit_outbox_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS audit_outbox; +DROP TYPE IF EXISTS audit_status; diff --git a/migrations/000004_create_audit_outbox_table.up.sql b/migrations/000004_create_audit_outbox_table.up.sql new file mode 100644 index 0000000..7054f08 --- /dev/null +++ b/migrations/000004_create_audit_outbox_table.up.sql @@ -0,0 +1,23 @@ +-- audit_outbox implements the transactional outbox pattern. +-- Every CreateTransaction inserts a row here WITHIN THE SAME DB TRANSACTION. +-- This guarantees: if the ledger transaction commits, the audit record exists. +-- A background worker reads PENDING rows and ships them to the webhook. +-- If the webhook is down, rows stay PENDING and are retried — no audit is lost. + +CREATE TYPE audit_status AS ENUM ('PENDING', 'SENT', 'FAILED', 'DEAD_LETTERED'); + +CREATE TABLE audit_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + status audit_status NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retries INT NOT NULL DEFAULT 3, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ +); + +-- Partial index — only scans PENDING rows, which is the hot path. +CREATE INDEX idx_audit_outbox_pending ON audit_outbox (created_at) + WHERE status = 'PENDING'; From 21d1f91b4a6542696fb30b6e24eeae0c698348e1 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 09:56:05 +0330 Subject: [PATCH 05/20] feat(audit): implement outbox worker for reliable event delivery - Remove synchronous auditor from service initialization - Add audit outbox worker that polls and processes pending events - Create PollAuditOutbox, MarkAuditEntrySent, and MarkAuditEntryFailed store methods - Insert audit events into outbox table on transaction creation - Add /swagger.json endpoint to serve OpenAPI specification - Extract jsonEncode helper to eliminate repeated error suppression comments - Reorganize imports in gateway module for consistency - Configure worker with 5-second poll interval and retry logic - Enable graceful shutdown of audit worker through error group context --- cmd/ledger/main.go | 23 ++++---- internal/store/postgres/outbox.go | 76 ++++++++++++++++++++++++++ internal/store/postgres/transaction.go | 20 +++++++ internal/transport/http/gateway.go | 17 +++++- 4 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 internal/store/postgres/outbox.go diff --git a/cmd/ledger/main.go b/cmd/ledger/main.go index 9473e3f..82eaa19 100644 --- a/cmd/ledger/main.go +++ b/cmd/ledger/main.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "syscall" + "time" "golang.org/x/sync/errgroup" @@ -62,16 +63,7 @@ func run() error { slog.Info("connected to postgres") - var auditor ledger.Auditor - if cfg.Audit.HookURL != "" { - auditor = audit.NewWebhookAuditor(cfg.Audit.HookURL, cfg.Audit.Mode) - slog.Info("audit webhook configured", "url", cfg.Audit.HookURL, "mode", cfg.Audit.Mode) - } else { - auditor = &audit.NoOp{} - slog.Warn("AUDIT_HOOK_URL not set — audit logging disabled") - } - - svc := ledger.NewService(pgStore, auditor) + svc := ledger.NewService(pgStore) grpcServer := transportgrpc.NewServer(svc) gateway, err := transporthttp.NewGateway( @@ -90,6 +82,17 @@ func run() error { g, gCtx := errgroup.WithContext(ctx) + if cfg.Audit.HookURL != "" { + worker := audit.NewWorker(pgStore, cfg.Audit.HookURL, 5*time.Second) + g.Go(func() error { + worker.Run(gCtx) + return nil + }) + slog.Info("audit outbox worker configured", "hook_url", cfg.Audit.HookURL) + } else { + slog.Warn("AUDIT_HOOK_URL not set — audit outbox worker disabled") + } + g.Go(func() error { slog.Info("gRPC server starting", "port", cfg.Server.GRPCPort) return grpcServer.Start(cfg.Server.GRPCPort) diff --git a/internal/store/postgres/outbox.go b/internal/store/postgres/outbox.go new file mode 100644 index 0000000..5aa0640 --- /dev/null +++ b/internal/store/postgres/outbox.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +func (s *Store) PollAuditOutbox(ctx context.Context, limit int) ([]ledger.AuditOutboxEntry, error) { + const q = ` + SELECT id, action_type, payload, retry_count, max_retries, created_at + FROM audit_outbox + WHERE status = 'PENDING' + ORDER BY created_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ` + + rows, err := s.pool.Query(ctx, q, limit) + if err != nil { + return nil, fmt.Errorf("postgres: poll audit outbox: %w", err) + } + defer rows.Close() + + var entries []ledger.AuditOutboxEntry + for rows.Next() { + var e ledger.AuditOutboxEntry + var rawPayload []byte + + if err = rows.Scan(&e.ID, &e.ActionType, &rawPayload, &e.RetryCount, &e.MaxRetries, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("postgres: scan audit outbox row: %w", err) + } + + if err = json.Unmarshal(rawPayload, &e.Payload); err != nil { + return nil, fmt.Errorf("postgres: unmarshal audit payload: %w", err) + } + + entries = append(entries, e) + } + + return entries, rows.Err() +} + +func (s *Store) MarkAuditEntrySent(ctx context.Context, id string) error { + const q = ` + UPDATE audit_outbox + SET status = 'SENT', processed_at = NOW() + WHERE id = $1 + ` + _, err := s.pool.Exec(ctx, q, id) + return err +} + +func (s *Store) MarkAuditEntryFailed(ctx context.Context, id, errMsg string) error { + const q = ` + UPDATE audit_outbox + SET + retry_count = retry_count + 1, + last_error = $2, + status = CASE + WHEN retry_count + 1 >= max_retries THEN 'DEAD_LETTERED'::audit_status + ELSE 'PENDING'::audit_status + END, + processed_at = CASE + WHEN retry_count + 1 >= max_retries THEN NOW() + ELSE NULL + END + WHERE id = $1 + ` + _, err := s.pool.Exec(ctx, q, id, errMsg) + return err +} + +var _ ledger.OutboxStore = (*Store)(nil) diff --git a/internal/store/postgres/transaction.go b/internal/store/postgres/transaction.go index c12b96d..d9b15e7 100644 --- a/internal/store/postgres/transaction.go +++ b/internal/store/postgres/transaction.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -139,6 +140,25 @@ func (s *Store) CreateTransaction(ctx context.Context, params ledger.CreateTrans return ledger.Transaction{}, fmt.Errorf("postgres: add balance: %w", err) } + auditPayload, err := json.Marshal(map[string]any{ + "transaction_id": txID, + "from_account_id": params.FromAccountID, + "to_account_id": params.ToAccountID, + "amount": params.Amount, + "currency_code": params.CurrencyCode, + }) + if err != nil { + return ledger.Transaction{}, fmt.Errorf("postgres: marshal audit payload: %w", err) + } + + const insertOutboxQ = ` + INSERT INTO audit_outbox (action_type, payload) + VALUES ($1, $2) + ` + if _, err = tx.Exec(ctx, insertOutboxQ, "transaction.created", auditPayload); err != nil { + return ledger.Transaction{}, fmt.Errorf("postgres: insert audit outbox: %w", err) + } + const completeTxQ = ` UPDATE transactions SET status = 'COMPLETED' diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index 2bd47c6..a8a4bd5 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -2,8 +2,8 @@ package http import ( "context" - "encoding/json" _ "embed" + "encoding/json" "fmt" "net/http" @@ -42,6 +42,11 @@ func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handl w.Header().Set("Content-Type", "text/html; charset=utf-8") writeResponse(w, []byte(swaggerUIHTML)) }) + mux.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + writeResponse(w, swaggerJSON) + }) return mux, nil } @@ -52,7 +57,7 @@ func healthHandler(ping PingFunc) http.HandlerFunc { if err := ping(r.Context()); err != nil { w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck + jsonEncode(w, map[string]string{ "status": "degraded", "reason": "database unreachable", }) @@ -60,7 +65,7 @@ func healthHandler(ping PingFunc) http.HandlerFunc { } w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck + jsonEncode(w, map[string]string{"status": "ok"}) } } @@ -71,3 +76,9 @@ func writeResponse(w http.ResponseWriter, data []byte) { } } +func jsonEncode(w http.ResponseWriter, data interface{}) { + err := json.NewEncoder(w).Encode(data) + if err != nil { + panic(err) + } +} From bfd7b83756cadab5b061b9c8beb19e5a1249f224 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 12:23:33 +0330 Subject: [PATCH 06/20] feat(i18n): add internationalization and error response formatting - Add i18n package with multi-language message catalog supporting English and Farsi - Implement language negotiation via Accept-Language header parsing - Create error mapping from domain errors to localized messages - Extract HTTP error response handling into dedicated response.go module - Implement CustomErrorHandler for gRPC-gateway with proper HTTP status code mapping - Add structured error response format with error details and metadata timestamps - Remove swagger.json route handler in favor of centralized response handling - Support locale fallback to English when requested language is unavailable --- internal/i18n/messages.go | 99 ++++++++++++++++++++++++++ internal/transport/http/gateway.go | 9 +-- internal/transport/http/response.go | 106 ++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 internal/i18n/messages.go create mode 100644 internal/transport/http/response.go diff --git a/internal/i18n/messages.go b/internal/i18n/messages.go new file mode 100644 index 0000000..68ee8db --- /dev/null +++ b/internal/i18n/messages.go @@ -0,0 +1,99 @@ +package i18n + +import "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" + +type Lang string + +const ( + LangEN Lang = "en" + LangFA Lang = "fa" +) + +var catalog = map[string]map[Lang]string{ + "account_not_found": { + LangEN: "account not found", + LangFA: "حساب یافت نشد", + }, + "transaction_not_found": { + LangEN: "transaction not found", + LangFA: "تراکنش یافت نشد", + }, + "insufficient_funds": { + LangEN: "insufficient funds", + LangFA: "موجودی کافی نیست", + }, + "currency_mismatch": { + LangEN: "currency mismatch between accounts", + LangFA: "واحد پولی حساب‌ها با هم مطابقت ندارد", + }, + "duplicate_transaction": { + LangEN: "transaction with this idempotency key already exists", + LangFA: "تراکنشی با این کلید idempotency قبلاً وجود دارد", + }, + "invalid_amount": { + LangEN: "amount must be greater than zero", + LangFA: "مقدار باید بزرگتر از صفر باشد", + }, + "same_account": { + LangEN: "source and destination accounts must be different", + LangFA: "حساب مبدا و مقصد باید متفاوت باشند", + }, + "invalid_account_type": { + LangEN: "invalid account type", + LangFA: "نوع حساب نامعتبر است", + }, + "invalid_currency_code": { + LangEN: "currency code must be a 3-letter ISO 4217 code", + LangFA: "کد ارز باید یک کد سه‌حرفی ISO 4217 باشد", + }, + "internal_error": { + LangEN: "an internal error occurred", + LangFA: "خطای داخلی رخ داده است", + }, +} + +var errorKey = map[error]string{ + ledger.ErrAccountNotFound: "account_not_found", + ledger.ErrTransactionNotFound: "transaction_not_found", + ledger.ErrInsufficientFunds: "insufficient_funds", + ledger.ErrCurrencyMismatch: "currency_mismatch", + ledger.ErrDuplicateTransaction: "duplicate_transaction", + ledger.ErrInvalidAmount: "invalid_amount", + ledger.ErrSameAccount: "same_account", + ledger.ErrInvalidAccountType: "invalid_account_type", + ledger.ErrInvalidCurrencyCode: "invalid_currency_code", +} + +func Message(err error, lang Lang) string { + if lang == "" { + lang = LangEN + } + + key, ok := errorKey[err] + if !ok { + key = "internal_error" + } + + msgs, ok := catalog[key] + if !ok { + return err.Error() + } + + if msg, ok := msgs[lang]; ok { + return msg + } + + return msgs[LangEN] +} + +func Parse(acceptLanguage string) Lang { + if len(acceptLanguage) >= 2 { + switch acceptLanguage[:2] { + case "fa", "ir": + return LangFA + case "en": + return LangEN + } + } + return LangEN +} diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index a8a4bd5..fa9b244 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -20,8 +20,9 @@ var swaggerJSON []byte type PingFunc func(ctx context.Context) error func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handler, error) { - gwMux := runtime.NewServeMux() - + gwMux := runtime.NewServeMux( + runtime.WithErrorHandler(CustomErrorHandler), + ) opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), } @@ -34,10 +35,6 @@ func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handl mux.Handle("/v1/", gwMux) mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/health", healthHandler(ping)) - mux.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - writeResponse(w, swaggerJSON) - }) mux.HandleFunc("/swagger/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") writeResponse(w, []byte(swaggerUIHTML)) diff --git a/internal/transport/http/response.go b/internal/transport/http/response.go new file mode 100644 index 0000000..ba2c4f5 --- /dev/null +++ b/internal/transport/http/response.go @@ -0,0 +1,106 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/mohammad-farrokhnia/go-ledger/internal/i18n" + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +type ErrorResponse struct { + Success bool `json:"success"` + Error ErrorDetail `json:"error"` + Meta Meta `json:"meta"` +} + +type ErrorDetail struct { + Code string `json:"code"` + Message string `json:"message"` + HTTPStatus int `json:"http_status"` +} + +type Meta struct { + Timestamp string `json:"timestamp"` +} + +var grpcToHTTPStatus = map[codes.Code]int{ + codes.OK: http.StatusOK, + codes.NotFound: http.StatusNotFound, + codes.InvalidArgument: http.StatusBadRequest, + codes.FailedPrecondition: http.StatusUnprocessableEntity, + codes.AlreadyExists: http.StatusConflict, + codes.Internal: http.StatusInternalServerError, + codes.Unauthenticated: http.StatusUnauthorized, + codes.PermissionDenied: http.StatusForbidden, + codes.Unavailable: http.StatusServiceUnavailable, +} + +func CustomErrorHandler(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) { + lang := i18n.Parse(r.Header.Get("Accept-Language")) + + s, _ := status.FromError(err) + code := s.Code() + httpStatus := grpcToHTTPStatus[code] + if httpStatus == 0 { + httpStatus = http.StatusInternalServerError + } + + message := localizedMessage(s.Message(), lang) + + resp := ErrorResponse{ + Success: false, + Error: ErrorDetail{ + Code: code.String(), + Message: message, + HTTPStatus: httpStatus, + }, + Meta: Meta{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + }, + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpStatus) + jsonEncoder(w, resp) +} + +func jsonEncoder(w http.ResponseWriter, resp interface{}) { + e := json.NewEncoder(w).Encode(resp) + if e != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +func localizedMessage(grpcMessage string, lang i18n.Lang) string { + domainErrors := []error{ + ledger.ErrAccountNotFound, + ledger.ErrTransactionNotFound, + ledger.ErrInsufficientFunds, + ledger.ErrCurrencyMismatch, + ledger.ErrDuplicateTransaction, + ledger.ErrInvalidAmount, + ledger.ErrSameAccount, + ledger.ErrInvalidAccountType, + ledger.ErrInvalidCurrencyCode, + } + + for _, domainErr := range domainErrors { + if grpcMessage == domainErr.Error() { + return i18n.Message(domainErr, lang) + } + } + + if grpcMessage == "an internal error occurred" { + return i18n.Message(errors.New("internal"), lang) + } + + return grpcMessage +} From 251bf95106b58653c7c48ac1c1d27c27f5b44dc4 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 13:31:44 +0330 Subject: [PATCH 07/20] feat(ledger): add UUID validation for account and transaction IDs - Add regex pattern for UUID format validation - Implement validateUUID helper function to validate UUID format and non-empty values - Add UUID validation to GetAccount method - Add UUID validation to GetBalance method - Add UUID validation to CreateTransaction method for both from and to account IDs - Fix whitespace inconsistency in Service struct field alignment - Ensure all account ID parameters are validated before store operations --- internal/ledger/service.go | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/ledger/service.go b/internal/ledger/service.go index c31dd60..aa60f27 100644 --- a/internal/ledger/service.go +++ b/internal/ledger/service.go @@ -2,9 +2,14 @@ package ledger import ( "context" + "regexp" "strings" ) +var uuidRegex = regexp.MustCompile( + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, +) + type ( Servicer interface { CreateAccount(ctx context.Context, name string, accountType AccountType, currencyCode string) (Account, error) @@ -21,7 +26,7 @@ type ( CurrencyCode string } Service struct { - store Store + store Store } ) @@ -53,6 +58,9 @@ func (s *Service) GetAccount(ctx context.Context, id string) (Account, error) { if id == "" { return Account{}, NewValidationError("account ID cannot be empty") } + if err := validateUUID(id, "account ID"); err != nil { + return Account{}, err + } return s.store.GetAccount(ctx, id) } @@ -61,6 +69,9 @@ func (s *Service) GetBalance(ctx context.Context, accountID string) (int64, erro if accountID == "" { return 0, NewValidationError("account ID cannot be empty") } + if err := validateUUID(accountID, "account ID"); err != nil { + return 0, err + } return s.store.GetBalance(ctx, accountID) } @@ -82,6 +93,13 @@ func (s *Service) CreateTransaction(ctx context.Context, params CreateTransactio return Transaction{}, err } + if err := validateUUID(params.FromAccountID, "from account ID"); err != nil { + return Transaction{}, err + } + if err := validateUUID(params.ToAccountID, "to account ID"); err != nil { + return Transaction{}, err + } + tx, err := s.store.CreateTransaction(ctx, CreateTransactionParams{ IdempotencyKey: params.IdempotencyKey, FromAccountID: params.FromAccountID, @@ -143,4 +161,14 @@ func validateAccountType(t AccountType) error { } } +func validateUUID(id, field string) error { + if id == "" { + return NewValidationError(field + " cannot be empty") + } + if !uuidRegex.MatchString(strings.ToLower(id)) { + return NewValidationError(field + " must be a valid UUID") + } + return nil +} + var _ Servicer = (*Service)(nil) From 5cf101430a65d2a3f17e419acec49be99a943af0 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 9 Jun 2026 13:36:31 +0330 Subject: [PATCH 08/20] test(ledger): add comprehensive service unit tests with mock store - Add service_test.go with mockStore implementation for testing ledger operations - Implement test cases for account creation validation (empty name, invalid currency, invalid type) - Implement test cases for transaction creation validation (zero/negative amounts, same account, empty idempotency key) - Implement test cases for wallet history pagination (default limit, limit cap enforcement) - Remove section comment headers from Makefile for cleaner organization - Add nolint directive to i18n/messages.go for intentional Persian typography character (U+200C ZWNJ) --- Makefile | 8 -- internal/i18n/messages.go | 1 + internal/ledger/service_test.go | 217 ++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 8 deletions(-) create mode 100644 internal/ledger/service_test.go diff --git a/Makefile b/Makefile index 062edae..2c372a6 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,6 @@ export migrate migrate-down migrate-create \ gen help -# Development - ## lint: run golangci-lint lint: golangci-lint run ./... @@ -28,7 +26,6 @@ build: run: go run ./cmd/ledger -# Infrastructure ## up: start containers up: @@ -52,7 +49,6 @@ logs: db: docker exec -it ledger-postgres psql -U postgres -d ledger -# Migrations ## migrate: run all pending migrations (up) migrate: @@ -72,16 +68,12 @@ migrate-test: migrate-down-test: migrate -path migrations -database "$(DB_DSN_TEST)" down 1 -# Code Generation ## gen: generate Go code and Swagger from .proto files gen: buf generate cp api/openapi/ledger.swagger.json internal/transport/http/swagger.json -# ============================================================ -# Help -# ============================================================ ## help: print this help message help: diff --git a/internal/i18n/messages.go b/internal/i18n/messages.go index 68ee8db..a71ac3a 100644 --- a/internal/i18n/messages.go +++ b/internal/i18n/messages.go @@ -1,3 +1,4 @@ +//nolint:staticcheck // U+200C (ZWNJ) is intentional Persian typography package i18n import "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" diff --git a/internal/ledger/service_test.go b/internal/ledger/service_test.go new file mode 100644 index 0000000..dbed1c1 --- /dev/null +++ b/internal/ledger/service_test.go @@ -0,0 +1,217 @@ +package ledger_test + +import ( + "context" + "errors" + "testing" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +type mockStore struct { + accounts map[string]ledger.Account + transactions map[string]ledger.Transaction + createErr error + getErr error +} + +func newMockStore() *mockStore { + return &mockStore{ + accounts: make(map[string]ledger.Account), + transactions: make(map[string]ledger.Transaction), + } +} + +func (m *mockStore) CreateAccount(_ context.Context, p ledger.CreateAccountParams) (ledger.Account, error) { + if m.createErr != nil { + return ledger.Account{}, m.createErr + } + acc := ledger.Account{ + ID: "acc-" + p.Name, + Name: p.Name, + Type: p.Type, + CurrencyCode: p.CurrencyCode, + Balance: 0, + } + m.accounts[acc.ID] = acc + return acc, nil +} + +func (m *mockStore) GetAccount(_ context.Context, id string) (ledger.Account, error) { + if m.getErr != nil { + return ledger.Account{}, m.getErr + } + acc, ok := m.accounts[id] + if !ok { + return ledger.Account{}, ledger.ErrAccountNotFound + } + return acc, nil +} + +func (m *mockStore) GetBalance(_ context.Context, id string) (int64, error) { + acc, ok := m.accounts[id] + if !ok { + return 0, ledger.ErrAccountNotFound + } + return acc.Balance, nil +} + +func (m *mockStore) CreateTransaction(_ context.Context, p ledger.CreateTransactionParams) (ledger.Transaction, error) { + if m.createErr != nil { + return ledger.Transaction{}, m.createErr + } + tx := ledger.Transaction{ + ID: "tx-" + p.IdempotencyKey, + IdempotencyKey: p.IdempotencyKey, + FromAccountID: p.FromAccountID, + ToAccountID: p.ToAccountID, + Amount: p.Amount, + CurrencyCode: p.CurrencyCode, + Status: ledger.TransactionStatusCompleted, + } + m.transactions[p.IdempotencyKey] = tx + return tx, nil +} + +func (m *mockStore) GetTransaction(_ context.Context, id string) (ledger.Transaction, error) { + for _, tx := range m.transactions { + if tx.ID == id { + return tx, nil + } + } + return ledger.Transaction{}, ledger.ErrTransactionNotFound +} + +func (m *mockStore) GetTransactionByIdempotencyKey(_ context.Context, key string) (ledger.Transaction, error) { + tx, ok := m.transactions[key] + if !ok { + return ledger.Transaction{}, ledger.ErrTransactionNotFound + } + return tx, nil +} + +func (m *mockStore) GetWalletHistory(_ context.Context, _ ledger.GetWalletHistoryParams) ([]ledger.Entry, error) { + return nil, nil +} + + +func TestService_CreateAccount_EmptyName(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateAccount(context.Background(), "", ledger.AccountTypeUser, "USD") + if err == nil { + t.Fatal("expected error for empty name") + } + + var valErr *ledger.ValidationError + if !errors.As(err, &valErr) { + t.Errorf("expected ValidationError, got %T: %v", err, err) + } +} + +func TestService_CreateAccount_InvalidCurrency(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + cases := []string{"", "US", "USDD", "123", "u$d"} + for _, code := range cases { + _, err := svc.CreateAccount(context.Background(), "test", ledger.AccountTypeUser, code) + if err == nil { + t.Errorf("expected error for currency %q, got nil", code) + } + } +} + +func TestService_CreateAccount_InvalidType(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateAccount(context.Background(), "test", "UNKNOWN_TYPE", "USD") + if !errors.Is(err, ledger.ErrInvalidAccountType) { + t.Errorf("expected ErrInvalidAccountType, got %v", err) + } +} + +func TestService_CreateTransaction_ZeroAmount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-1", + FromAccountID: "a", + ToAccountID: "b", + Amount: 0, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrInvalidAmount) { + t.Errorf("expected ErrInvalidAmount, got %v", err) + } +} + +func TestService_CreateTransaction_NegativeAmount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-2", + FromAccountID: "a", + ToAccountID: "b", + Amount: -100, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrInvalidAmount) { + t.Errorf("expected ErrInvalidAmount, got %v", err) + } +} + +func TestService_CreateTransaction_SameAccount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-3", + FromAccountID: "same", + ToAccountID: "same", + Amount: 100, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrSameAccount) { + t.Errorf("expected ErrSameAccount, got %v", err) + } +} + +func TestService_CreateTransaction_EmptyIdempotencyKey(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "", + FromAccountID: "a", + ToAccountID: "b", + Amount: 100, + CurrencyCode: "USD", + }) + + var valErr *ledger.ValidationError + if !errors.As(err, &valErr) { + t.Errorf("expected ValidationError, got %v", err) + } +} + +func TestService_GetWalletHistory_DefaultLimit(t *testing.T) { + store := newMockStore() + svc := ledger.NewService(store) + + store.accounts["test-id"] = ledger.Account{ID: "test-id", CurrencyCode: "USD"} + + _, err := svc.GetWalletHistory(context.Background(), "test-id", 0, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestService_GetWalletHistory_CapLimit(t *testing.T) { + store := newMockStore() + svc := ledger.NewService(store) + + store.accounts["test-id"] = ledger.Account{ID: "test-id"} + + _, err := svc.GetWalletHistory(context.Background(), "test-id", 200, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} From e43da585139289a547608c16edf3c1d22fc074a3 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Sat, 13 Jun 2026 13:30:02 +0330 Subject: [PATCH 09/20] feat(http): integrate Swagger UI for OpenAPI documentation - Add swaggo/http-swagger dependency for Swagger UI integration - Update buf.gen.yaml to output OpenAPI spec to internal/transport/http - Rename merged OpenAPI file from ledger to swagger for clarity - Create swagger.swagger.json with generated OpenAPI specification - Implement swagger_ui.go module to serve Swagger UI endpoints - Update gateway.go HTTP transport layer to include Swagger routes - Add response.go utilities for consistent HTTP response formatting - Update audit worker.go for compatibility with transport changes - Fix Makefile run target with proper go run command syntax - Add go-openapi and related dependencies to support OpenAPI generation - Consolidate API documentation in application rather than separate api/ directory --- Makefile | 3 +- buf.gen.yaml | 4 +- go.mod | 14 +- go.sum | 54 ++- internal/audit/worker.go | 2 +- internal/transport/http/gateway.go | 23 +- internal/transport/http/response.go | 93 +++-- internal/transport/http/swagger.swagger.json | 379 +++++++++++++++++++ internal/transport/http/swagger_ui.go | 15 +- 9 files changed, 520 insertions(+), 67 deletions(-) create mode 100644 internal/transport/http/swagger.swagger.json diff --git a/Makefile b/Makefile index 2c372a6..948cee5 100644 --- a/Makefile +++ b/Makefile @@ -24,8 +24,7 @@ build: ## run: run the service locally run: - go run ./cmd/ledger - + -go run ./cmd/ledger ## up: start containers up: diff --git a/buf.gen.yaml b/buf.gen.yaml index 9c6c517..35b12cf 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -17,8 +17,8 @@ plugins: - paths=source_relative - local: protoc-gen-openapiv2 - out: api/openapi + out: internal/transport/http opt: - output_format=json - allow_merge=true - - merge_file_name=ledger \ No newline at end of file + - merge_file_name=swagger diff --git a/go.mod b/go.mod index 79b6b5e..ee9250d 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.5 github.com/prometheus/client_golang v1.23.2 + github.com/swaggo/http-swagger v1.3.4 golang.org/x/sync v0.20.0 google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.81.1 @@ -17,24 +18,35 @@ require ( ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect + github.com/swaggo/swag v1.8.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.51.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 547b0d8..4adf976 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,12 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,6 +16,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -29,6 +44,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -41,18 +58,28 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg= github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -67,9 +94,16 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= +github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI= +github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -88,14 +122,24 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -107,8 +151,14 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/audit/worker.go b/internal/audit/worker.go index 4e65249..43520c9 100644 --- a/internal/audit/worker.go +++ b/internal/audit/worker.go @@ -29,7 +29,7 @@ func NewWorker(store ledger.OutboxStore, hookURL string, interval time.Duration) } func (w *Worker) Run(ctx context.Context) { - slog.Info("audit outbox worker started", "interval", w.interval, "hook_url", w.hookURL) + slog.Info("audit outbox worker started", "interval", w.interval.String(), "hook_url", w.hookURL) ticker := time.NewTicker(w.interval) defer ticker.Stop() diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index fa9b244..8f9b5ed 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -13,6 +13,7 @@ import ( "google.golang.org/grpc/credentials/insecure" ledgerv1 "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1" + "google.golang.org/protobuf/encoding/protojson" ) var swaggerJSON []byte @@ -20,8 +21,15 @@ var swaggerJSON []byte type PingFunc func(ctx context.Context) error func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handler, error) { + gwMux := runtime.NewServeMux( runtime.WithErrorHandler(CustomErrorHandler), + runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: false, + }, + }), ) opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -44,7 +52,6 @@ func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handl w.Header().Set("Access-Control-Allow-Origin", "*") writeResponse(w, swaggerJSON) }) - return mux, nil } @@ -52,17 +59,17 @@ func healthHandler(ping PingFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if err := ping(r.Context()); err != nil { + dbOK := ping(r.Context()) == nil + + if !dbOK { w.WriteHeader(http.StatusServiceUnavailable) - jsonEncode(w, map[string]string{ - "status": "degraded", - "reason": "database unreachable", - }) + jsonEncode(w, errorResponse(http.StatusServiceUnavailable, "database unreachable")) return } - w.WriteHeader(http.StatusOK) - jsonEncode(w, map[string]string{"status": "ok"}) + jsonEncode(w, SuccessResponse(http.StatusOK, map[string]any{ + "db": dbOK, + })) } } diff --git a/internal/transport/http/response.go b/internal/transport/http/response.go index ba2c4f5..ea7dab0 100644 --- a/internal/transport/http/response.go +++ b/internal/transport/http/response.go @@ -2,7 +2,6 @@ package http import ( "context" - "encoding/json" "errors" "net/http" "time" @@ -15,20 +14,50 @@ import ( "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" ) -type ErrorResponse struct { - Success bool `json:"success"` - Error ErrorDetail `json:"error"` - Meta Meta `json:"meta"` +const ( + appName = "go-ledger" + appVersion = "1.0.0" +) + +type Meta struct { + Name string `json:"name"` + Version string `json:"version"` + Code int `json:"code"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` } -type ErrorDetail struct { - Code string `json:"code"` - Message string `json:"message"` - HTTPStatus int `json:"http_status"` +type Response struct { + Data map[string]any `json:"data"` + Meta Meta `json:"meta"` } -type Meta struct { - Timestamp string `json:"timestamp"` +func newMeta(httpCode int, message string) Meta { + return Meta{ + Name: appName, + Version: appVersion, + Code: httpCode, + Message: message, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } +} + +func SuccessResponse(httpCode int, data map[string]any) Response { + d := map[string]any{"success": true} + for k, v := range data { + d[k] = v + } + return Response{ + Data: d, + Meta: newMeta(httpCode, http.StatusText(httpCode)), + } +} + +func errorResponse(httpCode int, message string) Response { + return Response{ + Data: map[string]any{"success": false}, + Meta: newMeta(httpCode, message), + } } var grpcToHTTPStatus = map[codes.Code]int{ @@ -43,40 +72,28 @@ var grpcToHTTPStatus = map[codes.Code]int{ codes.Unavailable: http.StatusServiceUnavailable, } -func CustomErrorHandler(ctx context.Context, mux *runtime.ServeMux, m runtime.Marshaler, w http.ResponseWriter, r *http.Request, err error) { +func CustomErrorHandler( + ctx context.Context, + mux *runtime.ServeMux, + m runtime.Marshaler, + w http.ResponseWriter, + r *http.Request, + err error, +) { lang := i18n.Parse(r.Header.Get("Accept-Language")) - s, _ := status.FromError(err) code := s.Code() - httpStatus := grpcToHTTPStatus[code] - if httpStatus == 0 { + + httpStatus, ok := grpcToHTTPStatus[code] + if !ok { httpStatus = http.StatusInternalServerError } message := localizedMessage(s.Message(), lang) - resp := ErrorResponse{ - Success: false, - Error: ErrorDetail{ - Code: code.String(), - Message: message, - HTTPStatus: httpStatus, - }, - Meta: Meta{ - Timestamp: time.Now().UTC().Format(time.RFC3339), - }, - } - w.Header().Set("Content-Type", "application/json") w.WriteHeader(httpStatus) - jsonEncoder(w, resp) -} - -func jsonEncoder(w http.ResponseWriter, resp interface{}) { - e := json.NewEncoder(w).Encode(resp) - if e != nil { - http.Error(w, "Failed to encode response", http.StatusInternalServerError) - } + jsonEncode(w, errorResponse(httpStatus, message)) } func localizedMessage(grpcMessage string, lang i18n.Lang) string { @@ -98,9 +115,5 @@ func localizedMessage(grpcMessage string, lang i18n.Lang) string { } } - if grpcMessage == "an internal error occurred" { - return i18n.Message(errors.New("internal"), lang) - } - - return grpcMessage + return i18n.Message(errors.New("internal"), lang) } diff --git a/internal/transport/http/swagger.swagger.json b/internal/transport/http/swagger.swagger.json new file mode 100644 index 0000000..3c9a34a --- /dev/null +++ b/internal/transport/http/swagger.swagger.json @@ -0,0 +1,379 @@ +{ + "swagger": "2.0", + "info": { + "title": "go-ledger API", + "description": "A high-consistency, bank-grade financial transaction service.", + "version": "1.0" + }, + "tags": [ + { + "name": "LedgerService" + } + ], + "schemes": [ + "http", + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/v1/transactions": { + "post": { + "operationId": "LedgerService_CreateTransaction", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateTransactionResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateTransactionRequest" + } + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets": { + "post": { + "operationId": "LedgerService_CreateWallet", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateWalletResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateWalletRequest" + } + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets/{walletId}/balance": { + "get": { + "operationId": "LedgerService_GetBalance", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetBalanceResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets/{walletId}/history": { + "get": { + "operationId": "LedgerService_GetWalletHistory", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetWalletHistoryResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "pageToken", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "LedgerService" + ] + } + } + }, + "definitions": { + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "v1AccountType": { + "type": "string", + "enum": [ + "ACCOUNT_TYPE_UNSPECIFIED", + "ACCOUNT_TYPE_USER", + "ACCOUNT_TYPE_SYSTEM", + "ACCOUNT_TYPE_PROVIDER" + ], + "default": "ACCOUNT_TYPE_UNSPECIFIED" + }, + "v1CreateTransactionRequest": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string" + }, + "fromAccountId": { + "type": "string" + }, + "toAccountId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1CreateTransactionResponse": { + "type": "object", + "properties": { + "transaction": { + "$ref": "#/definitions/v1Transaction" + } + } + }, + "v1CreateWalletRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1AccountType" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1CreateWalletResponse": { + "type": "object", + "properties": { + "wallet": { + "$ref": "#/definitions/v1Wallet" + } + } + }, + "v1Entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "accountId": { + "type": "string" + }, + "transactionId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "v1GetBalanceResponse": { + "type": "object", + "properties": { + "walletId": { + "type": "string" + }, + "balance": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1GetWalletHistoryResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1Entry" + } + }, + "nextPageToken": { + "type": "string" + } + } + }, + "v1Transaction": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "fromAccountId": { + "type": "string" + }, + "toAccountId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v1TransactionStatus" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "v1TransactionStatus": { + "type": "string", + "enum": [ + "TRANSACTION_STATUS_UNSPECIFIED", + "TRANSACTION_STATUS_PENDING", + "TRANSACTION_STATUS_COMPLETED", + "TRANSACTION_STATUS_FAILED" + ], + "default": "TRANSACTION_STATUS_UNSPECIFIED" + }, + "v1Wallet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1AccountType" + }, + "currencyCode": { + "type": "string" + }, + "balance": { + "type": "string", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/internal/transport/http/swagger_ui.go b/internal/transport/http/swagger_ui.go index 516e4d9..5c44d53 100644 --- a/internal/transport/http/swagger_ui.go +++ b/internal/transport/http/swagger_ui.go @@ -6,26 +6,19 @@ const swaggerUIHTML = ` go-ledger API - - +
- - + + - - - -` From 6b2e794ba29a888a516bb54fc7368a57966245d3 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Tue, 16 Jun 2026 14:51:58 +0330 Subject: [PATCH 13/20] fix: add graphify-out directory to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index db524fd..e49c5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ configs/config.yaml # Editor/IDE .idea/ .vscode/ +graphify-out/ \ No newline at end of file From 8f638d265aaa682965ac0f2b5756e9ae3f6b6706 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 08:17:18 +0330 Subject: [PATCH 14/20] fix: update README for accuracy and add API documentation details --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c302ab4..3af2211 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ graph TD B --> G[ledger.Service] G -->|Store interface| H[postgres.Store] - G -->|Auditor interface| I[audit.WebhookAuditor] + G -->|OutboxStore interface| I[audit.Worker] H -->|SELECT FOR UPDATE| J[(PostgreSQL 16)] - I -->|POST /ingest| K[go-ingestor] + I -->|polls outbox, POST /ingest| K[go-ingestor] ``` ### Why hexagonal architecture @@ -29,8 +29,8 @@ go-ledger has three input transports: gRPC, HTTP Gateway, and a future event con ``` cmd/ledger (composition root) ↓ -internal/ledger (domain: types + Store port + Auditor port + Service) - ↓ implements Store ↓ implements Auditor +internal/ledger (domain: types + Store port + OutboxStore port + Service) + ↓ implements Store ↓ implements OutboxStore internal/store/postgres internal/audit ↓ PostgreSQL @@ -86,6 +86,14 @@ Server → sees ON CONFLICT DO NOTHING, returns original transaction Money → moved exactly once ``` +## API Documentation + +Swagger UI is available at `http://localhost:8080/swagger/` when the service is running. The raw OpenAPI spec is at `http://localhost:8080/swagger.json`. + +## i18n + +Error messages are translated based on the client's `Accept-Language` header. Supported languages: **English** (default) and **Farsi**. Every error response includes a `messageCode` field (e.g. `INSUFFICIENT_FUNDS`) that clients can use to render their own localized strings independently of the server-side translations. + ## Tech Stack | Layer | Technology | @@ -99,31 +107,32 @@ Money → moved exactly once | Logging | `log/slog` (JSON) | | Metrics | Prometheus | | Config | koanf | +| i18n | Built-in (English + Farsi) | ## Quick Start **Prerequisites:** Go 1.26+, Docker, `buf`, `golang-migrate`, `grpcurl` ```bash -# Clone and set up environment +# Clone and configure git clone https://github.com/mohammad-farrokhnia/go-ledger.git cd go-ledger -cp .env.example .env cp configs/config.example.yaml configs/config.yaml +# Edit configs/config.yaml — at minimum set database.dsn # Start Postgres make up -# Run migrations +# Run migrations (requires DB_DSN env var or set in .env) make migrate # Start the service make run ``` -The service starts three ports: +The service starts two ports: - `:9090` — gRPC -- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/healthz`) + metrics (`/metrics`) +- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/health`) + metrics (`/metrics`) ## API Examples @@ -179,14 +188,22 @@ grpcurl -plaintext -d '{ ### Health check ```bash -curl http://localhost:8080/health -# {"status":"ok"} +curl http://localhost:8080/health | jq . +# { +# "data": {"status": "ok", "version": "1.0.0", "app": "go-ledger", "checks": {"postgres": "ok"}}, +# "meta": {"appName": "go-ledger", "version": "1.0.0", "timestamp": "...", "messageCode": "HEALTH_OK", "message": "healthy"} +# } ``` ### Prometheus metrics ```bash curl http://localhost:8080/metrics | grep ledger +# ledger_transactions_total{status="success",error_type=""} +# ledger_transactions_total{status="fail",error_type="insufficient_funds"} +# ledger_transaction_duration_seconds_* +# ledger_db_errors_total +# ledger_grpc_active_connections ``` ## Developer Commands @@ -207,18 +224,23 @@ make help # list all targets ## Configuration -Copy `.env.example` → `.env`. Never commit `.env`. +The service loads `configs/config.yaml` first, then overlays any matching environment variables. Create your config by copying the template: + +```bash +cp configs/config.example.yaml configs/config.yaml +``` + +The Makefile (`make migrate`, `make db`) reads a `.env` file for convenience — create one with `DB_DSN=...` if needed. Never commit `.env`. | Variable | Default | Description | |---|---|---| | `DB_DSN` | required | Postgres connection string | | `GRPC_PORT` | `9090` | gRPC server port | -| `HTTP_PORT` | `8080` | HTTP gateway port | -| `METRICS_PORT` | `9091` | Prometheus metrics port | +| `HTTP_PORT` | `8080` | HTTP gateway port (also serves `/metrics`, `/health`, `/swagger/`) | | `AUDIT_MODE` | `async` | `sync` blocks, `async` fire-and-forget | | `AUDIT_HOOK_URL` | — | go-ingestor endpoint, disabled if empty | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | -| `COMPOSE_PROJECT_NAME` | `docker` | the group name of continer in docker | +| `COMPOSE_PROJECT_NAME` | `docker` | Docker Compose project name | ## Running Tests @@ -251,10 +273,12 @@ docker compose -f deployments/docker/docker-compose.yml up | `account not found` | `NOT_FOUND` | Account ID does not exist | | `transaction not found` | `NOT_FOUND` | Transaction ID does not exist | | `insufficient funds` | `FAILED_PRECONDITION` | Sender balance too low | -| `currency mismatch` | `INVALID_ARGUMENT` | Accounts have different currencies | +| `currency mismatch between accounts` | `INVALID_ARGUMENT` | Accounts have different currencies | | `transaction with this idempotency key already exists` | `ALREADY_EXISTS` | Duplicate — fetch original | | `amount must be greater than zero` | `INVALID_ARGUMENT` | Invalid amount | | `source and destination accounts must be different` | `INVALID_ARGUMENT` | Same account | +| `invalid account type` | `INVALID_ARGUMENT` | Account type is not USER or SYSTEM | +| `currency code must be a 3-letter ISO 4217 code` | `INVALID_ARGUMENT` | Malformed currency code | | `an internal error occurred` | `INTERNAL` | Server error (details logged server-side) | ## Project Structure @@ -264,13 +288,18 @@ api/proto/ledger/v1/ proto definition + generated Go code cmd/ledger/ entrypoint — composition root only configs/ config.yaml template deployments/docker/ Dockerfile + docker-compose +docs/ API documentation internal/ - audit/ Auditor implementations (NoOp, Webhook) + audit/ outbox worker — polls DB, POSTs events to webhook config/ koanf config loader + i18n/ error message translation (English + Farsi) ledger/ domain: types, errors, Store/Auditor ports, Service metrics/ Prometheus metric definitions store/postgres/ Postgres implementation of ledger.Store transport/grpc/ gRPC server, handler, interceptors, mappers - transport/http/ gRPC-Gateway, health check, Swagger UI + transport/http/ gRPC-Gateway, health check, Swagger UI, error handler migrations/ SQL migration files (up + down) +pkg/ + apperr/ structured application error type + response/ HTTP response envelope helpers ``` From 81f9f228a53815908ed4ac72aed47b2e199deef2 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 08:29:36 +0330 Subject: [PATCH 15/20] fix: update CI workflows and add golangci-lint configuration --- .github/buf.gen.yaml | 2 -- .github/workflows/ci.yml | 22 +++++++------ .github/workflows/proto.yml | 15 +++++++-- .golangci-lint.yaml => .golangci.yaml | 0 internal/config/config.go | 12 ++++---- internal/i18n/codes.go | 22 ++++++------- internal/ledger/errors.go | 16 +++++----- internal/ledger/service_test.go | 1 - internal/metrics/metrics.go | 2 +- internal/store/postgres/transaction.go | 2 +- internal/store/postgres/transaction_test.go | 1 - internal/transport/grpc/errors.go | 2 +- internal/transport/grpc/server.go | 4 ++- internal/transport/http/gateway.go | 3 +- internal/transport/http/response.go | 1 - pkg/apperr/apperr.go | 34 ++++++++++----------- 16 files changed, 74 insertions(+), 65 deletions(-) delete mode 100644 .github/buf.gen.yaml rename .golangci-lint.yaml => .golangci.yaml (100%) diff --git a/.github/buf.gen.yaml b/.github/buf.gen.yaml deleted file mode 100644 index 65fbc42..0000000 --- a/.github/buf.gen.yaml +++ /dev/null @@ -1,2 +0,0 @@ -version: v2 -# plugins will be added in Task 2.3 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a017563..c7f26d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: pull_request: - branches: [develop, main] + branches: [master, develop] push: - branches: [develop] + branches: [master, develop] jobs: lint: @@ -16,14 +16,13 @@ jobs: with: go-version: '1.26' cache: true - - uses: golangci/golangci-lint-action@v8 + - uses: golangci/golangci-lint-action@v6 with: - version: v1.59.1 + version: v2.1.6 test: name: Test runs-on: ubuntu-latest - needs: lint services: postgres: image: postgres:16-alpine @@ -45,18 +44,21 @@ jobs: go-version: '1.26' cache: true - run: go mod download - - name: Run migrations on test DB + - name: Install migrate run: | - curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xvz - ./migrate -path migrations -database "postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable" up - - run: go test -race -count=1 -timeout 120s ./... + curl -sSL https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xz + sudo mv migrate /usr/local/bin/migrate + - name: Run migrations + run: migrate -path migrations -database "postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable" up + - name: Run tests + run: go test -race -count=1 -timeout 120s ./... env: DB_DSN_TEST: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable build: name: Build runs-on: ubuntu-latest - needs: test + needs: [lint, test] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 7c5d038..a32ef73 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -1,7 +1,18 @@ name: Proto on: - workflow_dispatch: # manual trigger only + pull_request: + paths: + - 'api/proto/**' + - 'buf.yaml' + - 'buf.gen.yaml' + push: + branches: [master, develop] + paths: + - 'api/proto/**' + - 'buf.yaml' + - 'buf.gen.yaml' + workflow_dispatch: jobs: lint: @@ -14,4 +25,4 @@ jobs: version: '1.34.0' lint: true format: true - breaking: false + breaking: ${{ github.event_name == 'pull_request' }} diff --git a/.golangci-lint.yaml b/.golangci.yaml similarity index 100% rename from .golangci-lint.yaml rename to .golangci.yaml diff --git a/internal/config/config.go b/internal/config/config.go index 5ddac85..e0d4877 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,13 +43,13 @@ func Load(configPath string) (*Config, error) { } envMap := map[string]string{ - "DB_DSN": "database.dsn", - "GRPC_PORT": "server.grpc_port", - "HTTP_PORT": "server.http_port", - "METRICS_PORT": "server.metrics_port", - "AUDIT_MODE": "audit.mode", + "DB_DSN": "database.dsn", + "GRPC_PORT": "server.grpc_port", + "HTTP_PORT": "server.http_port", + "METRICS_PORT": "server.metrics_port", + "AUDIT_MODE": "audit.mode", "AUDIT_HOOK_URL": "audit.hook_url", - "LOG_LEVEL": "log.level", + "LOG_LEVEL": "log.level", } if err := k.Load(env.ProviderWithValue("", ".", func(s, v string) (string, interface{}) { diff --git a/internal/i18n/codes.go b/internal/i18n/codes.go index a840195..124d823 100644 --- a/internal/i18n/codes.go +++ b/internal/i18n/codes.go @@ -18,17 +18,17 @@ const ( MsgConflict MessageCode = "CONFLICT" // Domain — accounts. - MsgAccountNotFound MessageCode = "ACCOUNT_NOT_FOUND" - MsgAccountCreated MessageCode = "ACCOUNT_CREATED" + MsgAccountNotFound MessageCode = "ACCOUNT_NOT_FOUND" + MsgAccountCreated MessageCode = "ACCOUNT_CREATED" // Domain — transactions. - MsgTransactionCreated MessageCode = "TRANSACTION_CREATED" - MsgTransactionNotFound MessageCode = "TRANSACTION_NOT_FOUND" - MsgInsufficientFunds MessageCode = "INSUFFICIENT_FUNDS" - MsgCurrencyMismatch MessageCode = "CURRENCY_MISMATCH" - MsgDuplicateTransaction MessageCode = "DUPLICATE_TRANSACTION" - MsgInvalidAmount MessageCode = "INVALID_AMOUNT" - MsgSameAccount MessageCode = "SAME_ACCOUNT" - MsgInvalidAccountType MessageCode = "INVALID_ACCOUNT_TYPE" - MsgInvalidCurrencyCode MessageCode = "INVALID_CURRENCY_CODE" + MsgTransactionCreated MessageCode = "TRANSACTION_CREATED" + MsgTransactionNotFound MessageCode = "TRANSACTION_NOT_FOUND" + MsgInsufficientFunds MessageCode = "INSUFFICIENT_FUNDS" + MsgCurrencyMismatch MessageCode = "CURRENCY_MISMATCH" + MsgDuplicateTransaction MessageCode = "DUPLICATE_TRANSACTION" + MsgInvalidAmount MessageCode = "INVALID_AMOUNT" + MsgSameAccount MessageCode = "SAME_ACCOUNT" + MsgInvalidAccountType MessageCode = "INVALID_ACCOUNT_TYPE" + MsgInvalidCurrencyCode MessageCode = "INVALID_CURRENCY_CODE" ) diff --git a/internal/ledger/errors.go b/internal/ledger/errors.go index 2cc4d67..7b6af25 100644 --- a/internal/ledger/errors.go +++ b/internal/ledger/errors.go @@ -3,15 +3,15 @@ package ledger import "errors" var ( - ErrAccountNotFound = errors.New("account not found") - ErrTransactionNotFound = errors.New("transaction not found") - ErrInsufficientFunds = errors.New("insufficient funds") - ErrCurrencyMismatch = errors.New("currency mismatch between accounts") + ErrAccountNotFound = errors.New("account not found") + ErrTransactionNotFound = errors.New("transaction not found") + ErrInsufficientFunds = errors.New("insufficient funds") + ErrCurrencyMismatch = errors.New("currency mismatch between accounts") ErrDuplicateTransaction = errors.New("transaction with this idempotency key already exists") - ErrInvalidAmount = errors.New("amount must be greater than zero") - ErrSameAccount = errors.New("source and destination accounts must be different") - ErrInvalidAccountType = errors.New("invalid account type") - ErrInvalidCurrencyCode = errors.New("currency code must be a 3-letter ISO 4217 code") + ErrInvalidAmount = errors.New("amount must be greater than zero") + ErrSameAccount = errors.New("source and destination accounts must be different") + ErrInvalidAccountType = errors.New("invalid account type") + ErrInvalidCurrencyCode = errors.New("currency code must be a 3-letter ISO 4217 code") ) type ValidationError struct { diff --git a/internal/ledger/service_test.go b/internal/ledger/service_test.go index dbed1c1..b7c9c3d 100644 --- a/internal/ledger/service_test.go +++ b/internal/ledger/service_test.go @@ -94,7 +94,6 @@ func (m *mockStore) GetWalletHistory(_ context.Context, _ ledger.GetWalletHistor return nil, nil } - func TestService_CreateAccount_EmptyName(t *testing.T) { svc := ledger.NewService(newMockStore()) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index b436aee..abb92f2 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -45,4 +45,4 @@ func init() { TransactionsTotal.WithLabelValues("fail", "invalid_input").Add(0) TransactionsTotal.WithLabelValues("fail", "not_found").Add(0) TransactionsTotal.WithLabelValues("fail", "internal").Add(0) -} \ No newline at end of file +} diff --git a/internal/store/postgres/transaction.go b/internal/store/postgres/transaction.go index d9b15e7..d0a0f1f 100644 --- a/internal/store/postgres/transaction.go +++ b/internal/store/postgres/transaction.go @@ -158,7 +158,7 @@ func (s *Store) CreateTransaction(ctx context.Context, params ledger.CreateTrans if _, err = tx.Exec(ctx, insertOutboxQ, "transaction.created", auditPayload); err != nil { return ledger.Transaction{}, fmt.Errorf("postgres: insert audit outbox: %w", err) } - + const completeTxQ = ` UPDATE transactions SET status = 'COMPLETED' diff --git a/internal/store/postgres/transaction_test.go b/internal/store/postgres/transaction_test.go index b8f8b24..1fa976c 100644 --- a/internal/store/postgres/transaction_test.go +++ b/internal/store/postgres/transaction_test.go @@ -61,7 +61,6 @@ func seedWallets(t *testing.T, s *postgres.Store, amount int64) (string, string) return sys.ID, usr.ID } - func TestCreateTransaction_Success(t *testing.T) { s := setupStore(t) ctx := context.Background() diff --git a/internal/transport/grpc/errors.go b/internal/transport/grpc/errors.go index 534c634..508e4a0 100644 --- a/internal/transport/grpc/errors.go +++ b/internal/transport/grpc/errors.go @@ -67,4 +67,4 @@ func logHandlerError(ctx context.Context, method string, err error) { default: slog.ErrorContext(ctx, method+" failed", "error", err) } -} \ No newline at end of file +} diff --git a/internal/transport/grpc/server.go b/internal/transport/grpc/server.go index d9ffdd6..5ee8d56 100644 --- a/internal/transport/grpc/server.go +++ b/internal/transport/grpc/server.go @@ -1,6 +1,7 @@ package grpc import ( + "context" "fmt" "net" @@ -30,7 +31,8 @@ func NewServer(svc ledger.Servicer) *Server { } func (s *Server) Start(port string) error { - lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port)) + lc := net.ListenConfig{} + lis, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf(":%s", port)) if err != nil { return fmt.Errorf("grpc: listen on port %s: %w", port, err) } diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index f5f2c37..0b30ea7 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -16,7 +16,6 @@ import ( ledgerv1 "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1" ) - //go:embed swagger.json var swaggerJSON []byte @@ -60,4 +59,4 @@ func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handl )) return mux, nil -} \ No newline at end of file +} diff --git a/internal/transport/http/response.go b/internal/transport/http/response.go index 8a7b1d7..59f6658 100644 --- a/internal/transport/http/response.go +++ b/internal/transport/http/response.go @@ -105,7 +105,6 @@ func resolveErrorCodes(code codes.Code, message string) (i18n.MessageCode, strin } } - func healthHandler(ping PingFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { lang := r.Header.Get("Accept-Language") diff --git a/pkg/apperr/apperr.go b/pkg/apperr/apperr.go index f331556..333fa3c 100644 --- a/pkg/apperr/apperr.go +++ b/pkg/apperr/apperr.go @@ -25,17 +25,17 @@ const ( // Code is a stable string identifier for each error condition. // Clients can rely on these — they never change. const ( - ErrAccountNotFound = "ACCOUNT_NOT_FOUND" - ErrTransactionNotFound = "TRANSACTION_NOT_FOUND" - ErrInsufficientFunds = "INSUFFICIENT_FUNDS" - ErrCurrencyMismatch = "CURRENCY_MISMATCH" + ErrAccountNotFound = "ACCOUNT_NOT_FOUND" + ErrTransactionNotFound = "TRANSACTION_NOT_FOUND" + ErrInsufficientFunds = "INSUFFICIENT_FUNDS" + ErrCurrencyMismatch = "CURRENCY_MISMATCH" ErrDuplicateTransaction = "DUPLICATE_TRANSACTION" - ErrInvalidAmount = "INVALID_AMOUNT" - ErrSameAccount = "SAME_ACCOUNT" - ErrInvalidAccountType = "INVALID_ACCOUNT_TYPE" - ErrInvalidCurrencyCode = "INVALID_CURRENCY_CODE" - ErrInternal = "INTERNAL_ERROR" - ErrBadRequest = "BAD_REQUEST" + ErrInvalidAmount = "INVALID_AMOUNT" + ErrSameAccount = "SAME_ACCOUNT" + ErrInvalidAccountType = "INVALID_ACCOUNT_TYPE" + ErrInvalidCurrencyCode = "INVALID_CURRENCY_CODE" + ErrInternal = "INTERNAL_ERROR" + ErrBadRequest = "BAD_REQUEST" ) // codeToMessageCode maps error codes to i18n message codes. @@ -84,9 +84,9 @@ func (e *AppError) Error() string { return e.code } -func (e *AppError) Code() string { return e.code } -func (e *AppError) Type() Type { return e.errType } -func (e *AppError) Unwrap() error { return e.underlying } +func (e *AppError) Code() string { return e.code } +func (e *AppError) Type() Type { return e.errType } +func (e *AppError) Unwrap() error { return e.underlying } func (e *AppError) WithContext(key string, val any) *AppError { e.ctx[key] = val @@ -113,8 +113,8 @@ func (e *AppError) HTTPStatus() int { } // Constructors. -func NotFound(code string) *AppError { return New(code, TypeNotFound) } -func Validation(code string) *AppError { return New(code, TypeValidation) } -func Conflict(code string) *AppError { return New(code, TypeConflict) } -func Internal(code string) *AppError { return New(code, TypeInternal) } +func NotFound(code string) *AppError { return New(code, TypeNotFound) } +func Validation(code string) *AppError { return New(code, TypeValidation) } +func Conflict(code string) *AppError { return New(code, TypeConflict) } +func Internal(code string) *AppError { return New(code, TypeInternal) } func InternalErr(code string, err error) *AppError { return NewWithErr(code, TypeInternal, err) } From aea5e375753f49bb3caac87940f433d1ce39d8c0 Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 08:59:53 +0330 Subject: [PATCH 16/20] fix: replace static idempotency keys with dynamic key generation in transaction tests --- internal/store/postgres/transaction_test.go | 23 +++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/store/postgres/transaction_test.go b/internal/store/postgres/transaction_test.go index 1fa976c..a5d6556 100644 --- a/internal/store/postgres/transaction_test.go +++ b/internal/store/postgres/transaction_test.go @@ -2,6 +2,7 @@ package postgres_test import ( "context" + "crypto/rand" "errors" "fmt" "os" @@ -12,6 +13,16 @@ import ( "github.com/mohammad-farrokhnia/go-ledger/internal/store/postgres" ) +func newKey() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + func setupStore(t *testing.T) *postgres.Store { t.Helper() @@ -67,7 +78,7 @@ func TestCreateTransaction_Success(t *testing.T) { sysID, userID := seedWallets(t, s, 1000) tx, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "a0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -95,7 +106,7 @@ func TestCreateTransaction_InsufficientFunds(t *testing.T) { sysID, userID := seedWallets(t, s, 50) _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "b0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -117,7 +128,7 @@ func TestCreateTransaction_Idempotency(t *testing.T) { sysID, userID := seedWallets(t, s, 1000) params := ledger.CreateTransactionParams{ - IdempotencyKey: "d0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -156,7 +167,7 @@ func TestCreateTransaction_CurrencyMismatch(t *testing.T) { }) _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "e0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: usd.ID, ToAccountID: irr.ID, Amount: 100, @@ -188,7 +199,7 @@ func TestCreateTransaction_ConcurrentDeductions(t *testing.T) { go func(i int) { defer wg.Done() - key := fmt.Sprintf("f0000000-0000-0000-0000-%012d", i) + key := newKey() _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ IdempotencyKey: key, @@ -249,7 +260,7 @@ func TestCreateTransaction_ConcurrentContention(t *testing.T) { go func(i int) { defer wg.Done() - key := fmt.Sprintf("a1000000-0000-0000-0000-%012d", i) + key := newKey() _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ IdempotencyKey: key, From 4172838fbc8be4034419bab21cb1e0c5c038c7cf Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 09:22:04 +0330 Subject: [PATCH 17/20] fix: update CI workflow to use Go 1.26 and enhance protoc plugin installation --- .github/workflows/ci.yml | 41 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7f26d2..8b36cdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,22 +7,55 @@ on: branches: [master, develop] jobs: + generate: + name: Generate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + - name: Install protoc plugins + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest + - name: Install buf + run: | + curl -sSL "https://github.com/bufbuild/buf/releases/download/v1.34.0/buf-Linux-x86_64" \ + -o /usr/local/bin/buf + chmod +x /usr/local/bin/buf + - name: Generate + run: buf generate + - uses: actions/upload-artifact@v4 + with: + name: generated-proto + path: api/proto/ledger/v1/*.pb.go + retention-days: 1 + lint: name: Lint runs-on: ubuntu-latest + needs: generate steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto - uses: actions/setup-go@v5 with: go-version: '1.26' cache: true - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v7 with: version: v2.1.6 test: name: Test runs-on: ubuntu-latest + needs: generate services: postgres: image: postgres:16-alpine @@ -39,6 +72,9 @@ jobs: --health-retries 5 steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto - uses: actions/setup-go@v5 with: go-version: '1.26' @@ -61,6 +97,9 @@ jobs: needs: [lint, test] steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto - uses: actions/setup-go@v5 with: go-version: '1.26' From 971a6e6d682b9a74d3bad8d8e5c9b40b89b3aeec Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 10:45:50 +0330 Subject: [PATCH 18/20] fix: update CI workflows to use latest buf release and streamline artifact paths --- .github/workflows/ci.yml | 11 +++++++---- .github/workflows/proto.yml | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b36cdc..14942f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,15 +24,14 @@ jobs: go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest - name: Install buf run: | - curl -sSL "https://github.com/bufbuild/buf/releases/download/v1.34.0/buf-Linux-x86_64" \ + curl -sSL "https://github.com/bufbuild/buf/releases/latest/download/buf-Linux-x86_64" \ -o /usr/local/bin/buf chmod +x /usr/local/bin/buf - - name: Generate - run: buf generate + - run: buf generate - uses: actions/upload-artifact@v4 with: name: generated-proto - path: api/proto/ledger/v1/*.pb.go + path: api/proto/ledger/v1/ retention-days: 1 lint: @@ -44,6 +43,7 @@ jobs: - uses: actions/download-artifact@v4 with: name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' @@ -51,6 +51,7 @@ jobs: - uses: golangci/golangci-lint-action@v7 with: version: v2.1.6 + install-mode: goinstall test: name: Test @@ -75,6 +76,7 @@ jobs: - uses: actions/download-artifact@v4 with: name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' @@ -100,6 +102,7 @@ jobs: - uses: actions/download-artifact@v4 with: name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index a32ef73..30bce62 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -22,7 +22,6 @@ jobs: - uses: actions/checkout@v4 - uses: bufbuild/buf-action@v1 with: - version: '1.34.0' lint: true format: true breaking: ${{ github.event_name == 'pull_request' }} From 7d7c44468a13cce3ac1b7d47fa02e5fac44b4e8d Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 10:58:48 +0330 Subject: [PATCH 19/20] fix: update proto files for consistency and streamline HTTP option syntax --- .github/workflows/proto.yml | 3 +++ api/proto/ledger/v1/ledger.proto | 12 ++++-------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 30bce62..1c65e1f 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -18,6 +18,9 @@ jobs: lint: name: Buf lint runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v4 - uses: bufbuild/buf-action@v1 diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index 589fd35..8d94323 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -2,12 +2,12 @@ syntax = "proto3"; package ledger.v1; -option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1"; - import "google/api/annotations.proto"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1"; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { title: "go-ledger API" @@ -116,9 +116,7 @@ service LedgerService { } rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse) { - option (google.api.http) = { - get: "/v1/wallets/{wallet_id}/balance" - }; + option (google.api.http) = {get: "/v1/wallets/{wallet_id}/balance"}; } rpc CreateTransaction(CreateTransactionRequest) returns (CreateTransactionResponse) { @@ -129,8 +127,6 @@ service LedgerService { } rpc GetWalletHistory(GetWalletHistoryRequest) returns (GetWalletHistoryResponse) { - option (google.api.http) = { - get: "/v1/wallets/{wallet_id}/history" - }; + option (google.api.http) = {get: "/v1/wallets/{wallet_id}/history"}; } } From f96d2b5127d9813946c021ca38b0bed95cb637bc Mon Sep 17 00:00:00 2001 From: "mohammad.farrokhnia" Date: Wed, 17 Jun 2026 11:06:28 +0330 Subject: [PATCH 20/20] fix: disable formatting in CI workflow for buf action and clean up proto file --- .github/workflows/proto.yml | 2 +- api/proto/ledger/v1/ledger.proto | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 1c65e1f..72a9554 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -26,5 +26,5 @@ jobs: - uses: bufbuild/buf-action@v1 with: lint: true - format: true + format: false breaking: ${{ github.event_name == 'pull_request' }} diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index 8d94323..41f240d 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -7,7 +7,6 @@ import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1"; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { title: "go-ledger API"