feat(api): add card-to-iban HTTP endpoint
This commit is contained in:
308
backend/internal/httpapi/handler.go
Normal file
308
backend/internal/httpapi/handler.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user