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,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,
)
}
}