diff --git a/README.md b/README.md index 727ab68..78c826a 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ npm install npm run dev ``` -The frontend is available at `http://127.0.0.1:3000`. The backend health endpoint is available at `http://127.0.0.1:8080/health`. +The frontend is available at `http://127.0.0.1:3000`. The backend health endpoint is available at `http://127.0.0.1:8080/health`, and the interactive API documentation is available at `http://127.0.0.1:8080/docs`. ## Running with Docker Compose @@ -86,6 +86,8 @@ The database volume is preserved by default. To remove it intentionally, run `do ## API Contract +The OpenAPI 3.0 document is served at `http://127.0.0.1:8080/openapi.json`. Swagger UI is available at `http://127.0.0.1:8080/swagger/`, with `/docs` provided as a shortcut. The OpenAPI document is embedded in the backend binary; only the Swagger UI assets are loaded from a CDN. + ### Convert a card number to an IBAN ```http @@ -167,5 +169,5 @@ The test suite covers card validation and masking, the conversion service, the Z - The frontend normalizes Persian and Arabic digits and removes spaces or hyphens before submission. - SQLite is sufficient for this assessment and uses a single writer connection for predictable behavior. - Only the IBAN is exposed to the frontend. Account details, owner information, and raw ZarinHub responses remain internal. -- Retries, Docker, OpenAPI, CI, dark mode, and production infrastructure are intentionally outside the project scope. +- Retries, CI, dark mode, and production infrastructure are intentionally outside the project scope. - The interface uses the existing ZarinPal design system's font and logo assets. The font license is stored in `frontend/public/fonts`. diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index bdda3d3..aa3a491 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -91,6 +91,7 @@ func run(logger *slog.Logger) error { mux := http.NewServeMux() mux.HandleFunc("/health", healthHandler) mux.HandleFunc("/api/v1/card-to-iban", handler.Convert) + httpapi.RegisterDocumentation(mux) server := &http.Server{ Addr: config.ServerAddr, diff --git a/backend/internal/httpapi/docs.go b/backend/internal/httpapi/docs.go new file mode 100644 index 0000000..b9014de --- /dev/null +++ b/backend/internal/httpapi/docs.go @@ -0,0 +1,53 @@ +package httpapi + +import ( + _ "embed" + "net/http" +) + +//go:embed openapi.json +var openAPIDocument []byte + +const swaggerUI = ` + + + + + Card to IBAN API documentation + + + +
+ + + + +` + +// RegisterDocumentation registers the OpenAPI document and its Swagger UI. +func RegisterDocumentation(mux *http.ServeMux) { + mux.HandleFunc("GET /openapi.json", serveOpenAPI) + mux.HandleFunc("GET /swagger/", serveSwaggerUI) + mux.HandleFunc("GET /docs", redirectToSwaggerUI) +} + +func serveOpenAPI(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(openAPIDocument) +} + +func serveSwaggerUI(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(swaggerUI)) +} + +func redirectToSwaggerUI(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/swagger/", http.StatusTemporaryRedirect) +} diff --git a/backend/internal/httpapi/docs_test.go b/backend/internal/httpapi/docs_test.go new file mode 100644 index 0000000..fcfbdee --- /dev/null +++ b/backend/internal/httpapi/docs_test.go @@ -0,0 +1,119 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRegisterDocumentation(t *testing.T) { + tests := []struct { + name string + path string + wantStatus int + wantContentType string + wantBody string + wantLocation string + }{ + { + name: "OpenAPI document", + path: "/openapi.json", + wantStatus: http.StatusOK, + wantContentType: "application/json", + wantBody: `"openapi": "3.0.3"`, + }, + { + name: "Swagger UI", + path: "/swagger/", + wantStatus: http.StatusOK, + wantContentType: "text/html; charset=utf-8", + wantBody: `url: "/openapi.json"`, + }, + { + name: "documentation shortcut", + path: "/docs", + wantStatus: http.StatusTemporaryRedirect, + wantLocation: "/swagger/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + RegisterDocumentation(mux) + request := httptest.NewRequest(http.MethodGet, tt.path, nil) + response := httptest.NewRecorder() + + mux.ServeHTTP(response, request) + + if response.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", response.Code, tt.wantStatus) + } + if tt.wantContentType != "" { + if got := response.Header().Get("Content-Type"); got != tt.wantContentType { + t.Errorf("Content-Type = %q, want %q", got, tt.wantContentType) + } + } + if tt.wantBody != "" && !strings.Contains(response.Body.String(), tt.wantBody) { + t.Errorf("body does not contain %q", tt.wantBody) + } + if got := response.Header().Get("Location"); got != tt.wantLocation { + t.Errorf("Location = %q, want %q", got, tt.wantLocation) + } + }) + } +} + +func TestOpenAPIDocumentCoversPublicAPI(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + RegisterDocumentation(mux) + request := httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + response := httptest.NewRecorder() + mux.ServeHTTP(response, request) + + var document struct { + OpenAPI string `json:"openapi"` + Paths map[string]map[string]struct { + Responses map[string]json.RawMessage `json:"responses"` + } `json:"paths"` + Components struct { + Schemas map[string]json.RawMessage `json:"schemas"` + } `json:"components"` + } + if err := json.Unmarshal(response.Body.Bytes(), &document); err != nil { + t.Fatalf("decode OpenAPI document: %v", err) + } + + if document.OpenAPI != "3.0.3" { + t.Errorf("OpenAPI version = %q, want 3.0.3", document.OpenAPI) + } + if _, ok := document.Paths["/health"]["get"]; !ok { + t.Error("OpenAPI document does not define GET /health") + } + + conversion, ok := document.Paths["/api/v1/card-to-iban"]["post"] + if !ok { + t.Fatal("OpenAPI document does not define POST /api/v1/card-to-iban") + } + for _, status := range []string{"200", "400", "405", "422", "500", "502", "503", "504"} { + if _, ok := conversion.Responses[status]; !ok { + t.Errorf("POST /api/v1/card-to-iban does not document response %s", status) + } + } + for _, schema := range []string{ + "ConvertRequest", + "ConvertResponse", + "ErrorResponse", + "HealthResponse", + } { + if _, ok := document.Components.Schemas[schema]; !ok { + t.Errorf("OpenAPI document does not define schema %s", schema) + } + } +} diff --git a/backend/internal/httpapi/openapi.json b/backend/internal/httpapi/openapi.json new file mode 100644 index 0000000..ea977c8 --- /dev/null +++ b/backend/internal/httpapi/openapi.json @@ -0,0 +1,363 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Card to IBAN API", + "version": "1.0.0", + "description": "Converts an Iranian bank card number to its IBAN." + }, + "servers": [ + { + "url": "/", + "description": "Current server" + } + ], + "tags": [ + { + "name": "System" + }, + { + "name": "Conversion" + } + ], + "paths": { + "/health": { + "get": { + "tags": ["System"], + "summary": "Check service health", + "operationId": "getHealth", + "responses": { + "200": { + "description": "The service is running.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + }, + "example": { + "status": "ok" + } + } + } + } + } + } + }, + "/api/v1/card-to-iban": { + "post": { + "tags": ["Conversion"], + "summary": "Convert a card number to an IBAN", + "operationId": "convertCardToIBAN", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConvertRequest" + }, + "example": { + "card": "6037997512345678" + } + } + } + }, + "responses": { + "200": { + "description": "The card number was converted successfully.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConvertResponse" + }, + "example": { + "data": { + "iban": "IR820540102680020817909002" + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "400": { + "$ref": "#/components/responses/InvalidCard" + }, + "405": { + "$ref": "#/components/responses/MethodNotAllowed" + }, + "422": { + "$ref": "#/components/responses/ConversionRejected" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "502": { + "$ref": "#/components/responses/UpstreamFailure" + }, + "503": { + "$ref": "#/components/responses/UpstreamUnavailable" + }, + "504": { + "$ref": "#/components/responses/UpstreamTimeout" + } + } + } + } + }, + "components": { + "headers": { + "RequestID": { + "description": "Identifier generated for tracing this request.", + "schema": { + "type": "string" + }, + "example": "SERVER_GENERATED_REQUEST_ID" + } + }, + "schemas": { + "HealthResponse": { + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": ["ok"] + } + } + }, + "ConvertRequest": { + "type": "object", + "additionalProperties": false, + "required": ["card"], + "properties": { + "card": { + "type": "string", + "description": "Iranian bank card number containing exactly 16 ASCII digits.", + "pattern": "^[0-9]{16}$", + "minLength": 16, + "maxLength": 16, + "example": "6037997512345678" + } + } + }, + "ConvertResponse": { + "type": "object", + "required": ["data", "requestId"], + "properties": { + "data": { + "$ref": "#/components/schemas/ConvertData" + }, + "requestId": { + "type": "string", + "example": "SERVER_GENERATED_REQUEST_ID" + } + } + }, + "ConvertData": { + "type": "object", + "required": ["iban"], + "properties": { + "iban": { + "type": "string", + "pattern": "^IR[0-9]{24}$", + "example": "IR820540102680020817909002" + } + } + }, + "ErrorResponse": { + "type": "object", + "required": ["error", "requestId"], + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "requestId": { + "type": "string", + "example": "SERVER_GENERATED_REQUEST_ID" + } + } + }, + "ErrorDetail": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "enum": [ + "invalid_card", + "method_not_allowed", + "conversion_rejected", + "internal_error", + "upstream_failure", + "upstream_unavailable", + "upstream_timeout" + ] + }, + "message": { + "type": "string", + "description": "A user-facing error message in Persian." + } + } + } + }, + "responses": { + "InvalidCard": { + "description": "The JSON body or card number is invalid.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "invalid_card", + "message": "شماره کارت باید شامل ۱۶ رقم باشد." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "MethodNotAllowed": { + "description": "The request method is not supported for this path.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "method_not_allowed", + "message": "این روش برای این مسیر پشتیبانی نمی‌شود." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "ConversionRejected": { + "description": "The upstream service rejected the conversion.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "conversion_rejected", + "message": "تبدیل این شماره کارت انجام نشد." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "InternalError": { + "description": "An internal operation failed.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "internal_error", + "message": "خطایی در پردازش درخواست رخ داد." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "UpstreamFailure": { + "description": "Upstream authentication failed or its response was invalid.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "upstream_failure", + "message": "ارتباط با سرویس بانکی با خطا روبه‌رو شد." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "UpstreamUnavailable": { + "description": "The upstream service is temporarily unavailable.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "upstream_unavailable", + "message": "سرویس بانکی موقتاً در دسترس نیست." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + }, + "UpstreamTimeout": { + "description": "The upstream request timed out.", + "headers": { + "X-Request-ID": { + "$ref": "#/components/headers/RequestID" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": { + "code": "upstream_timeout", + "message": "پاسخ سرویس بانکی بیش از حد طول کشید." + }, + "requestId": "SERVER_GENERATED_REQUEST_ID" + } + } + } + } + } + } +}