feat(api): add OpenAPI documentation

This commit is contained in:
2026-09-21 17:48:27 +03:30
parent 6e0b718811
commit 5175d1dd65
5 changed files with 540 additions and 2 deletions

View 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)
}

View 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)
}
}
}

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