From bef7a727996a39551b465ed4d8fbf1414b377511 Mon Sep 17 00:00:00 2001 From: Wong Ding Feng Date: Sun, 31 May 2026 23:59:51 +0800 Subject: [PATCH] feat: OpenAPI assembly from endpoint records Co-Authored-By: Claude Sonnet 4.6 --- src/auto_reverse/doc/openapi.py | 56 +++++++++++++++++++++++++++++++++ tests/test_openapi.py | 36 +++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 src/auto_reverse/doc/openapi.py create mode 100644 tests/test_openapi.py diff --git a/src/auto_reverse/doc/openapi.py b/src/auto_reverse/doc/openapi.py new file mode 100644 index 0000000..6d127c7 --- /dev/null +++ b/src/auto_reverse/doc/openapi.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from auto_reverse.models import EndpointRecord + +_PARAM = re.compile(r"\{([^}]+)\}") + + +def _path_params(template: str) -> list[dict[str, Any]]: + return [ + {"name": name, "in": "path", "required": True, "schema": {"type": "string"}} + for name in _PARAM.findall(template) + ] + + +def _operation(rec: EndpointRecord) -> dict[str, Any]: + op: dict[str, Any] = {} + if rec.summary: + op["summary"] = rec.summary + if rec.description: + op["description"] = rec.description + if rec.tag: + op["tags"] = [rec.tag] + params = _path_params(rec.signature.path_template) + params += [ + {"name": q, "in": "query", "required": False, "schema": {"type": "string"}} + for q in sorted(rec.query_params) + ] + if params: + op["parameters"] = params + if rec.request_schema is not None: + op["requestBody"] = { + "content": {"application/json": {"schema": rec.request_schema}} + } + status = rec.signature.status_class.replace("x", "X") + response: dict[str, Any] = {"description": rec.summary or "Response"} + if rec.response_schema is not None: + response["content"] = {"application/json": {"schema": rec.response_schema}} + op["responses"] = {status[0] + "XX": response} + return op + + +def build_openapi(records: list[EndpointRecord], title: str) -> dict[str, Any]: + paths: dict[str, dict[str, Any]] = {} + for rec in records: + template = rec.signature.path_template + method = rec.signature.method.lower() + paths.setdefault(template, {})[method] = _operation(rec) + return { + "openapi": "3.1.0", + "info": {"title": title, "version": "0.0.0"}, + "paths": paths, + } diff --git a/tests/test_openapi.py b/tests/test_openapi.py new file mode 100644 index 0000000..018dc87 --- /dev/null +++ b/tests/test_openapi.py @@ -0,0 +1,36 @@ +from auto_reverse.doc.openapi import build_openapi +from auto_reverse.models import EndpointRecord, Signature + + +def _record(method: str, template: str, **kw) -> EndpointRecord: + rec = EndpointRecord(signature=Signature(method, "ex.com", template, "2xx")) + for k, v in kw.items(): + setattr(rec, k, v) + return rec + + +def test_builds_paths_and_methods(): + records = [ + _record("GET", "/api/users", summary="List users", + response_schema={"type": "array"}), + _record("POST", "/api/users", summary="Create user", + request_schema={"type": "object"}), + ] + spec = build_openapi(records, title="ex.com API") + assert spec["openapi"].startswith("3.") + assert spec["info"]["title"] == "ex.com API" + assert set(spec["paths"]["/api/users"]) == {"get", "post"} + assert spec["paths"]["/api/users"]["get"]["summary"] == "List users" + + +def test_path_param_declared_for_template(): + rec = _record("GET", "/api/users/{id}", summary="Get user") + spec = build_openapi([rec], title="x") + params = spec["paths"]["/api/users/{id}"]["get"]["parameters"] + assert any(p["name"] == "id" and p["in"] == "path" for p in params) + + +def test_request_body_included_when_schema_present(): + rec = _record("POST", "/api/x", request_schema={"type": "object"}) + op = build_openapi([rec], title="x")["paths"]["/api/x"]["post"] + assert op["requestBody"]["content"]["application/json"]["schema"] == {"type": "object"}