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"
+ }
+ }
+ }
+ }
+ }
+ }
+}