feat(api): add card-to-iban HTTP endpoint

This commit is contained in:
2026-09-21 11:46:35 +03:30
parent b713a4daf1
commit 5dbfe0a12f
4 changed files with 703 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
package httpapi
import "net/http"
func AllowOrigin(
origin string,
next http.Handler,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Vary", "Origin")
if r.Header.Get("Origin") == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,77 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestAllowOrigin(t *testing.T) {
const allowedOrigin = "http://127.0.0.1:3000"
nextCalled := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
w.WriteHeader(http.StatusNoContent)
})
handler := AllowOrigin(allowedOrigin, next)
request := httptest.NewRequest(http.MethodPost, "/api/v1/card-to-iban", nil)
request.Header.Set("Origin", allowedOrigin)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if !nextCalled {
t.Fatal("next handler was not called")
}
if got := response.Header().Get("Access-Control-Allow-Origin"); got != allowedOrigin {
t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, allowedOrigin)
}
}
func TestAllowOriginDoesNotReflectUnknownOrigin(t *testing.T) {
handler := AllowOrigin(
"http://127.0.0.1:3000",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
)
request := httptest.NewRequest(http.MethodPost, "/api/v1/card-to-iban", nil)
request.Header.Set("Origin", "https://example.com")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if got := response.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q, want empty", got)
}
}
func TestAllowOriginHandlesPreflight(t *testing.T) {
nextCalled := false
handler := AllowOrigin(
"http://127.0.0.1:3000",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
nextCalled = true
}),
)
request := httptest.NewRequest(http.MethodOptions, "/api/v1/card-to-iban", nil)
request.Header.Set("Origin", "http://127.0.0.1:3000")
request.Header.Set("Access-Control-Request-Method", http.MethodPost)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", response.Code)
}
if nextCalled {
t.Error("preflight request reached the next handler")
}
if got := response.Header().Get("Access-Control-Allow-Methods"); got != "POST, OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q", got)
}
}

View File

