From c2480cc4ac09223db786075926bc5bcd6d8a2af6 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 10:36:49 +0000 Subject: [PATCH 01/15] fix(spec): scan app routes before generating specs --- src/azure_functions_openapi/__init__.py | 47 +++++++++++++- src/azure_functions_openapi/openapi.py | 2 +- tests/test_decorator_order_routes.py | 82 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 tests/test_decorator_order_routes.py diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index 1e5dbd36..27591998 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -1,4 +1,6 @@ # src/azure_functions_openapi/__init__.py +from typing import Any + from azure_functions_openapi._warnings import SpecWarning, WarningCode import azure_functions_openapi.bridge as _bridge from azure_functions_openapi.decorator import ( @@ -14,10 +16,10 @@ OPENAPI_VERSION_3_2, SpecReport, generate_openapi_report, - generate_openapi_spec, get_openapi_json, get_openapi_yaml, ) +from azure_functions_openapi.spec import generate_openapi_spec as _generate_openapi_spec from azure_functions_openapi.swagger_ui import render_swagger_ui from azure_functions_openapi.types import OpenAPIOperationMetadata @@ -25,6 +27,49 @@ scan_endpoint_metadata = _bridge.scan_endpoint_metadata scan_validation_metadata = _bridge.scan_validation_metadata + +def generate_openapi_spec( + title: str = "API", + version: str = "1.0.0", + openapi_version: str = OPENAPI_VERSION_3_1, + description: str = ( + "Auto-generated OpenAPI documentation. Markdown supported in descriptions (CommonMark)." + ), + security_schemes: dict[str, dict[str, Any]] | None = None, + route_prefix: str = "/api", + strict: bool = False, + registry: OpenAPIRegistry | None = None, + hoist_flat_schemas: bool = False, + infer_auth_level: bool = False, + servers: list[dict[str, Any]] | None = None, + contact: dict[str, Any] | None = None, + license: dict[str, Any] | None = None, + external_docs: dict[str, Any] | None = None, + tags: list[dict[str, Any]] | None = None, + app: object | None = None, +) -> dict[str, Any]: + """Compile a spec, optionally reconciling routes from a FunctionApp first.""" + if app is not None: + _bridge.scan_endpoint_metadata(app, route_prefix=route_prefix, registry=registry) + return _generate_openapi_spec( + title=title, + version=version, + openapi_version=openapi_version, + description=description, + security_schemes=security_schemes, + route_prefix=route_prefix, + strict=strict, + registry=registry, + hoist_flat_schemas=hoist_flat_schemas, + infer_auth_level=infer_auth_level, + servers=servers, + contact=contact, + license=license, + external_docs=external_docs, + tags=tags, + ) + + __all__ = [ "__version__", "OPENAPI_VERSION_3_0", diff --git a/src/azure_functions_openapi/openapi.py b/src/azure_functions_openapi/openapi.py index bd6238e6..94345b30 100644 --- a/src/azure_functions_openapi/openapi.py +++ b/src/azure_functions_openapi/openapi.py @@ -12,12 +12,12 @@ import warnings +from azure_functions_openapi import generate_openapi_spec from azure_functions_openapi.spec import ( DEFAULT_OPENAPI_INFO_DESCRIPTION, OPENAPI_VERSION_3_0, OPENAPI_VERSION_3_1, OPENAPI_VERSION_3_2, - generate_openapi_spec, get_openapi_json, get_openapi_yaml, ) diff --git a/tests/test_decorator_order_routes.py b/tests/test_decorator_order_routes.py new file mode 100644 index 00000000..fb750931 --- /dev/null +++ b/tests/test_decorator_order_routes.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from collections.abc import Iterator + +import azure.functions as func +import pytest + +from azure_functions_openapi import ( + clear_openapi_registry, + generate_openapi_spec, + openapi, +) + + +@pytest.fixture(autouse=True) +def _clean_registry() -> Iterator[None]: + clear_openapi_registry() + yield + clear_openapi_registry() + + +def test_spec_uses_binding_route_when_openapi_is_outermost() -> None: + # Given: @openapi can inspect the builder returned by @app.route. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @openapi(summary="outer") + @app.route(route="things/{id}", methods=["PATCH"]) + def outer_handler(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: binding metadata is reconciled and the spec is generated. + spec = generate_openapi_spec(app=app) + + # Then: the binding route, method, and route parameter are documented. + operation = spec["paths"]["/api/things/{id}"]["patch"] + assert operation["parameters"] == [ + {"name": "id", "in": "path", "required": True, "schema": {"type": "string"}} + ] + + +def test_spec_uses_binding_route_when_openapi_is_innermost() -> None: + # Given: @openapi runs before @app.route and cannot inspect its binding yet. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.function_name(name="custom_name") + @app.route(route="items/{item_id:int}", methods=["GET", "DELETE"]) + @openapi(summary="inner") + def inner_handler(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: binding metadata is reconciled with a custom host route prefix. + spec = generate_openapi_spec(app=app, route_prefix="/v1") + + # Then: neither the Python nor registered function name becomes the path. + path_item = spec["paths"]["/v1/items/{item_id}"] + assert set(path_item) == {"get", "delete"} + assert path_item["get"]["parameters"] == [ + { + "name": "item_id", + "in": "path", + "required": True, + "schema": {"type": "integer"}, + } + ] + assert "/v1/inner_handler" not in spec["paths"] + assert "/v1/custom_name" not in spec["paths"] + + +def test_spec_keeps_function_name_route_without_explicit_binding_route() -> None: + # Given: neither decorator supplies an explicit route. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(methods=["POST"]) + @openapi(summary="implicit") + def implicit_handler(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: the app is scanned and its spec is generated. + spec = generate_openapi_spec(app=app) + + # Then: Azure's implicit function-name route remains documented. + assert set(spec["paths"]["/api/implicit_handler"]) == {"post"} From dca4a9dc3fa7fcedac9c7afad61571e13a3bd621 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 10:36:49 +0000 Subject: [PATCH 02/15] docs(architecture): clarify decorator order discovery --- docs/architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 36c3e5ba..c835d59d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,7 +86,7 @@ The registry is consumed only when a client explicitly requests the spec (`GET / ## How Route Discovery Works -Discovery is how this package finds every HTTP route on a `FunctionApp` and reconciles it with `@openapi(...)` metadata. It runs at **import time** inside `function_app.py` (via `scan_endpoint_metadata`) or on demand from the CLI (`generate --app module:variable`). Route and method come **binding-first**: the Azure `@app.route(...)` binding is the source of truth, and `@openapi(...)` only enriches the operation the binding already defines. For the shared `FunctionBuilder` background this relies on, see [How the worker binds handlers §2](https://yeongseon.dev/azure-functions-python/platform/how-the-worker-binds-handlers/). +Discovery is how this package finds every HTTP route on a `FunctionApp` and reconciles it with `@openapi(...)` metadata. It runs at **import time** inside `function_app.py` (via `scan_endpoint_metadata`), when `generate_openapi_spec(app=app)` receives the app, or on demand from the CLI (`generate --app module:variable`). Route and method come **binding-first**: the Azure `@app.route(...)` binding is the source of truth, and `@openapi(...)` only enriches the operation the binding already defines. For the shared `FunctionBuilder` background this relies on, see [How the worker binds handlers §2](https://yeongseon.dev/azure-functions-python/platform/how-the-worker-binds-handlers/). ### Enumerating functions without breaking boot @@ -96,7 +96,7 @@ Because `build()` is idempotent, **re-scanning is safe**: repeated scans neither ### `@openapi` below `@app.route` -Decorator order is a valid degree of freedom. When `@openapi` is applied *below* `@app.route`, the builder has no trigger yet, so `FunctionBuilder.build()` raises `ValueError` and the function is skipped by the normal built-`Function` path. The user handler still exists on the builder, so `get_unbuilt_user_handler()` reads it defensively via the public `Function.get_user_function()` accessor — a guarded, **side-effect-free** inspection that leaves a later `build()` (once the outer `@app.route` applies the trigger) valid and idempotent (`src/azure_functions_openapi/adapters/azure_functions.py:182-206`). In this ordering `@openapi` never sees the HTTP binding and registers with `method=None`; discovery then explodes that single entry into one operation per bound method rather than collapsing every method into one. +Decorator order is a valid degree of freedom. When `@openapi` is applied *below* `@app.route`, it runs before the route decorator and cannot see the binding. Generate with `generate_openapi_spec(app=app)` or call `scan_endpoint_metadata(app)` before generating so discovery can reconcile the registered metadata with the completed binding. It then uses the binding route and explodes the unresolved method into one operation per bound method. Without an app scan, a bare registry has no evidence of a decorator that has not run yet and keeps the historical function-name fallback. ### `--isolate-app` fails closed From 7e78312003a2bf506fe9f519b2a4439c1eb993a2 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 03/15] fix(spec): support app-aware generation facades --- src/azure_functions_openapi/__init__.py | 167 +++++++++++++++++++++-- src/azure_functions_openapi/_warnings.py | 1 + src/azure_functions_openapi/bridge.py | 2 + src/azure_functions_openapi/cli.py | 9 +- src/azure_functions_openapi/decorator.py | 1 + src/azure_functions_openapi/openapi.py | 12 +- tests/test_decorator_order_routes.py | 149 ++++++++++++++++++++ tests/test_public_api.py | 2 + 8 files changed, 330 insertions(+), 13 deletions(-) diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index 27591998..476c4122 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -1,5 +1,9 @@ # src/azure_functions_openapi/__init__.py +import json from typing import Any +import warnings + +import yaml from azure_functions_openapi._warnings import SpecWarning, WarningCode import azure_functions_openapi.bridge as _bridge @@ -10,15 +14,15 @@ ) from azure_functions_openapi.exceptions import OpenAPISpecConfigError, SDKIncompatibleError from azure_functions_openapi.registry import OpenAPIRegistry +from azure_functions_openapi.registry import registry as _default_registry from azure_functions_openapi.spec import ( + DEFAULT_OPENAPI_INFO_DESCRIPTION, OPENAPI_VERSION_3_0, OPENAPI_VERSION_3_1, OPENAPI_VERSION_3_2, SpecReport, - generate_openapi_report, - get_openapi_json, - get_openapi_yaml, ) +from azure_functions_openapi.spec import generate_openapi_report as _generate_openapi_report from azure_functions_openapi.spec import generate_openapi_spec as _generate_openapi_spec from azure_functions_openapi.swagger_ui import render_swagger_ui from azure_functions_openapi.types import OpenAPIOperationMetadata @@ -28,13 +32,41 @@ scan_validation_metadata = _bridge.scan_validation_metadata +def _prepare_generation( + app: object | None, + route_prefix: str, + registry: OpenAPIRegistry | None, + strict: bool, +) -> tuple[SpecWarning, ...]: + """Scan an app and diagnose registry routes that still lack binding evidence.""" + if app is not None: + _bridge.scan_endpoint_metadata(app, route_prefix=route_prefix, registry=registry) + + active_registry = registry if registry is not None else _default_registry + unresolved = tuple( + SpecWarning( + code=WarningCode.UNRESOLVED_ROUTE, + message=( + f"Route for '{entry.get('function_name') or key}' has no binding evidence; " + "using the function name fallback. Pass app=... or set route= on @openapi." + ), + function_name=entry.get("function_name") or key, + ) + for key, entry in active_registry.snapshot().items() + if entry.get("route") is None and not entry.get("_route_evidence") + ) + if unresolved and strict: + raise OpenAPISpecConfigError(unresolved[0].message) + for item in unresolved: + warnings.warn(item.message, RuntimeWarning, stacklevel=3) + return unresolved + + def generate_openapi_spec( title: str = "API", version: str = "1.0.0", openapi_version: str = OPENAPI_VERSION_3_1, - description: str = ( - "Auto-generated OpenAPI documentation. Markdown supported in descriptions (CommonMark)." - ), + description: str = DEFAULT_OPENAPI_INFO_DESCRIPTION, security_schemes: dict[str, dict[str, Any]] | None = None, route_prefix: str = "/api", strict: bool = False, @@ -49,8 +81,7 @@ def generate_openapi_spec( app: object | None = None, ) -> dict[str, Any]: """Compile a spec, optionally reconciling routes from a FunctionApp first.""" - if app is not None: - _bridge.scan_endpoint_metadata(app, route_prefix=route_prefix, registry=registry) + _prepare_generation(app, route_prefix, registry, strict) return _generate_openapi_spec( title=title, version=version, @@ -70,6 +101,126 @@ def generate_openapi_spec( ) +def get_openapi_json( + title: str = "API", + version: str = "1.0.0", + openapi_version: str = OPENAPI_VERSION_3_1, + description: str = DEFAULT_OPENAPI_INFO_DESCRIPTION, + security_schemes: dict[str, dict[str, Any]] | None = None, + route_prefix: str = "/api", + strict: bool = False, + registry: OpenAPIRegistry | None = None, + hoist_flat_schemas: bool = False, + infer_auth_level: bool = False, + servers: list[dict[str, Any]] | None = None, + contact: dict[str, Any] | None = None, + license: dict[str, Any] | None = None, + external_docs: dict[str, Any] | None = None, + tags: list[dict[str, Any]] | None = None, + app: object | None = None, +) -> str: + """Return JSON after optionally reconciling routes from a completed FunctionApp.""" + spec = generate_openapi_spec( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + app, + ) + return json.dumps(spec, indent=2, ensure_ascii=False) + + +def get_openapi_yaml( + title: str = "API", + version: str = "1.0.0", + openapi_version: str = OPENAPI_VERSION_3_1, + description: str = DEFAULT_OPENAPI_INFO_DESCRIPTION, + security_schemes: dict[str, dict[str, Any]] | None = None, + route_prefix: str = "/api", + strict: bool = False, + registry: OpenAPIRegistry | None = None, + hoist_flat_schemas: bool = False, + infer_auth_level: bool = False, + servers: list[dict[str, Any]] | None = None, + contact: dict[str, Any] | None = None, + license: dict[str, Any] | None = None, + external_docs: dict[str, Any] | None = None, + tags: list[dict[str, Any]] | None = None, + app: object | None = None, +) -> str: + """Return YAML after optionally reconciling routes from a completed FunctionApp.""" + spec = generate_openapi_spec( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + app, + ) + return yaml.safe_dump(spec, sort_keys=False, allow_unicode=True) + + +def generate_openapi_report( + title: str = "API", + version: str = "1.0.0", + openapi_version: str = OPENAPI_VERSION_3_1, + description: str = DEFAULT_OPENAPI_INFO_DESCRIPTION, + security_schemes: dict[str, dict[str, Any]] | None = None, + route_prefix: str = "/api", + strict: bool = False, + registry: OpenAPIRegistry | None = None, + hoist_flat_schemas: bool = False, + infer_auth_level: bool = False, + servers: list[dict[str, Any]] | None = None, + contact: dict[str, Any] | None = None, + license: dict[str, Any] | None = None, + external_docs: dict[str, Any] | None = None, + tags: list[dict[str, Any]] | None = None, + app: object | None = None, +) -> SpecReport: + """Return a spec report after optionally reconciling a completed FunctionApp.""" + unresolved = _prepare_generation(app, route_prefix, registry, strict) + report = _generate_openapi_report( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + ) + return SpecReport(spec=report.spec, warnings=(*report.warnings, *unresolved)) + + __all__ = [ "__version__", "OPENAPI_VERSION_3_0", diff --git a/src/azure_functions_openapi/_warnings.py b/src/azure_functions_openapi/_warnings.py index 5415e59d..6b87bed9 100644 --- a/src/azure_functions_openapi/_warnings.py +++ b/src/azure_functions_openapi/_warnings.py @@ -35,6 +35,7 @@ class WarningCode(StrEnum): VERSION_DOWNGRADE_DROP = "version-downgrade-drop" SCHEMA_SUBSTITUTION = "schema-substitution" OPERATION_SKIPPED = "operation-skipped" + UNRESOLVED_ROUTE = "unresolved-route" def __str__(self) -> str: # pragma: no cover - trivial return self.value diff --git a/src/azure_functions_openapi/bridge.py b/src/azure_functions_openapi/bridge.py index d26d6ade..ad29918a 100644 --- a/src/azure_functions_openapi/bridge.py +++ b/src/azure_functions_openapi/bridge.py @@ -586,6 +586,8 @@ def _on_skip(name: str | None, reason: str) -> None: explode_canonical = ( canonical_target is not None and canonical_target.get("method") is None ) + if canonical_target is not None: + canonical_target["_route_evidence"] = True # An explicit @openapi(method=...) that the binding does not serve is # a mismatch: stamp the binding's specified method set on the entry so # spec.py can flag the unreachable operation (#362/#368). Unspecified diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index a55e0107..9833c059 100644 --- a/src/azure_functions_openapi/cli.py +++ b/src/azure_functions_openapi/cli.py @@ -6,6 +6,7 @@ from pathlib import Path import sys +from azure_functions_openapi import _prepare_generation from azure_functions_openapi.bridge import scan_endpoint_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import OpenAPIRegistry @@ -279,6 +280,12 @@ def handle_generate(args: argparse.Namespace) -> int: if not isinstance(description, str): description = DEFAULT_OPENAPI_INFO_DESCRIPTION + unresolved = _prepare_generation( + None, + getattr(args, "route_prefix", "/api"), + active_registry, + getattr(args, "strict", False), + ) spec = generate_openapi_spec( args.title, args.version, @@ -288,7 +295,7 @@ def handle_generate(args: argparse.Namespace) -> int: strict=getattr(args, "strict", False), registry=active_registry, ) - warnings = collect_spec_warnings(spec, registry=active_registry) + warnings = (*collect_spec_warnings(spec, registry=active_registry), *unresolved) # Surface structured warnings (version skew / namespace fallback / # spec-validation) as JSON lines on stderr so CI can parse them, and # gate the exit code on them when --fail-on-warnings is set. diff --git a/src/azure_functions_openapi/decorator.py b/src/azure_functions_openapi/decorator.py index 1dc46039..03098d95 100644 --- a/src/azure_functions_openapi/decorator.py +++ b/src/azure_functions_openapi/decorator.py @@ -726,6 +726,7 @@ def decorator(func: F) -> F: # ── routing info ───────────────────────────────────────── "route": validated_route, "method": validated_method, + "_route_evidence": route is not None or adapters.is_function_builder(func), # Evidence that the runtime answers every HTTP method # (binding present, ``methods=`` omitted). Gates all-method # expansion in the spec generator; a bare @openapi stays diff --git a/src/azure_functions_openapi/openapi.py b/src/azure_functions_openapi/openapi.py index 94345b30..8786ef63 100644 --- a/src/azure_functions_openapi/openapi.py +++ b/src/azure_functions_openapi/openapi.py @@ -3,7 +3,9 @@ Importing from ``azure_functions_openapi.openapi`` is preserved for backward compatibility with code written against versions <= 0.17.x. -The canonical location is :mod:`azure_functions_openapi.spec`. +The canonical low-level compiler remains :mod:`azure_functions_openapi.spec`. +For backward compatibility, every generation function exported by this shim +redirects to the package-root facade, where ``app=`` route discovery is available. Scheduled for removal in 1.0. See issue #194. """ @@ -12,14 +14,16 @@ import warnings -from azure_functions_openapi import generate_openapi_spec +from azure_functions_openapi import ( + generate_openapi_spec, + get_openapi_json, + get_openapi_yaml, +) from azure_functions_openapi.spec import ( DEFAULT_OPENAPI_INFO_DESCRIPTION, OPENAPI_VERSION_3_0, OPENAPI_VERSION_3_1, OPENAPI_VERSION_3_2, - get_openapi_json, - get_openapi_yaml, ) warnings.warn( diff --git a/tests/test_decorator_order_routes.py b/tests/test_decorator_order_routes.py index fb750931..9d8db209 100644 --- a/tests/test_decorator_order_routes.py +++ b/tests/test_decorator_order_routes.py @@ -1,15 +1,25 @@ from __future__ import annotations from collections.abc import Iterator +import json +from pathlib import Path +import subprocess +import sys import azure.functions as func import pytest +import yaml from azure_functions_openapi import ( + WarningCode, clear_openapi_registry, + generate_openapi_report, generate_openapi_spec, + get_openapi_json, + get_openapi_yaml, openapi, ) +from azure_functions_openapi.exceptions import OpenAPISpecConfigError @pytest.fixture(autouse=True) @@ -66,6 +76,145 @@ def inner_handler(req: func.HttpRequest) -> func.HttpResponse: assert "/v1/custom_name" not in spec["paths"] +def test_json_uses_binding_route_when_openapi_is_innermost() -> None: + # Given: @openapi runs before the route binding exists. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(route="json-items", methods=["GET"]) + @openapi(summary="JSON items") + def json_items(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: the package-root JSON facade receives the completed app. + document = json.loads(get_openapi_json(app=app)) + + # Then: it scans the binding before serializing the spec. + assert set(document["paths"]) == {"/api/json-items"} + + +def test_yaml_uses_binding_route_when_openapi_is_innermost() -> None: + # Given: @openapi runs before the route binding exists. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(route="yaml-items", methods=["POST"]) + @openapi(summary="YAML items") + def yaml_items(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: the package-root YAML facade receives the completed app. + document = yaml.safe_load(get_openapi_yaml(app=app)) + + # Then: it scans the binding before serializing the spec. + assert set(document["paths"]) == {"/api/yaml-items"} + + +def test_report_uses_binding_route_when_openapi_is_innermost() -> None: + # Given: @openapi runs before the route binding exists. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(route="report-items", methods=["GET"]) + @openapi(summary="Report items") + def report_items(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: the package-root report facade receives the completed app. + report = generate_openapi_report(app=app) + + # Then: it scans the binding before compiling and reports no unresolved route. + assert set(report.spec["paths"]) == {"/api/report-items"} + assert not report.warnings + + +def test_report_warns_when_route_has_no_binding_evidence() -> None: + # Given: a bare decorator has neither an explicit route nor a visible builder. + @openapi(summary="Unresolved") + def unresolved(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When: a report is generated without scanning an app. + with pytest.warns(RuntimeWarning, match="unresolved"): + report = generate_openapi_report() + + # Then: the function-name fallback is machine-readable. + assert [(warning.code, warning.function_name) for warning in report.warnings] == [ + (WarningCode.UNRESOLVED_ROUTE, "unresolved") + ] + + +def test_strict_generation_raises_when_route_has_no_binding_evidence() -> None: + # Given: a bare decorator has no evidence for its runtime route. + @openapi(summary="Unresolved") + def unresolved(req: func.HttpRequest) -> func.HttpResponse: + return func.HttpResponse("OK") + + # When/Then: strict generation refuses the silent function-name fallback. + with pytest.raises(OpenAPISpecConfigError, match="unresolved"): + generate_openapi_spec(strict=True) + + +def test_cli_app_variable_uses_binding_route_for_innermost_openapi(tmp_path: Path) -> None: + # Given: an importable app uses the innermost decorator order. + (tmp_path / "function_app.py").write_text( + "import azure.functions as func\n" + "from azure_functions_openapi import openapi\n" + "app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)\n" + "@app.route(route='cli-items', methods=['GET'])\n" + "@openapi(summary='CLI items')\n" + "def cli_items(req: func.HttpRequest): return func.HttpResponse('OK')\n", + encoding="utf-8", + ) + output = tmp_path / "openapi.json" + + # When: the CLI resolves and scans the completed FunctionApp. + result = subprocess.run( + [ + sys.executable, + "-m", + "azure_functions_openapi.cli", + "generate", + "--app", + "function_app:app", + "--output", + str(output), + ], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + # Then: the generated path comes from the binding. + assert result.returncode == 0, result.stderr + assert set(json.loads(output.read_text(encoding="utf-8"))["paths"]) == {"/api/cli-items"} + + +def test_cli_module_warns_for_innermost_openapi_without_scan(tmp_path: Path) -> None: + # Given: a module-only import registers metadata before its route is visible. + (tmp_path / "function_app.py").write_text( + "import azure.functions as func\n" + "from azure_functions_openapi import openapi\n" + "app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)\n" + "@app.route(route='cli-items', methods=['GET'])\n" + "@openapi(summary='CLI items')\n" + "def cli_items(req: func.HttpRequest): return func.HttpResponse('OK')\n", + encoding="utf-8", + ) + + # When: the CLI imports only the module and cannot scan the app binding. + result = subprocess.run( + [sys.executable, "-m", "azure_functions_openapi.cli", "generate", "--app", "function_app"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + # Then: generation succeeds for compatibility but exposes the unresolved route. + assert result.returncode == 0, result.stderr + warnings = [json.loads(line) for line in result.stderr.splitlines() if line.startswith("{")] + assert any(warning["code"] == "unresolved-route" for warning in warnings) + + def test_spec_keeps_function_name_route_without_explicit_binding_route() -> None: # Given: neither decorator supplies an explicit route. app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 56132434..7ac976e4 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -93,6 +93,8 @@ def test_openapi_submodule_is_importable_via_importlib(self) -> None: assert isinstance(openapi_module, types.ModuleType) assert openapi_module.generate_openapi_spec is azure_functions_openapi.generate_openapi_spec + assert openapi_module.get_openapi_json is azure_functions_openapi.get_openapi_json + assert openapi_module.get_openapi_yaml is azure_functions_openapi.get_openapi_yaml def test_openapi_submodule_emits_deprecation_warning(self) -> None: import importlib From 70cfd72f430ba71aa9c9d5bb5ccaa8e5c01f4652 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 04/15] docs(readme): explain decorator route discovery --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index def5b77c..2c8ceab1 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,11 @@ azure-functions-openapi generate --app function_app --title "My API" --pretty -- azure-functions-openapi generate --app function_app --format yaml --output openapi.yaml ``` -Pass `module:variable` to resolve the `FunctionApp` instance and also discover -endpoint-metadata routes — those registered by producers like `@validate_http` -or `azure-functions-langgraph` — merging them with your `@openapi` routes into a -single spec. With `module` alone the CLI imports the module (firing `@openapi` -decorators) but does not scan for endpoint metadata: +Pass `module:variable` to resolve and scan the completed `FunctionApp`. This is +required when `@openapi` is below `@app.route`, because the inner decorator runs +before the route binding exists. The scan also discovers endpoint-metadata routes +from producers like `@validate_http` or `azure-functions-langgraph`. With +`module` alone, unresolved routes warn and fall back to the function name: ```bash azure-functions-openapi generate --app function_app:app --title "My API" @@ -263,6 +263,7 @@ def openapi_json(req: func.HttpRequest) -> func.HttpResponse: get_openapi_json( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/json", ) @@ -274,6 +275,7 @@ def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: get_openapi_yaml( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/x-yaml", ) From 9527475ec6ca0532b665d257c859717069147ad5 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 05/15] docs(i18n): document app route scanning --- README.ja.md | 4 ++++ README.ko.md | 4 ++++ README.zh-CN.md | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/README.ja.md b/README.ja.md index 2ad1d90b..ce84e537 100644 --- a/README.ja.md +++ b/README.ja.md @@ -166,6 +166,8 @@ from azure_functions_openapi import ( app = func.FunctionApp() +> `@openapi` を `@app.route` の下(最内層)に置くと、ルートバインディングより先に実行されます。この順序では、正しいパスを検出するため、パッケージルートの `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)`、または `generate_openapi_report(app=app)` を使用してください。`app=` なしでは関数名へのフォールバック警告が発生し、`strict=True` ではエラーになります。 + # 普通の Pydantic モデルで API を記述します。 class GreetRequest(BaseModel): @@ -209,6 +211,7 @@ def openapi_json(req: func.HttpRequest) -> func.HttpResponse: get_openapi_json( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/json", ) @@ -220,6 +223,7 @@ def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: get_openapi_yaml( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/x-yaml", ) diff --git a/README.ko.md b/README.ko.md index 22684d69..c19aec6f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -166,6 +166,8 @@ from azure_functions_openapi import ( app = func.FunctionApp() +> `@openapi`가 `@app.route` 아래(가장 안쪽)에 있으면 라우트 바인딩보다 먼저 실행됩니다. 이 순서를 사용할 때는 정확한 경로를 검색하도록 패키지 루트의 `generate_openapi_spec(app=app)`, `get_openapi_json(app=app)`, `get_openapi_yaml(app=app)` 또는 `generate_openapi_report(app=app)`를 사용하세요. `app=` 없이 생성하면 함수 이름 대체 경로에 대한 경고가 발생하며, `strict=True`에서는 오류가 발생합니다. + # 일반 Pydantic 모델로 API를 기술합니다. class GreetRequest(BaseModel): @@ -209,6 +211,7 @@ def openapi_json(req: func.HttpRequest) -> func.HttpResponse: get_openapi_json( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/json", ) @@ -220,6 +223,7 @@ def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: get_openapi_yaml( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/x-yaml", ) diff --git a/README.zh-CN.md b/README.zh-CN.md index a08a75e7..23466713 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -166,6 +166,8 @@ from azure_functions_openapi import ( app = func.FunctionApp() +> 当 `@openapi` 位于 `@app.route` 下方(最内层)时,它会在路由绑定存在之前执行。使用此顺序时,请通过包根目录的 `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)` 或 `generate_openapi_report(app=app)` 扫描应用以获得正确路径。省略 `app=` 会对函数名回退路径发出警告;`strict=True` 时会报错。 + # 使用普通的 Pydantic 模型描述你的 API。 class GreetRequest(BaseModel): @@ -209,6 +211,7 @@ def openapi_json(req: func.HttpRequest) -> func.HttpResponse: get_openapi_json( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/json", ) @@ -220,6 +223,7 @@ def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: get_openapi_yaml( title="Sample API", description="OpenAPI document for the Sample API.", + app=app, ), mimetype="application/x-yaml", ) From 2b0e1ffb657641fb4d2d40e8ca91eabae65a71e1 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 06/15] docs(api): document app-aware generation --- docs/api.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index c66a3b82..db8935ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -66,6 +66,15 @@ from azure_functions_openapi import ( !!! note `get_openapi_json()` and `get_openapi_yaml()` return strings, not `HttpResponse`. Wrap the returned value in `func.HttpResponse` in your Azure Function route. +!!! warning + If `@openapi` is the innermost decorator (below `@app.route`), it runs before + the route binding exists. Pass the completed app to the package-root + `generate_openapi_spec(app=app)`, `get_openapi_json(app=app)`, + `get_openapi_yaml(app=app)`, or `generate_openapi_report(app=app)`. Without + binding evidence, generation warns before falling back to the function name; + `strict=True` raises instead. The low-level functions in + `azure_functions_openapi.spec` intentionally remain SDK-free registry compilers. + ## Common usage patterns ### Minimal endpoint @@ -177,12 +186,12 @@ def search(req: func.HttpRequest) -> func.HttpResponse: ```python @app.route(route="openapi.json", methods=["GET"]) def openapi_json(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse(get_openapi_json(title="My API", version="1.0.0"), mimetype="application/json") + return func.HttpResponse(get_openapi_json(title="My API", version="1.0.0", app=app), mimetype="application/json") @app.route(route="openapi.yaml", methods=["GET"]) def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: - return func.HttpResponse(get_openapi_yaml(title="My API", version="1.0.0"), mimetype="application/x-yaml") + return func.HttpResponse(get_openapi_yaml(title="My API", version="1.0.0", app=app), mimetype="application/x-yaml") @app.route(route="docs", methods=["GET"]) From df75c46f95a4b0fbb5ec43a4db045590d855a697 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 07/15] docs(cli): require app variables for route discovery --- docs/cli.md | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index d6d82ba0..cbb1ddee 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -66,26 +66,28 @@ Generate OpenAPI 3.1 output: azure-functions-openapi generate --openapi-version 3.1 --output openapi-3.1.json ``` -Import your function app so routes are registered: +Import and scan your function app so decorator routes use their real bindings: ```bash -azure-functions-openapi generate --app function_app --title "Todo API" +azure-functions-openapi generate --app function_app:app --title "Todo API" ``` Pretty-print JSON output: ```bash -azure-functions-openapi generate --app function_app --pretty --output openapi.json +azure-functions-openapi generate --app function_app:app --pretty --output openapi.json ``` ### Endpoint-metadata discovery (`module:variable`) -`@openapi`-decorated routes register themselves the moment the module is -imported, so `--app module` is enough to see them. Producers that register -**only** through the shared endpoint-metadata namespace — `@validate_http` -(azure-functions-validation), `azure-functions-langgraph`, and other -third-party producers — are attached to the live `FunctionApp` object and are -discovered by scanning it. +`@openapi`-decorated routes register themselves when the module is imported, +but an innermost `@openapi` (below `@app.route`) runs before its route binding +exists. Therefore `--app module:variable` is required to recover the real route. +It is also required for producers that register **only** through the shared +endpoint-metadata namespace — `@validate_http` +(azure-functions-validation), `azure-functions-langgraph`, and others — because +their metadata is attached to the live `FunctionApp` object and discovered by +scanning it. To include those endpoints, pass an explicit `module:variable` so the CLI can resolve the `FunctionApp` instance and run discovery on it: @@ -98,10 +100,10 @@ azure-functions-openapi generate --app function_app:app --output openapi.json Discovery semantics: -- **`--app function_app`** (module only): the module is imported so `@openapi` - decorators fire, but endpoint-metadata discovery is **not** run — the CLI - never guesses the `FunctionApp` variable name. A note is printed reminding - you to pass `module:variable` to also discover endpoint-metadata routes. +- **`--app function_app`** (module only): decorators fire, but binding discovery + is **not** run. An innermost `@openapi` therefore emits an `unresolved-route` + warning and uses the historical function-name fallback. The CLI never guesses + the `FunctionApp` variable name. - **`--app function_app:app`** (module + variable): the named `FunctionApp` attribute is resolved and scanned, merging endpoint-metadata routes into the same registry as the decorator routes. @@ -120,7 +122,7 @@ Discovery semantics: | Option | Alias | Values | Default | Description | | --- | --- | --- | --- | --- | -| `--app` | - | `module` or `module:var` | - | Import module so `@openapi` decorators register routes; with `module:var`, also resolve the `FunctionApp` and discover endpoint-metadata routes (see [Endpoint-metadata discovery](#endpoint-metadata-discovery-modulevariable)) | +| `--app` | - | `module` or `module:var` | - | Import the module; use `module:var` to scan route bindings (required for innermost `@openapi`) and endpoint metadata | | `--title` | - | any string | `API` | OpenAPI `info.title` | | `--version` | - | any string | `1.0.0` | OpenAPI `info.version` | | `--description` | - | any string | library default | OpenAPI `info.description` (Markdown / CommonMark supported) | @@ -190,10 +192,10 @@ jobs: ### Empty `paths` in output - Ensure app handlers are decorated with `@openapi` -- Pass `--app ` so decorated routes are registered before generation: +- Pass `--app :` so decorated routes and bindings are reconciled: ```bash - azure-functions-openapi generate --app function_app --title "My API" + azure-functions-openapi generate --app function_app:app --title "My API" ``` - Use `module:variable` syntax when the `FunctionApp` instance is not named `app`: From 879096cca83c5109e2b5d5ad450c580daee62524 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:19:07 +0000 Subject: [PATCH 08/15] docs(llms): correct decorator order examples --- llms-full.txt | 7 +++++-- llms.txt | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/llms-full.txt b/llms-full.txt index b01528cc..ad7fadf1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -187,10 +187,11 @@ def generate_openapi_spec( license: dict[str, Any] | None = None, external_docs: dict[str, Any] | None = None, tags: list[dict[str, Any]] | None = None, + app: object | None = None, ) -> dict[str, Any]: ... ``` -**Purpose**: Compile all registered endpoints into an OpenAPI specification dictionary. +**Purpose**: At the package root, optionally scan `app` and then compile all registered endpoints. Pass `app=app` when `@openapi` is below `@app.route`; the inner decorator runs before the binding exists. The JSON, YAML, and report facades accept the same option. Without route evidence, generation warns before using the function name fallback, and `strict=True` raises. `azure_functions_openapi.spec` remains the SDK-free low-level registry compiler. **Returns**: OpenAPI spec dict (can be JSON-serialized or converted to YAML). @@ -214,6 +215,7 @@ def get_openapi_json( registry: OpenAPIRegistry | None = None, hoist_flat_schemas: bool = False, infer_auth_level: bool = False, + app: object | None = None, ) -> str: ... ``` @@ -384,7 +386,8 @@ def openapi_spec(req: func.HttpRequest) -> func.HttpResponse: spec = get_openapi_json( title="My API", version="1.0.0", - description="My Azure Functions API" + description="My Azure Functions API", + app=app, ) return func.HttpResponse(spec, mimetype="application/json") diff --git a/llms.txt b/llms.txt index 09655a85..72b190ba 100644 --- a/llms.txt +++ b/llms.txt @@ -22,8 +22,8 @@ Renders Swagger UI with enterprise security headers (CSP, HSTS). - `@openapi(summary, description, ...)` — Decorator for HTTP handlers - `register_openapi_metadata(path, method, ...)` — Programmatic metadata registration - `clear_openapi_registry()` — Clear registry for fresh start -- `generate_openapi_spec(title, version, ...)` — Generate spec dict -- `get_openapi_json(title, version, ...)` — Generate OpenAPI JSON string +- Package-root `generate_openapi_spec(..., app=app)` — scan a completed FunctionApp, then generate a spec dict +- Package-root `get_openapi_json(..., app=app)` / `get_openapi_yaml(..., app=app)` — scan then serialize; pass `app=` when `@openapi` is below `@app.route` - `get_openapi_yaml(title, version, ...)` — Generate OpenAPI YAML string - `render_swagger_ui(title, openapi_url, ...)` — Render Swagger UI HTML - `OpenAPIOperationMetadata` — Type for operation metadata @@ -56,7 +56,7 @@ def greet(req: func.HttpRequest) -> func.HttpResponse: # Expose OpenAPI spec @app.route(route="openapi.json") def openapi_spec(req: func.HttpRequest) -> func.HttpResponse: - spec = get_openapi_json(title="My API", version="1.0.0") + spec = get_openapi_json(title="My API", version="1.0.0", app=app) return func.HttpResponse(spec, mimetype="application/json") ``` From 319a82aeb7c453457edce53c65376d530fba51ff Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 09/15] fix(spec): preserve facade errors and nonfatal route warnings --- src/azure_functions_openapi/__init__.py | 97 +++++++++++++------------ src/azure_functions_openapi/cli.py | 1 - tests/test_decorator_order_routes.py | 21 ++++-- tests/test_generation_facade_compat.py | 78 ++++++++++++++++++++ 4 files changed, 143 insertions(+), 54 deletions(-) create mode 100644 tests/test_generation_facade_compat.py diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index 476c4122..2d73be54 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -36,7 +36,6 @@ def _prepare_generation( app: object | None, route_prefix: str, registry: OpenAPIRegistry | None, - strict: bool, ) -> tuple[SpecWarning, ...]: """Scan an app and diagnose registry routes that still lack binding evidence.""" if app is not None: @@ -47,16 +46,14 @@ def _prepare_generation( SpecWarning( code=WarningCode.UNRESOLVED_ROUTE, message=( - f"Route for '{entry.get('function_name') or key}' has no binding evidence; " - "using the function name fallback. Pass app=... or set route= on @openapi." + f"Route for '{entry.get('function_name') or key}' could not be verified; " + "if this function uses a custom route, pass app=... (or route=...)." ), function_name=entry.get("function_name") or key, ) for key, entry in active_registry.snapshot().items() if entry.get("route") is None and not entry.get("_route_evidence") ) - if unresolved and strict: - raise OpenAPISpecConfigError(unresolved[0].message) for item in unresolved: warnings.warn(item.message, RuntimeWarning, stacklevel=3) return unresolved @@ -81,7 +78,7 @@ def generate_openapi_spec( app: object | None = None, ) -> dict[str, Any]: """Compile a spec, optionally reconciling routes from a FunctionApp first.""" - _prepare_generation(app, route_prefix, registry, strict) + _prepare_generation(app, route_prefix, registry) return _generate_openapi_spec( title=title, version=version, @@ -120,25 +117,30 @@ def get_openapi_json( app: object | None = None, ) -> str: """Return JSON after optionally reconciling routes from a completed FunctionApp.""" - spec = generate_openapi_spec( - title, - version, - openapi_version, - description, - security_schemes, - route_prefix, - strict, - registry, - hoist_flat_schemas, - infer_auth_level, - servers, - contact, - license, - external_docs, - tags, - app, - ) - return json.dumps(spec, indent=2, ensure_ascii=False) + try: + spec = generate_openapi_spec( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + app, + ) + return json.dumps(spec, indent=2, ensure_ascii=False) + except ValueError: + raise + except Exception as error: + raise RuntimeError("Failed to generate OpenAPI JSON") from error def get_openapi_yaml( @@ -160,25 +162,30 @@ def get_openapi_yaml( app: object | None = None, ) -> str: """Return YAML after optionally reconciling routes from a completed FunctionApp.""" - spec = generate_openapi_spec( - title, - version, - openapi_version, - description, - security_schemes, - route_prefix, - strict, - registry, - hoist_flat_schemas, - infer_auth_level, - servers, - contact, - license, - external_docs, - tags, - app, - ) - return yaml.safe_dump(spec, sort_keys=False, allow_unicode=True) + try: + spec = generate_openapi_spec( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + app, + ) + return yaml.safe_dump(spec, sort_keys=False, allow_unicode=True) + except ValueError: + raise + except Exception as error: + raise RuntimeError("Failed to generate OpenAPI YAML") from error def generate_openapi_report( @@ -200,7 +207,7 @@ def generate_openapi_report( app: object | None = None, ) -> SpecReport: """Return a spec report after optionally reconciling a completed FunctionApp.""" - unresolved = _prepare_generation(app, route_prefix, registry, strict) + unresolved = _prepare_generation(app, route_prefix, registry) report = _generate_openapi_report( title, version, diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index 9833c059..ec3c8f6e 100644 --- a/src/azure_functions_openapi/cli.py +++ b/src/azure_functions_openapi/cli.py @@ -284,7 +284,6 @@ def handle_generate(args: argparse.Namespace) -> int: None, getattr(args, "route_prefix", "/api"), active_registry, - getattr(args, "strict", False), ) spec = generate_openapi_spec( args.title, diff --git a/tests/test_decorator_order_routes.py b/tests/test_decorator_order_routes.py index 9d8db209..b10126e4 100644 --- a/tests/test_decorator_order_routes.py +++ b/tests/test_decorator_order_routes.py @@ -19,7 +19,6 @@ get_openapi_yaml, openapi, ) -from azure_functions_openapi.exceptions import OpenAPISpecConfigError @pytest.fixture(autouse=True) @@ -141,15 +140,21 @@ def unresolved(req: func.HttpRequest) -> func.HttpResponse: ] -def test_strict_generation_raises_when_route_has_no_binding_evidence() -> None: - # Given: a bare decorator has no evidence for its runtime route. - @openapi(summary="Unresolved") - def unresolved(req: func.HttpRequest) -> func.HttpResponse: +def test_strict_generation_warns_when_default_route_cannot_be_verified() -> None: + # Given: Azure's implicit route is correct, but the inner decorator cannot verify it. + app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) + + @app.route(methods=["GET"]) + @openapi(summary="Implicit") + def implicit(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse("OK") - # When/Then: strict generation refuses the silent function-name fallback. - with pytest.raises(OpenAPISpecConfigError, match="unresolved"): - generate_openapi_spec(strict=True) + # When: strict generation runs without the completed app. + with pytest.warns(RuntimeWarning, match="could not be verified"): + spec = generate_openapi_spec(strict=True) + + # Then: the compatible function-name route remains available under strict mode. + assert set(spec["paths"]) == {"/api/implicit"} def test_cli_app_variable_uses_binding_route_for_innermost_openapi(tmp_path: Path) -> None: diff --git a/tests/test_generation_facade_compat.py b/tests/test_generation_facade_compat.py new file mode 100644 index 00000000..54353670 --- /dev/null +++ b/tests/test_generation_facade_compat.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from collections.abc import Callable +import importlib +from types import ModuleType + +import pytest + +import azure_functions_openapi as package_api +from azure_functions_openapi.exceptions import OpenAPISpecConfigError + + +@pytest.fixture(scope="module") +def root_api() -> ModuleType: + return package_api + + +@pytest.fixture(scope="module") +def shim_api() -> ModuleType: + with pytest.warns(DeprecationWarning): + return importlib.import_module("azure_functions_openapi.openapi") + + +@pytest.mark.parametrize("module_fixture", ["root_api", "shim_api"]) +@pytest.mark.parametrize( + ("function_name", "message"), + [ + ("get_openapi_json", "Failed to generate OpenAPI JSON"), + ("get_openapi_yaml", "Failed to generate OpenAPI YAML"), + ], +) +def test_serialization_facades_wrap_unexpected_generation_errors( + module_fixture: str, + function_name: str, + message: str, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: spec generation fails unexpectedly through a public facade import. + module = request.getfixturevalue(module_fixture) + cause = TypeError("unexpected generation failure") + + def fail_generation(*args: object, **kwargs: object) -> dict[str, object]: + raise cause + + monkeypatch.setattr(package_api, "generate_openapi_spec", fail_generation) + facade: Callable[[], str] = getattr(module, function_name) + + # When: the facade generates serialized output. + with pytest.raises(RuntimeError, match=f"^{message}$") as exc_info: + facade() + + # Then: it preserves the historical RuntimeError contract and cause. + assert exc_info.value.__cause__ is cause + + +@pytest.mark.parametrize("module_fixture", ["root_api", "shim_api"]) +@pytest.mark.parametrize("function_name", ["get_openapi_json", "get_openapi_yaml"]) +def test_serialization_facades_preserve_configuration_errors( + module_fixture: str, + function_name: str, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: spec generation reports a caller-fixable configuration error. + module = request.getfixturevalue(module_fixture) + error = OpenAPISpecConfigError("invalid configuration") + + def fail_generation(*args: object, **kwargs: object) -> dict[str, object]: + raise error + + monkeypatch.setattr(package_api, "generate_openapi_spec", fail_generation) + facade: Callable[[], str] = getattr(module, function_name) + + # When/Then: the facade passes the original typed error through unchanged. + with pytest.raises(OpenAPISpecConfigError) as exc_info: + facade() + assert exc_info.value is error From 1f3bc7cecfc48262b6a83791a83acd14d1945cd5 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 10/15] docs(readme): clarify nonfatal unresolved routes --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2c8ceab1..5e35e936 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,10 @@ Pass `module:variable` to resolve and scan the completed `FunctionApp`. This is required when `@openapi` is below `@app.route`, because the inner decorator runs before the route binding exists. The scan also discovers endpoint-metadata routes from producers like `@validate_http` or `azure-functions-langgraph`. With -`module` alone, unresolved routes warn and fall back to the function name: +`module` alone, routes that cannot be verified emit a non-fatal +`unresolved-route` warning and fall back to the function name. This warning +remains non-fatal under `strict=True`, because the fallback can be Azure's +correct default route: ```bash azure-functions-openapi generate --app function_app:app --title "My API" From 55aa3fc1e81175ec2c50e472290c19dbeecfb014 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 11/15] docs(i18n): clarify nonfatal unresolved routes --- README.ja.md | 2 +- README.ko.md | 2 +- README.zh-CN.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index ce84e537..ddd8be12 100644 --- a/README.ja.md +++ b/README.ja.md @@ -166,7 +166,7 @@ from azure_functions_openapi import ( app = func.FunctionApp() -> `@openapi` を `@app.route` の下(最内層)に置くと、ルートバインディングより先に実行されます。この順序では、正しいパスを検出するため、パッケージルートの `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)`、または `generate_openapi_report(app=app)` を使用してください。`app=` なしでは関数名へのフォールバック警告が発生し、`strict=True` ではエラーになります。 +> `@openapi` を `@app.route` の下(最内層)に置くと、ルートバインディングより先に実行されます。この順序では、正しいパスを検出するため、パッケージルートの `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)`、または `generate_openapi_report(app=app)` を使用してください。`app=` なしでは、検証できないルートに対して非致命的な `unresolved-route` 警告が発生し、関数名へフォールバックします。このフォールバックが Azure の正しい既定ルートである場合があるため、`strict=True` でもエラーにはなりません。 # 普通の Pydantic モデルで API を記述します。 diff --git a/README.ko.md b/README.ko.md index c19aec6f..52123cb3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -166,7 +166,7 @@ from azure_functions_openapi import ( app = func.FunctionApp() -> `@openapi`가 `@app.route` 아래(가장 안쪽)에 있으면 라우트 바인딩보다 먼저 실행됩니다. 이 순서를 사용할 때는 정확한 경로를 검색하도록 패키지 루트의 `generate_openapi_spec(app=app)`, `get_openapi_json(app=app)`, `get_openapi_yaml(app=app)` 또는 `generate_openapi_report(app=app)`를 사용하세요. `app=` 없이 생성하면 함수 이름 대체 경로에 대한 경고가 발생하며, `strict=True`에서는 오류가 발생합니다. +> `@openapi`가 `@app.route` 아래(가장 안쪽)에 있으면 라우트 바인딩보다 먼저 실행됩니다. 이 순서를 사용할 때는 정확한 경로를 검색하도록 패키지 루트의 `generate_openapi_spec(app=app)`, `get_openapi_json(app=app)`, `get_openapi_yaml(app=app)` 또는 `generate_openapi_report(app=app)`를 사용하세요. `app=` 없이 생성하면 확인할 수 없는 경로에 대해 치명적이지 않은 `unresolved-route` 경고가 발생하고 함수 이름으로 대체됩니다. 이 대체 경로가 Azure의 올바른 기본 경로일 수 있으므로 `strict=True`에서도 오류가 발생하지 않습니다. # 일반 Pydantic 모델로 API를 기술합니다. diff --git a/README.zh-CN.md b/README.zh-CN.md index 23466713..1f90b3c5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -166,7 +166,7 @@ from azure_functions_openapi import ( app = func.FunctionApp() -> 当 `@openapi` 位于 `@app.route` 下方(最内层)时,它会在路由绑定存在之前执行。使用此顺序时,请通过包根目录的 `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)` 或 `generate_openapi_report(app=app)` 扫描应用以获得正确路径。省略 `app=` 会对函数名回退路径发出警告;`strict=True` 时会报错。 +> 当 `@openapi` 位于 `@app.route` 下方(最内层)时,它会在路由绑定存在之前执行。使用此顺序时,请通过包根目录的 `generate_openapi_spec(app=app)`、`get_openapi_json(app=app)`、`get_openapi_yaml(app=app)` 或 `generate_openapi_report(app=app)` 扫描应用以获得正确路径。省略 `app=` 时,无法验证的路由会发出非致命的 `unresolved-route` 警告并回退到函数名。由于该回退可能正是 Azure 的默认路由,因此即使 `strict=True` 也不会报错。 # 使用普通的 Pydantic 模型描述你的 API。 From aea5d5133fd3c6af1a45161ddc8743c5ab19917b Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 12/15] docs(api): clarify nonfatal unresolved routes --- docs/api.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/api.md b/docs/api.md index db8935ed..fcc8527a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -71,8 +71,10 @@ from azure_functions_openapi import ( the route binding exists. Pass the completed app to the package-root `generate_openapi_spec(app=app)`, `get_openapi_json(app=app)`, `get_openapi_yaml(app=app)`, or `generate_openapi_report(app=app)`. Without - binding evidence, generation warns before falling back to the function name; - `strict=True` raises instead. The low-level functions in + binding evidence, generation emits a non-fatal `unresolved-route` warning + before falling back to the function name. The warning remains non-fatal + under `strict=True`, because the fallback can be Azure's correct default + route. The low-level functions in `azure_functions_openapi.spec` intentionally remain SDK-free registry compilers. ## Common usage patterns From 14a0431eaf7b72ad9ae9a7344dcbfcba57203ab4 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 13/15] docs(cli): clarify nonfatal unresolved routes --- docs/cli.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index cbb1ddee..b0c32296 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,8 +102,9 @@ Discovery semantics: - **`--app function_app`** (module only): decorators fire, but binding discovery is **not** run. An innermost `@openapi` therefore emits an `unresolved-route` - warning and uses the historical function-name fallback. The CLI never guesses - the `FunctionApp` variable name. + warning and uses the historical function-name fallback. This warning remains + non-fatal with `--strict`, because the fallback can be Azure's correct default + route. The CLI never guesses the `FunctionApp` variable name. - **`--app function_app:app`** (module + variable): the named `FunctionApp` attribute is resolved and scanned, merging endpoint-metadata routes into the same registry as the decorator routes. From b820047cc0e89d8f8b923ec895c7ba180a9b6ce3 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:48:00 +0000 Subject: [PATCH 14/15] docs(llms): clarify nonfatal unresolved routes --- llms-full.txt | 2 +- llms.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/llms-full.txt b/llms-full.txt index ad7fadf1..025d60d8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -191,7 +191,7 @@ def generate_openapi_spec( ) -> dict[str, Any]: ... ``` -**Purpose**: At the package root, optionally scan `app` and then compile all registered endpoints. Pass `app=app` when `@openapi` is below `@app.route`; the inner decorator runs before the binding exists. The JSON, YAML, and report facades accept the same option. Without route evidence, generation warns before using the function name fallback, and `strict=True` raises. `azure_functions_openapi.spec` remains the SDK-free low-level registry compiler. +**Purpose**: At the package root, optionally scan `app` and then compile all registered endpoints. Pass `app=app` when `@openapi` is below `@app.route`; the inner decorator runs before the binding exists. The JSON, YAML, and report facades accept the same option. Without route evidence, generation emits a non-fatal structured `unresolved-route` warning before using the function-name fallback, including under `strict=True`, because the fallback can be Azure's correct default route. `azure_functions_openapi.spec` remains the SDK-free low-level registry compiler. **Returns**: OpenAPI spec dict (can be JSON-serialized or converted to YAML). diff --git a/llms.txt b/llms.txt index 72b190ba..434b9b49 100644 --- a/llms.txt +++ b/llms.txt @@ -24,6 +24,7 @@ Renders Swagger UI with enterprise security headers (CSP, HSTS). - `clear_openapi_registry()` — Clear registry for fresh start - Package-root `generate_openapi_spec(..., app=app)` — scan a completed FunctionApp, then generate a spec dict - Package-root `get_openapi_json(..., app=app)` / `get_openapi_yaml(..., app=app)` — scan then serialize; pass `app=` when `@openapi` is below `@app.route` +- Unverified routes emit a non-fatal structured `unresolved-route` warning and use the function-name fallback, including under `strict=True` - `get_openapi_yaml(title, version, ...)` — Generate OpenAPI YAML string - `render_swagger_ui(title, openapi_url, ...)` — Render Swagger UI HTML - `OpenAPIOperationMetadata` — Type for operation metadata From 91c23646c53b45e79481bc8a83214a16d03236f1 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Wed, 7 Oct 2026 11:55:41 +0000 Subject: [PATCH 15/15] fix(spec): match low-level error wrapping and keep unresolved routes advisory --- docs/cli.md | 2 +- src/azure_functions_openapi/__init__.py | 4 +-- src/azure_functions_openapi/cli.py | 6 +++- tests/test_decorator_order_routes.py | 37 +++++++++++++++++++++++++ tests/test_generation_facade_compat.py | 31 +++++++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index b0c32296..058d0e5d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -134,7 +134,7 @@ Discovery semantics: | `--route-prefix` | - | any string (or `""`) | `/api` | HTTP route prefix from `host.json` `extensions.http.routePrefix`. See [Route Prefix](route-prefix.md). | | `--fail-on-empty-paths` | - | flag | `false` | Exit with code 1 if the generated spec has no paths | | `--strict` | - | flag | `false` | Fail on any malformed registry entry instead of skipping it. Recommended for CI where a missing path should break the build | -| `--fail-on-warnings` | - | flag | `false` | Exit with code 2 if the generator emits any structured warnings (version skew, namespace fallback, or spec-validation issues). Use in CI to stop a wrong-but-plausible spec from being published | +| `--fail-on-warnings` | - | flag | `false` | Exit with code 2 if the generator emits any structured warnings (version skew, namespace fallback, or spec-validation issues; the advisory `unresolved-route` warning never fails). Use in CI to stop a wrong-but-plausible spec from being published | | `--isolate-app` | - | flag | `false` | Scan the `--app` `FunctionApp` into a fresh, app-scoped registry instead of the shared global one. Requires `--app module:variable`. Use when several apps are imported in one process to avoid cross-app route leakage | ## Exit codes diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index 2d73be54..23902e77 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -137,7 +137,7 @@ def get_openapi_json( app, ) return json.dumps(spec, indent=2, ensure_ascii=False) - except ValueError: + except OpenAPISpecConfigError: raise except Exception as error: raise RuntimeError("Failed to generate OpenAPI JSON") from error @@ -182,7 +182,7 @@ def get_openapi_yaml( app, ) return yaml.safe_dump(spec, sort_keys=False, allow_unicode=True) - except ValueError: + except OpenAPISpecConfigError: raise except Exception as error: raise RuntimeError("Failed to generate OpenAPI YAML") from error diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index ec3c8f6e..571b746b 100644 --- a/src/azure_functions_openapi/cli.py +++ b/src/azure_functions_openapi/cli.py @@ -7,6 +7,7 @@ import sys from azure_functions_openapi import _prepare_generation +from azure_functions_openapi._warnings import WarningCode from azure_functions_openapi.bridge import scan_endpoint_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import OpenAPIRegistry @@ -331,7 +332,10 @@ def handle_generate(args: argparse.Namespace) -> int: # surfaced to stderr above for CI to parse). Placed AFTER the empty-paths # block so its diagnostic hint still prints and --fail-on-empty-paths # (exit 1) stays reachable when warnings and empty paths coincide. - if getattr(args, "fail_on_warnings", False) is True and warnings: + # unresolved-route is advisory: the implicit function-name route is often + # correct, so it never fails --fail-on-warnings. + gating_warnings = [w for w in warnings if w.code != WarningCode.UNRESOLVED_ROUTE] + if getattr(args, "fail_on_warnings", False) is True and gating_warnings: return 2 if args.format == "json": diff --git a/tests/test_decorator_order_routes.py b/tests/test_decorator_order_routes.py index b10126e4..e6219890 100644 --- a/tests/test_decorator_order_routes.py +++ b/tests/test_decorator_order_routes.py @@ -220,6 +220,43 @@ def test_cli_module_warns_for_innermost_openapi_without_scan(tmp_path: Path) -> assert any(warning["code"] == "unresolved-route" for warning in warnings) +def test_cli_unresolved_route_does_not_trip_fail_on_warnings(tmp_path: Path) -> None: + # Given: a module-only import of an innermost @openapi with an implicit route. + (tmp_path / "function_app.py").write_text( + "import azure.functions as func\n" + "from azure_functions_openapi import openapi\n" + "app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)\n" + "@app.route(methods=['GET'])\n" + "@openapi(summary='Implicit')\n" + "def implicit_items(req: func.HttpRequest): return func.HttpResponse('OK')\n", + encoding="utf-8", + ) + + # When: the CLI runs with --strict --fail-on-warnings. + result = subprocess.run( + [ + sys.executable, + "-m", + "azure_functions_openapi.cli", + "generate", + "--app", + "function_app", + "--strict", + "--fail-on-warnings", + ], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + # Then: the advisory warning is reported but the spec is still emitted. + assert result.returncode == 0, result.stderr + assert "/api/implicit_items" in result.stdout + warnings = [json.loads(line) for line in result.stderr.splitlines() if line.startswith("{")] + assert any(warning["code"] == "unresolved-route" for warning in warnings) + + def test_spec_keeps_function_name_route_without_explicit_binding_route() -> None: # Given: neither decorator supplies an explicit route. app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) diff --git a/tests/test_generation_facade_compat.py b/tests/test_generation_facade_compat.py index 54353670..60496808 100644 --- a/tests/test_generation_facade_compat.py +++ b/tests/test_generation_facade_compat.py @@ -76,3 +76,34 @@ def fail_generation(*args: object, **kwargs: object) -> dict[str, object]: with pytest.raises(OpenAPISpecConfigError) as exc_info: facade() assert exc_info.value is error + + +@pytest.mark.parametrize("module_fixture", ["root_api", "shim_api"]) +@pytest.mark.parametrize( + ("function_name", "message"), + [ + ("get_openapi_json", "Failed to generate OpenAPI JSON"), + ("get_openapi_yaml", "Failed to generate OpenAPI YAML"), + ], +) +def test_serialization_facades_wrap_plain_value_errors( + module_fixture: str, + function_name: str, + message: str, + request: pytest.FixtureRequest, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: spec generation raises a plain ValueError (not a config error). + module = request.getfixturevalue(module_fixture) + cause = ValueError("plain value error") + + def fail_generation(*args: object, **kwargs: object) -> dict[str, object]: + raise cause + + monkeypatch.setattr(package_api, "generate_openapi_spec", fail_generation) + facade: Callable[[], str] = getattr(module, function_name) + + # When/Then: it is wrapped exactly like the low-level spec.py facades. + with pytest.raises(RuntimeError, match=f"^{message}$") as exc_info: + facade() + assert exc_info.value.__cause__ is cause