From 27bbbbcf4b3fef8d0fa170fbeb7b7db12a26e5fc Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 10 Jun 2026 03:15:45 -0700 Subject: [PATCH] feat(openapi): document canonical error schema in swagger via contract decorators @accepts(query/body) now emits a 422 response with ErrorBody; @returns emits a default error response with ErrorBody. ErrorBody (and auto-promoted ErrorDetail) are registered in openapi_ns so they appear in definitions and are reachable from both error response entries. --- api/controllers/openapi/__init__.py | 3 +- api/controllers/openapi/_contract.py | 4 ++ .../controllers/openapi/test_contract.py | 38 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 872493f3c8..fb41ad64cc 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -1,7 +1,7 @@ from flask import Blueprint from flask_restx import Namespace -from controllers.openapi._errors import OpenApiErrorFormatter +from controllers.openapi._errors import ErrorBody, OpenApiErrorFormatter from libs.device_flow_security import attach_anti_framing from libs.external_api import ExternalApi @@ -84,6 +84,7 @@ register_schema_models( ) register_response_schema_models( openapi_ns, + ErrorBody, TagItem, UsageInfo, MessageMetadata, diff --git a/api/controllers/openapi/_contract.py b/api/controllers/openapi/_contract.py index 0979b01a35..a7dcf9093d 100644 --- a/api/controllers/openapi/_contract.py +++ b/api/controllers/openapi/_contract.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ValidationError from controllers.common.schema import query_params_from_model, query_params_from_request from controllers.openapi import openapi_ns +from controllers.openapi._errors import ErrorBody def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable: @@ -51,6 +52,8 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non openapi_ns.doc(params=query_params_from_model(query))(wrapper) if body is not None: openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper) + if query is not None or body is not None: + openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator @@ -76,6 +79,7 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) - return result openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper) + openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper) return wrapper return decorator diff --git a/api/tests/unit_tests/controllers/openapi/test_contract.py b/api/tests/unit_tests/controllers/openapi/test_contract.py index 990437e37f..b8773f56df 100644 --- a/api/tests/unit_tests/controllers/openapi/test_contract.py +++ b/api/tests/unit_tests/controllers/openapi/test_contract.py @@ -208,3 +208,41 @@ def test_accepts_body_emits_expect_through_guard_stack(): apidoc = getattr(view, "__apidoc__", {}) assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect + + +def _response_model_name(entry) -> str: + """Extract the model name from a flask-restx __apidoc__ response entry. + + flask-restx stores responses as ``(description, model, kwargs)`` tuples + where ``model.name`` is the registered schema name. + """ + if isinstance(entry, tuple) and len(entry) >= 2: + model = entry[1] + return getattr(model, "name", "") or "" + return "" + + +def test_accepts_documents_422_error_response(app): + from controllers.openapi._errors import ErrorBody + + @accepts(query=ContractQuery) + def view(*, query): + return query + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "422" in responses + assert _response_model_name(responses["422"]) == ErrorBody.__name__ + + +def test_returns_documents_default_error_response(app): + from controllers.openapi._errors import ErrorBody + + @returns(200, ContractResp) + def view(): + return ContractResp(value=1) + + doc = getattr(view, "__apidoc__", {}) + responses = doc.get("responses", {}) + assert "default" in responses + assert _response_model_name(responses["default"]) == ErrorBody.__name__