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() clientIP := extractClientIP(r) 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, ClientIP: clientIP, 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 } var clientIPValue string if clientIP != nil { clientIPValue = *clientIP } if err := h.audits.Save(r.Context(), record); err != nil { h.logger.ErrorContext( r.Context(), "request audit failed", "request_id", requestID, "client_ip", clientIPValue, "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, "client_ip", clientIPValue, "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, ) } }