@@ -0,0 +1,308 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"card-to-iban/internal/audit"
"card-to-iban/internal/card"
"card-to-iban/internal/service"
"card-to-iban/internal/zarinhub"
)
const maxRequestBodySize = 1024
var (
errInvalidRequest = errors.New("invalid request body")
errMethodNotAllowed = errors.New("method not allowed")
)
type Converter interface {
ConvertCardToIBAN(
ctx context.Context,
cardNumber string,
) (service.ConversionResult, error)
}
type AuditSaver interface {
Save(ctx context.Context, record audit.Record) error
}
type Handler struct {
converter Converter
audits AuditSaver
logger *slog.Logger
now func() time.Time
newRequestID func() string
}
func NewHandler(
converter Converter,
audits AuditSaver,
logger *slog.Logger,
) *Handler {
return &Handler{
converter: converter,
audits: audits,
logger: logger,
now: time.Now,
newRequestID: rand.Text,
}
}
func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) {
startedAt := h.now()
requestID := h.newRequestID()
w.Header().Set("X-Request-ID", requestID)
w.Header().Set("X-Content-Type-Options", "nosniff")
var (
cardNumber string
result service.ConversionResult
convertErr error
)
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
convertErr = errMethodNotAllowed
} else {
cardNumber, convertErr = decodeCard(w, r)
if convertErr == nil {
result, convertErr = h.converter.ConvertCardToIBAN(
r.Context(),
cardNumber,
)
}
}
outcome := outcomeFor(convertErr)
duration := h.now().Sub(startedAt)
record := audit.Record{
RequestID: requestID,
CreatedAt: startedAt,
Duration: duration,
Status: outcome.auditStatus,
MaskedCard: card.Mask(cardNumber),
ResponseJSON: audit.SanitizeResponse(
result.RawResponse,
card.Mask(cardNumber),
),
}
if result.UpstreamHTTPStatus != 0 {
upstreamStatus := result.UpstreamHTTPStatus
record.UpstreamHTTPStatus = &upstreamStatus
}
if err := h.audits.Save(r.Context(), record); err != nil {
h.logger.ErrorContext(
r.Context(),
"request audit failed",
"request_id", requestID,
"route", "/api/v1/card-to-iban",
"http_status", http.StatusInternalServerError,
"audit_status", audit.StatusInternalError,
"duration_ms", duration.Milliseconds(),
"error", err,
)
h.writeError(
w,
requestID,
http.StatusInternalServerError,
"internal_error",
"خطایی در پردازش درخواست رخ داد.",
)
return
}
h.logger.InfoContext(
r.Context(),
"request completed",
"request_id", requestID,
"route", "/api/v1/card-to-iban",
"http_status", outcome.httpStatus,
"audit_status", outcome.auditStatus,
"duration_ms", duration.Milliseconds(),
)
if convertErr != nil {
h.writeError(
w,
requestID,
outcome.httpStatus,
outcome.code,
outcome.message,
)
return
}
h.writeJSON(w, http.StatusOK, successResponse{
Data: successData{
IBAN: result.IBAN,
},
RequestID: requestID,
})
}
func decodeCard(
w http.ResponseWriter,
r *http.Request,
) (string, error) {
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var request convertRequest
if err := decoder.Decode(&request); err != nil {
return "", fmt.Errorf("%w: %v", errInvalidRequest, err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return "", errInvalidRequest
}
return request.Card, nil
}
type outcome struct {
httpStatus int
code string
message string
auditStatus audit.Status
}
func outcomeFor(err error) outcome {
switch {
case err == nil:
return outcome{
httpStatus: http.StatusOK,
auditStatus: audit.StatusSuccess,
}
case errors.Is(err, errMethodNotAllowed):
return outcome{
httpStatus: http.StatusMethodNotAllowed,
code: "method_not_allowed",
message: "این روش برای این مسیر پشتیبانی نمی‌شود.",
auditStatus: audit.StatusValidationFailed,
}
case errors.Is(err, errInvalidRequest),
errors.Is(err, card.ErrCardRequired),
errors.Is(err, card.ErrCardLength),
errors.Is(err, card.ErrCardNumeric):
return outcome{
httpStatus: http.StatusBadRequest,
code: "invalid_card",
message: "شماره کارت باید شامل ۱۶ رقم باشد.",
auditStatus: audit.StatusValidationFailed,
}
case errors.Is(err, zarinhub.ErrRejected):
return outcome{
httpStatus: http.StatusUnprocessableEntity,
code: "conversion_rejected",
message: "تبدیل این شماره کارت انجام نشد.",
auditStatus: audit.StatusUpstreamRejected,
}
case errors.Is(err, zarinhub.ErrAuthentication):
return outcome{
httpStatus: http.StatusBadGateway,
code: "upstream_failure",
message: "ارتباط با سرویس بانکی با خطا روبه‌رو شد.",
auditStatus: audit.StatusAuthenticationError,
}
case errors.Is(err, zarinhub.ErrTimeout):
return outcome{
httpStatus: http.StatusGatewayTimeout,
code: "upstream_timeout",
message: "پاسخ سرویس بانکی بیش از حد طول کشید.",
auditStatus: audit.StatusUpstreamTimeout,
}
case errors.Is(err, zarinhub.ErrUnavailable):
return outcome{
httpStatus: http.StatusServiceUnavailable,
code: "upstream_unavailable",
message: "سرویس بانکی موقتاً در دسترس نیست.",
auditStatus: audit.StatusUpstreamUnavailable,
}
case errors.Is(err, zarinhub.ErrInvalidResponse):
return outcome{
httpStatus: http.StatusBadGateway,
code: "upstream_failure",
message: "پاسخ سرویس بانکی قابل پردازش نبود.",
auditStatus: audit.StatusUpstreamInvalid,
}
default:
return outcome{
httpStatus: http.StatusInternalServerError,
code: "internal_error",
message: "خطایی در پردازش درخواست رخ داد.",
auditStatus: audit.StatusInternalError,
}
}
}
type convertRequest struct {
Card string `json:"card"`
}
type successResponse struct {
Data successData `json:"data"`
RequestID string `json:"requestId"`
}
type successData struct {
IBAN string `json:"iban"`
}
type errorResponse struct {
Error responseError `json:"error"`
RequestID string `json:"requestId"`
}
type responseError struct {
Code string `json:"code"`
Message string `json:"message"`
}
func (h *Handler) writeError(
w http.ResponseWriter,
requestID string,
status int,
code string,
message string,
) {
h.writeJSON(w, status, errorResponse{
Error: responseError{
Code: code,
Message: message,
},
RequestID: requestID,
})
}
func (h *Handler) writeJSON(
w http.ResponseWriter,
status int,
payload any,
) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(payload); err != nil {
h.logger.Error(
"response encoding failed",
"http_status", status,
"error", err,
)
}
}

