feat(api): add OpenAPI documentation
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
53
backend/internal/httpapi/docs.go
Normal file
53
backend/internal/httpapi/docs.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed openapi.json
|
||||
var openAPIDocument []byte
|
||||
|
||||
const swaggerUI = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Card to IBAN API documentation</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = () => SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis]
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
// 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)
|
||||
}
|
||||
119
backend/internal/httpapi/docs_test.go
Normal file
119
backend/internal/httpapi/docs_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
363
backend/internal/httpapi/openapi.json
Normal file
363
backend/internal/httpapi/openapi.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user