View File

@@ -0,0 +1,293 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"card-to-iban/internal/audit"
"card-to-iban/internal/card"
"card-to-iban/internal/service"
"card-to-iban/internal/zarinhub"
)
type fakeConverter struct {
result service.ConversionResult
err error
called bool
}
func (f *fakeConverter) ConvertCardToIBAN(
ctx context.Context,
cardNumber string,
) (service.ConversionResult, error) {
f.called = true
return f.result, f.err
}
type fakeAuditSaver struct {
records []audit.Record
err error
}
func (f *fakeAuditSaver) Save(
ctx context.Context,
record audit.Record,
) error {
f.records = append(f.records, record)
return f.err
}
func TestHandlerConvert(t *testing.T) {
tests := []struct {
name string
method string
body string
result service.ConversionResult
convertErr error
wantHTTPStatus int
wantErrorCode string
wantAuditStatus audit.Status
wantMaskedCard string
wantCalled bool
}{
{
name: "success",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
result: service.ConversionResult{
IBAN: "IR820540102680020817909002",
UpstreamHTTPStatus: http.StatusOK,
RawResponse: []byte(
`{"data":{"card":"6037997512345678","iban":"IR820540102680020817909002"}}`,
),
},
wantHTTPStatus: http.StatusOK,
wantAuditStatus: audit.StatusSuccess,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "malformed json",
method: http.MethodPost,
body: `{not-json}`,
wantHTTPStatus: http.StatusBadRequest,
wantErrorCode: "invalid_card",
wantAuditStatus: audit.StatusValidationFailed,
},
{
name: "missing card",
method: http.MethodPost,
body: `{}`,
convertErr: card.ErrCardRequired,
wantHTTPStatus: http.StatusBadRequest,
wantErrorCode: "invalid_card",
wantAuditStatus: audit.StatusValidationFailed,
wantCalled: true,
},
{
name: "wrong card length",
method: http.MethodPost,
body: `{"card":"123"}`,
convertErr: card.ErrCardLength,
wantHTTPStatus: http.StatusBadRequest,
wantErrorCode: "invalid_card",
wantAuditStatus: audit.StatusValidationFailed,
wantCalled: true,
},
{
name: "non numeric card",
method: http.MethodPost,
body: `{"card":"603799ABC2345678"}`,
convertErr: card.ErrCardNumeric,
wantHTTPStatus: http.StatusBadRequest,
wantErrorCode: "invalid_card",
wantAuditStatus: audit.StatusValidationFailed,
wantCalled: true,
},
{
name: "upstream rejection",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
convertErr: zarinhub.ErrRejected,
wantHTTPStatus: http.StatusUnprocessableEntity,
wantErrorCode: "conversion_rejected",
wantAuditStatus: audit.StatusUpstreamRejected,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "upstream authentication failure",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
convertErr: zarinhub.ErrAuthentication,
wantHTTPStatus: http.StatusBadGateway,
wantErrorCode: "upstream_failure",
wantAuditStatus: audit.StatusAuthenticationError,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "upstream timeout",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
convertErr: zarinhub.ErrTimeout,
wantHTTPStatus: http.StatusGatewayTimeout,
wantErrorCode: "upstream_timeout",
wantAuditStatus: audit.StatusUpstreamTimeout,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "upstream unavailable",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
convertErr: zarinhub.ErrUnavailable,
wantHTTPStatus: http.StatusServiceUnavailable,
wantErrorCode: "upstream_unavailable",
wantAuditStatus: audit.StatusUpstreamUnavailable,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "invalid upstream response",
method: http.MethodPost,
body: `{"card":"6037997512345678"}`,
convertErr: zarinhub.ErrInvalidResponse,
wantHTTPStatus: http.StatusBadGateway,
wantErrorCode: "upstream_failure",
wantAuditStatus: audit.StatusUpstreamInvalid,
wantMaskedCard: "603799******5678",
wantCalled: true,
},
{
name: "method not allowed",
method: http.MethodGet,
wantHTTPStatus: http.StatusMethodNotAllowed,
wantErrorCode: "method_not_allowed",
wantAuditStatus: audit.StatusValidationFailed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
converter := &fakeConverter{
result: tt.result,
err: tt.convertErr,
}
audits := &fakeAuditSaver{}
handler := NewHandler(
converter,
audits,
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
handler.newRequestID = func() string { return "request-test" }
request := httptest.NewRequest(
tt.method,
"/api/v1/card-to-iban",
strings.NewReader(tt.body),
)
response := httptest.NewRecorder()
handler.Convert(response, request)
if response.Code != tt.wantHTTPStatus {
t.Fatalf("status = %d, want %d; body = %s", response.Code, tt.wantHTTPStatus, response.Body.String())
}
if got := response.Header().Get("X-Request-ID"); got != "request-test" {
t.Errorf("X-Request-ID = %q, want request-test", got)
}
var payload struct {
Data struct {
IBAN string `json:"iban"`
} `json:"data"`
Error struct {
Code string `json:"code"`
} `json:"error"`
RequestID string `json:"requestId"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.RequestID != "request-test" {
t.Errorf("requestId = %q, want request-test", payload.RequestID)
}
if payload.Error.Code != tt.wantErrorCode {
t.Errorf("error code = %q, want %q", payload.Error.Code, tt.wantErrorCode)
}
if tt.wantHTTPStatus == http.StatusOK && payload.Data.IBAN != tt.result.IBAN {
t.Errorf("iban = %q, want %q", payload.Data.IBAN, tt.result.IBAN)
}
if converter.called != tt.wantCalled {
t.Errorf("converter called = %v, want %v", converter.called, tt.wantCalled)
}
if len(audits.records) != 1 {
t.Fatalf("audit records = %d, want 1", len(audits.records))
}
record := audits.records[0]
if record.Status != tt.wantAuditStatus {
t.Errorf("audit status = %q, want %q", record.Status, tt.wantAuditStatus)
}
if record.MaskedCard != tt.wantMaskedCard {
t.Errorf("masked card = %q, want %q", record.MaskedCard, tt.wantMaskedCard)
}
if strings.Contains(string(record.ResponseJSON), "6037997512345678") {
t.Error("audit response contains the full card number")
}
})
}
}
func TestHandlerFailsClosedWhenAuditSaveFails(t *testing.T) {
converter := &fakeConverter{
result: service.ConversionResult{
IBAN: "IR820540102680020817909002",
UpstreamHTTPStatus: http.StatusOK,
},
}
audits := &fakeAuditSaver{err: errors.New("database unavailable")}
handler := NewHandler(
converter,
audits,
slog.New(slog.NewTextHandler(io.Discard, nil)),
)
handler.newRequestID = func() string { return "request-test" }
request := httptest.NewRequest(
http.MethodPost,
"/api/v1/card-to-iban",
strings.NewReader(`{"card":"6037997512345678"}`),
)
response := httptest.NewRecorder()
handler.Convert(response, request)
if response.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", response.Code)
}
if strings.Contains(response.Body.String(), "IR820540102680020817909002") {
t.Error("response exposes iban after audit save failure")
}
if !strings.Contains(response.Body.String(), `"code":"internal_error"`) {
t.Errorf("response body = %s, want internal_error", response.Body.String())
}
}