From 51dcd450f03d8d34bd25e7ea95e342542e10242e Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sat, 18 Jul 2026 22:16:59 +0900 Subject: [PATCH 1/7] fix(registry): unify callable identity, self-locking mutators, deprecate discrete params Resolve three related registry/decorator issues: - #279: Introduce canonical_function_id() (module.qualname via inspect.unwrap) so decorator and bridge agree on function identity. Bridge lookup now prefers find_by_function_id(), falls back to endpoint key, and only uses short-name matching when unambiguous (warns otherwise). - #284: OpenAPIRegistry.set/setdefault/get now acquire the internal RLock themselves, making mutators self-locking and safe within outer transactions. - #285: @openapi emits DeprecationWarning when discrete request_model/ request_body/response_model/response params are used, steering callers to the unified requests=/responses= API. Adds tests/test_registry_identity.py and tests/test_deprecation.py. Coverage 96.17% (>= 95% gate). Closes #279 Closes #284 Closes #285 --- src/azure_functions_openapi/bridge.py | 49 ++++-- src/azure_functions_openapi/decorator.py | 44 +++-- src/azure_functions_openapi/registry.py | 72 ++++++++- tests/test_deprecation.py | 76 +++++++++ tests/test_registry_identity.py | 196 +++++++++++++++++++++++ 5 files changed, 404 insertions(+), 33 deletions(-) create mode 100644 tests/test_deprecation.py create mode 100644 tests/test_registry_identity.py diff --git a/src/azure_functions_openapi/bridge.py b/src/azure_functions_openapi/bridge.py index 409aefe..88fac84 100644 --- a/src/azure_functions_openapi/bridge.py +++ b/src/azure_functions_openapi/bridge.py @@ -9,7 +9,7 @@ from azure_functions_openapi.decorator import register_openapi_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError -from azure_functions_openapi.registry import registry +from azure_functions_openapi.registry import canonical_function_id, registry from azure_functions_openapi.routes import DEFAULT_ROUTE_PREFIX, normalize_route_prefix from azure_functions_openapi.utils import type_to_schema @@ -294,6 +294,8 @@ def scan_validation_metadata(app: Any, route_prefix: str = DEFAULT_ROUTE_PREFIX) if metadata is None: continue + canonical_id = canonical_function_id(handler) + binding = _extract_http_binding(function_obj) if binding is None: logger.debug( @@ -309,21 +311,38 @@ def scan_validation_metadata(app: Any, route_prefix: str = DEFAULT_ROUTE_PREFIX) endpoint_key = f"{method}::{path}" with registry.lock: - explicit_by_name = registry.get(function_name) - explicit_by_endpoint = registry.get(endpoint_key) - - if explicit_by_name is not None: - _merge_into_existing(explicit_by_name, discovered) - logger.debug( - "Merged validation metadata into explicit @openapi entry '%s'", - function_name, - ) - continue - - if explicit_by_endpoint is not None: - _merge_into_existing(explicit_by_endpoint, discovered) + # Resolve the target entry by, in order of trust: + # 1. canonical callable identity (collision-free), + # 2. the OpenAPI endpoint key (method::path), + # 3. the short function name (backward-compatible fallback). + target = registry.find_by_function_id(canonical_id) + match_kind = "canonical @openapi id" + + if target is None: + target = registry.get(endpoint_key) + match_kind = "OpenAPI endpoint" + + if target is None: + # Short-name fallback: refuse to merge when the name is + # ambiguous (shared across modules) to avoid silently + # attaching metadata to the wrong handler. + if registry.count_by_function_name(function_name) > 1: + logger.warning( + "Refusing to merge validation metadata by ambiguous " + "short name '%s': multiple @openapi entries share this " + "name across modules. Registering a standalone endpoint " + "instead.", + function_name, + ) + else: + target = registry.get(function_name) + match_kind = "short-name fallback" + + if target is not None: + _merge_into_existing(target, discovered) logger.debug( - "Merged validation metadata into explicit OpenAPI endpoint '%s'", + "Merged validation metadata via %s into endpoint '%s'", + match_kind, endpoint_key, ) continue diff --git a/src/azure_functions_openapi/decorator.py b/src/azure_functions_openapi/decorator.py index 2d29587..f7e9d01 100644 --- a/src/azure_functions_openapi/decorator.py +++ b/src/azure_functions_openapi/decorator.py @@ -3,12 +3,13 @@ import logging from typing import Any, Callable, TypeVar, cast +import warnings from azure.functions.decorators.function_app import FunctionBuilder from pydantic import BaseModel from azure_functions_openapi.exceptions import OpenAPISpecConfigError, SDKIncompatibleError -from azure_functions_openapi.registry import OpenAPIRegistry, registry +from azure_functions_openapi.registry import OpenAPIRegistry, canonical_function_id, registry from azure_functions_openapi.utils import sanitize_operation_id, validate_route_path # Define a generic type variable for functions @@ -205,16 +206,15 @@ def update_todo(req: func.HttpRequest) -> func.HttpResponse: (equivalent to `response_model`) or a manual responses dict keyed by status code (equivalent to `response`). - .. note:: - **Deprecation plan (pre-1.0).** This decorator currently exposes two - parallel parameter styles: the discrete pairs - (``request_model``/``request_body`` and ``response_model``/``response``) - and the unified parameters (``requests`` and ``responses``). Before the - 1.0 contract freeze, only **one** documented style will ship. The - unified ``requests``/``responses`` parameters are the intended survivors; - the discrete parameters are planned for deprecation (with a release of - overlap and a ``DeprecationWarning``) and eventual removal. New code - should prefer ``requests``/``responses``. Tracking: issue #272. + .. deprecated:: + This decorator currently exposes two parallel parameter styles: the + discrete pairs (``request_model``/``request_body`` and + ``response_model``/``response``) and the unified parameters + (``requests`` and ``responses``). The unified ``requests``/``responses`` + parameters are the intended survivors; passing any of the discrete + parameters now emits a :class:`DeprecationWarning`. They will be removed + in a future release after a deprecation window. New code should prefer + ``requests``/``responses``. Tracking: issue #285. Returns ------- @@ -264,6 +264,26 @@ def decorator(func: F) -> F: resolved_response_model = response_model resolved_response = response + _deprecated_used = [ + name + for name, value in ( + ("request_model", request_model), + ("request_body", request_body), + ("response_model", response_model), + ("response", response), + ) + if value is not None + ] + if _deprecated_used: + warnings.warn( + f"The discrete @openapi parameter(s) {_deprecated_used} are " + "deprecated in favor of the unified 'requests='/'responses=' " + "parameters and will be removed in a future release. " + "See https://github.com/yeongseon/azure-functions-openapi-python/issues/285.", + DeprecationWarning, + stacklevel=2, + ) + if requests is not None: if request_model is not None or request_body is not None: raise ValueError( @@ -299,7 +319,7 @@ def decorator(func: F) -> F: metadata_func.__name__, ) - function_id = f"{metadata_func.__module__}.{metadata_func.__qualname__}" + function_id = canonical_function_id(metadata_func) with _registry_lock: registry_key = metadata_func.__name__ diff --git a/src/azure_functions_openapi/registry.py b/src/azure_functions_openapi/registry.py index 36e7921..a8414f3 100644 --- a/src/azure_functions_openapi/registry.py +++ b/src/azure_functions_openapi/registry.py @@ -13,6 +13,7 @@ from contextlib import AbstractContextManager import copy +import inspect import threading from typing import Any @@ -55,19 +56,33 @@ def entries(self) -> dict[str, dict[str, Any]]: return self._entries def get(self, key: str) -> dict[str, Any] | None: - """Return the live entry stored under *key*, or ``None`` if absent.""" - return self._entries.get(key) + """Return the live entry stored under *key*, or ``None`` if absent. + + The returned dict is the *live* entry (not a copy). Callers that need a + consistent read-modify-write view must hold :attr:`lock` across the + whole ``get`` → mutate sequence; use :meth:`snapshot` when a detached + copy is acceptable. + """ + with self._lock: + return self._entries.get(key) def set(self, key: str, value: dict[str, Any]) -> None: - """Store *value* under *key*. Caller must hold :attr:`lock`.""" - self._entries[key] = value + """Store *value* under *key*. + + Acquires :attr:`lock` internally. Because the lock is re-entrant, this + stays safe when the caller already holds it for a larger transaction. + """ + with self._lock: + self._entries[key] = value def setdefault(self, key: str, value: dict[str, Any]) -> dict[str, Any]: """Insert *value* under *key* if absent; return the stored entry. - Caller must hold :attr:`lock`. + Acquires :attr:`lock` internally (re-entrant, so nesting inside an + outer ``with registry.lock:`` transaction is safe). """ - return self._entries.setdefault(key, value) + with self._lock: + return self._entries.setdefault(key, value) def snapshot(self) -> dict[str, dict[str, Any]]: """Return a deep copy of all entries, taken under :attr:`lock`.""" @@ -79,7 +94,52 @@ def clear(self) -> None: with self._lock: self._entries.clear() + def find_by_function_id(self, function_id: str) -> dict[str, Any] | None: + """Return the entry whose ``_function_id`` equals *function_id*. + + This is the collision-free lookup path: ``@openapi`` records a + canonical ``_function_id`` (see :func:`canonical_function_id`) for every + entry, so a handler can be resolved by identity regardless of how its + short name collides with other modules. Returns ``None`` if no entry + matches. Caller should hold :attr:`lock` when the result is used for a + read-modify-write transaction. + """ + with self._lock: + for entry in self._entries.values(): + if entry.get("_function_id") == function_id: + return entry + return None + + def count_by_function_name(self, function_name: str) -> int: + """Return how many entries carry ``function_name`` as their name. + + Used to detect ambiguous short-name fallbacks (two handlers sharing a + short name across modules) so callers can refuse to merge silently. + """ + with self._lock: + return sum( + 1 + for entry in self._entries.values() + if entry.get("function_name") == function_name + ) + # Process-wide singleton. The ``@openapi`` decorator records metadata at import # time — before any application object exists — so a shared instance is required. registry = OpenAPIRegistry() + + +def canonical_function_id(handler: Any) -> str: + """Compute a stable, collision-free identity for a handler callable. + + Unwraps decorator layers (``functools.wraps`` sets ``__wrapped__``) so that + an inner handler and any wrappers resolve to the same identity, then keys by + fully-qualified name: ``f"{module}.{qualname}"``. Both the ``@openapi`` + decorator (when recording ``_function_id``) and the SDK bridge (when looking + an entry back up) use this helper, so they always agree on identity even + when two handlers share a short ``__name__`` across different modules. + """ + target = inspect.unwrap(handler) if callable(handler) else handler + module = getattr(target, "__module__", "") or "" + qualname = getattr(target, "__qualname__", None) or getattr(target, "__name__", "") or "" + return f"{module}.{qualname}" diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py new file mode 100644 index 0000000..8597dda --- /dev/null +++ b/tests/test_deprecation.py @@ -0,0 +1,76 @@ +"""Tests for the discrete-parameter deprecation (issue #285).""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any +import warnings + +from pydantic import BaseModel +import pytest + +from azure_functions_openapi.decorator import ( + clear_openapi_registry, + get_openapi_registry, + openapi, +) + + +@pytest.fixture(autouse=True) +def _clean_registry() -> Iterator[None]: + clear_openapi_registry() + yield + clear_openapi_registry() + + +class ReqModel(BaseModel): + name: str + + +class RespModel(BaseModel): + id: int + + +@pytest.mark.parametrize( + "kwargs", + [ + {"request_model": ReqModel}, + {"request_body": {"type": "object"}}, + {"response_model": RespModel}, + {"response": {200: {"description": "ok"}}}, + ], +) +def test_discrete_parameters_emit_deprecation_warning(kwargs: dict[str, Any]) -> None: + with pytest.warns(DeprecationWarning, match="unified"): + + @openapi(route="items", method="post", **kwargs) + def handler(req: Any) -> Any: + return req + + +def test_unified_parameters_do_not_warn() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + + @openapi(route="items", method="post", requests=ReqModel, responses=RespModel) + def handler(req: Any) -> Any: + return req + + +def test_discrete_and_unified_produce_identical_metadata() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + @openapi(route="a", method="post", request_model=ReqModel, response_model=RespModel) + def discrete(req: Any) -> Any: + return req + + @openapi(route="b", method="post", requests=ReqModel, responses=RespModel) + def unified(req: Any) -> Any: + return req + + reg = get_openapi_registry() + d = reg["discrete"] + u = reg["unified"] + assert d["request_model"] is u["request_model"] is ReqModel + assert d["response_model"] is u["response_model"] is RespModel diff --git a/tests/test_registry_identity.py b/tests/test_registry_identity.py new file mode 100644 index 0000000..d63b32f --- /dev/null +++ b/tests/test_registry_identity.py @@ -0,0 +1,196 @@ +"""Tests for registry identity/locking hardening (issues #279, #284).""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from functools import wraps +import threading +from typing import Any + +from pydantic import BaseModel +import pytest + +from azure_functions_openapi.bridge import scan_validation_metadata +from azure_functions_openapi.decorator import ( + clear_openapi_registry, + get_openapi_registry, + openapi, +) +from azure_functions_openapi.registry import ( + OpenAPIRegistry, + canonical_function_id, +) + + +@pytest.fixture(autouse=True) +def _clean_registry() -> Iterator[None]: + clear_openapi_registry() + yield + clear_openapi_registry() + + +# ── Mock Azure Functions SDK objects (mirrors tests/test_bridge.py) ────────── +@dataclass +class MockBinding: + route: str + methods: list[str] | None + type: str = "httpTrigger" + + +@dataclass +class MockFunction: + _name: str + _func: Any + _bindings: list[Any] + + +@dataclass +class MockBuilder: + _function: MockFunction + + +@dataclass +class MockApp: + _function_builders: list[MockBuilder] + + +def _make_app(name: str, route: str, handler: Any, method: str = "post") -> MockApp: + binding = MockBinding(route=route, methods=[method.upper()]) + fn = MockFunction(_name=name, _func=handler, _bindings=[binding]) + return MockApp(_function_builders=[MockBuilder(_function=fn)]) + + +def _set_validation(handler: Any, metadata: dict[str, Any]) -> None: + setattr(handler, "_azure_functions_metadata", {"validation": metadata}) + + +class Body(BaseModel): + name: str + + +# Two @openapi handlers that share the short name ``create_user`` but live in +# different scopes → different qualname → the classic cross-module collision. +def _make_handler_a() -> Any: + @openapi(summary="A", route="users", method="post") + def create_user(req: Any) -> Any: + return req + + return create_user + + +def _make_handler_b() -> Any: + @openapi(summary="B", route="accounts", method="post") + def create_user(req: Any) -> Any: + return req + + return create_user + + +# ── #284: registry mutators are self-locking ──────────────────────────────── +def test_registry_set_and_setdefault_without_external_lock() -> None: + reg = OpenAPIRegistry() + reg.set("k", {"function_name": "k"}) + assert reg.get("k") == {"function_name": "k"} + # setdefault returns existing without overwriting + assert reg.setdefault("k", {"function_name": "other"})["function_name"] == "k" + assert reg.setdefault("new", {"function_name": "new"})["function_name"] == "new" + + +def test_registry_concurrent_registration_is_safe() -> None: + reg = OpenAPIRegistry() + + def worker(i: int) -> None: + for j in range(50): + reg.set(f"{i}-{j}", {"function_name": f"{i}-{j}"}) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(reg.snapshot()) == 8 * 50 + + +# ── #279: canonical identity helper ───────────────────────────────────────── +def test_canonical_function_id_uses_module_and_qualname() -> None: + def handler(req: Any) -> Any: + return req + + cid = canonical_function_id(handler) + assert cid.endswith(".test_canonical_function_id_uses_module_and_qualname..handler") + assert handler.__module__ in cid + + +def test_canonical_function_id_unwraps_functools_wraps() -> None: + def inner(req: Any) -> Any: + return req + + @wraps(inner) + def outer(req: Any) -> Any: + return inner(req) + + assert canonical_function_id(outer) == canonical_function_id(inner) + + +# ── #279: bridge merges into the correct entry despite short-name collision ── +@pytest.mark.parametrize("register_a_first", [True, False]) +def test_bridge_merges_into_correct_handler_on_name_collision(register_a_first: bool) -> None: + if register_a_first: + handler_a = _make_handler_a() + handler_b = _make_handler_b() + else: + handler_b = _make_handler_b() + handler_a = _make_handler_a() + + # Attach validation metadata only to handler A and scan it. + _set_validation(handler_a, {"body": Body}) + app = _make_app(name="create_user", route="users", handler=handler_a) + scan_validation_metadata(app) + + reg = get_openapi_registry() + a_id = canonical_function_id(handler_a) + b_id = canonical_function_id(handler_b) + entry_a = next(e for e in reg.values() if e.get("_function_id") == a_id) + entry_b = next(e for e in reg.values() if e.get("_function_id") == b_id) + + # Metadata merged into A (its route), never into B. + assert entry_a.get("request_body") is not None + assert entry_a["summary"] == "A" + assert entry_b.get("request_body") is None + assert entry_b["summary"] == "B" + + +def test_bridge_double_scan_does_not_duplicate(caplog: pytest.LogCaptureFixture) -> None: + handler_a = _make_handler_a() + _set_validation(handler_a, {"body": Body}) + app = _make_app(name="create_user", route="users", handler=handler_a) + + scan_validation_metadata(app) + count_after_first = len(get_openapi_registry()) + scan_validation_metadata(app) + count_after_second = len(get_openapi_registry()) + + assert count_after_first == count_after_second + + +def test_bridge_refuses_ambiguous_short_name_fallback(caplog: pytest.LogCaptureFixture) -> None: + # Two @openapi entries share the short name; scan a third, unregistered + # handler whose canonical id matches neither and whose route matches no + # endpoint. The short-name fallback must refuse to merge and warn. + _make_handler_a() + _make_handler_b() + + def create_user(req: Any) -> Any: # not @openapi-registered + return req + + _set_validation(create_user, {"body": Body}) + app = _make_app(name="create_user", route="unrelated", handler=create_user) + + with caplog.at_level("WARNING"): + scan_validation_metadata(app) + + assert any("ambiguous" in rec.message.lower() for rec in caplog.records) + # A standalone endpoint was registered instead of a wrong merge. + assert "post::/api/unrelated" in get_openapi_registry() From 2f80ca956e5406655e7d449f83a8950e71b552f2 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 19 Jul 2026 17:52:58 +0900 Subject: [PATCH 2/7] fix: defer deprecation warning past mixed-style validation Addresses review on #286: - Emit the discrete-parameter DeprecationWarning only after the mixed-style ValueError checks, so callers who pass both unified and discrete params get the error without a spurious deprecation warning. - Format the deprecated parameter names as a comma-separated string instead of a Python list repr for readability. - Document the registry key shape (short-name + fully-qualified id on name collisions) in docs/troubleshooting.md so get_openapi_registry() consumers are not surprised on upgrade. --- docs/troubleshooting.md | 7 ++++ src/azure_functions_openapi/decorator.py | 44 +++++++++++++----------- tests/test_deprecation.py | 38 ++++++++++++++++++++ 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 54cca3a..dc19034 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -57,6 +57,13 @@ from azure_functions_openapi.decorator import get_openapi_registry print(get_openapi_registry().keys()) ``` +Registry keys are keyed by the decorated function's short name. When two +handlers share the same short name, the earlier (displaced) entry is preserved +under its fully-qualified id (`module.qualname`) in addition to the short-name +key, so no registration is silently lost. If you rely on these keys for +debugging, expect both a short-name key and a fully-qualified key when name +collisions occur. + ### Fixes - add `@openapi` to every operation you want documented diff --git a/src/azure_functions_openapi/decorator.py b/src/azure_functions_openapi/decorator.py index f7e9d01..ed710b3 100644 --- a/src/azure_functions_openapi/decorator.py +++ b/src/azure_functions_openapi/decorator.py @@ -264,26 +264,6 @@ def decorator(func: F) -> F: resolved_response_model = response_model resolved_response = response - _deprecated_used = [ - name - for name, value in ( - ("request_model", request_model), - ("request_body", request_body), - ("response_model", response_model), - ("response", response), - ) - if value is not None - ] - if _deprecated_used: - warnings.warn( - f"The discrete @openapi parameter(s) {_deprecated_used} are " - "deprecated in favor of the unified 'requests='/'responses=' " - "parameters and will be removed in a future release. " - "See https://github.com/yeongseon/azure-functions-openapi-python/issues/285.", - DeprecationWarning, - stacklevel=2, - ) - if requests is not None: if request_model is not None or request_body is not None: raise ValueError( @@ -312,6 +292,30 @@ def decorator(func: F) -> F: "'responses' must be either a Pydantic BaseModel subclass or a dictionary." ) + # Emit the deprecation warning only after mixed-style validation + # has passed, so callers hitting a ValueError above are not also + # warned about a config they were told is invalid. + _deprecated_used = [ + name + for name, value in ( + ("request_model", request_model), + ("request_body", request_body), + ("response_model", response_model), + ("response", response), + ) + if value is not None + ] + if _deprecated_used: + warnings.warn( + "The discrete @openapi parameter(s) " + f"{', '.join(_deprecated_used)} are " + "deprecated in favor of the unified 'requests='/'responses=' " + "parameters and will be removed in a future release. " + "See https://github.com/yeongseon/azure-functions-openapi-python/issues/285.", + DeprecationWarning, + stacklevel=2, + ) + # Validate request/response models _validate_models( resolved_request_model, diff --git a/tests/test_deprecation.py b/tests/test_deprecation.py index 8597dda..502bf59 100644 --- a/tests/test_deprecation.py +++ b/tests/test_deprecation.py @@ -74,3 +74,41 @@ def unified(req: Any) -> Any: u = reg["unified"] assert d["request_model"] is u["request_model"] is ReqModel assert d["response_model"] is u["response_model"] is RespModel + + +def test_no_deprecation_warning_when_mixed_style_raises() -> None: + # A caller mixing unified and discrete parameters gets a ValueError; the + # deprecation warning must not also fire in that case. + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(ValueError, match="Cannot provide both"): + + @openapi( + route="items", + method="post", + requests=ReqModel, + request_model=ReqModel, + ) + def handler(req: Any) -> Any: + return req + + +def test_deprecation_warning_lists_names_comma_separated() -> None: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + + @openapi( + route="items", + method="post", + request_model=ReqModel, + response_model=RespModel, + ) + def handler(req: Any) -> Any: + return req + + messages = [str(w.message) for w in caught if issubclass(w.category, DeprecationWarning)] + assert messages, "expected a DeprecationWarning" + message = messages[0] + assert "request_model, response_model" in message + # Must not be rendered as a Python list repr. + assert "['" not in message From 1187b419286de0b978e92b9e08f487f5c88b18dd Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Mon, 20 Jul 2026 09:51:12 +0900 Subject: [PATCH 3/7] refactor(bridge): type validation read-side contract and fix nested version gate --- .../_validation_contract.py | 46 ++++++++++++++ src/azure_functions_openapi/bridge.py | 37 +++++------ tests/test_bridge.py | 61 +++++++++++++------ tests/test_validation_contract.py | 27 ++++++++ 4 files changed, 136 insertions(+), 35 deletions(-) create mode 100644 src/azure_functions_openapi/_validation_contract.py create mode 100644 tests/test_validation_contract.py diff --git a/src/azure_functions_openapi/_validation_contract.py b/src/azure_functions_openapi/_validation_contract.py new file mode 100644 index 0000000..7bdc826 --- /dev/null +++ b/src/azure_functions_openapi/_validation_contract.py @@ -0,0 +1,46 @@ +"""Read-side mirror of the ``validation`` namespace metadata contract. + +The bridge consumes the cross-package ``_azure_functions_metadata`` convention +attribute (namespace ``"validation"``) written by ``azure-functions-validation``. +This module mirrors the *shape the bridge reads* as a ``TypedDict`` so the +consumed contract is explicit and type-checked, without importing the producing +package. + +This intentionally duplicates the producer's write-side TypedDict: the two +packages release independently, and a read-side mirror keeps the consumer's +expectations pinned even if the producer evolves. Model-type fields are ``Any`` +because they carry user-defined Pydantic classes that vary per handler. +""" + +from __future__ import annotations + +from typing import Any, TypedDict + +# Convention attribute name shared across every Azure Functions toolkit package. +HANDLER_METADATA_ATTR = "_azure_functions_metadata" + +# Namespace owned by ``azure-functions-validation``. +VALIDATION_NAMESPACE = "validation" + +# Payload ``version`` values this consumer understands. +SUPPORTED_VALIDATION_VERSIONS: frozenset[int] = frozenset({1}) + + +class _ValidationMetadataRequired(TypedDict): + """Keys present on every validation payload.""" + + version: int + + +class ValidationMetadata(_ValidationMetadataRequired, total=False): + """The ``validation`` namespace payload read from ``HANDLER_METADATA_ATTR``. + + ``body``/``query``/``path``/``headers``/``response_model`` carry user-defined + Pydantic model classes (or ``None``), hence ``Any``. + """ + + body: Any + query: Any + path: Any + headers: Any + response_model: Any diff --git a/src/azure_functions_openapi/bridge.py b/src/azure_functions_openapi/bridge.py index 88fac84..95940e4 100644 --- a/src/azure_functions_openapi/bridge.py +++ b/src/azure_functions_openapi/bridge.py @@ -7,6 +7,11 @@ from pydantic import BaseModel +from azure_functions_openapi._validation_contract import ( + HANDLER_METADATA_ATTR, + SUPPORTED_VALIDATION_VERSIONS, + VALIDATION_NAMESPACE, +) from azure_functions_openapi.decorator import register_openapi_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import canonical_function_id, registry @@ -204,17 +209,11 @@ def _discovered_operation( } -# Convention-based handler metadata attribute name. -# Packages in the Azure Functions Python DX Toolkit write per-namespace -# dicts into this attribute so consumers can discover metadata without -# importing the producing package. -_HANDLER_METADATA_ATTR = "_azure_functions_metadata" - # Maximum decorator depth to walk when chasing ``__wrapped__``. _MAX_WRAPPED_DEPTH = 16 -# Supported metadata protocol versions. -_SUPPORTED_VERSIONS: frozenset[int] = frozenset({1}) +# Backward-compatible alias for the convention attribute name. +_HANDLER_METADATA_ATTR = HANDLER_METADATA_ATTR def _read_validation_hints(handler: Any) -> dict[str, Any] | None: @@ -225,8 +224,8 @@ def _read_validation_hints(handler: Any) -> dict[str, Any] | None: ensures that metadata set by an inner decorator is still discovered even when additional decorators (e.g. ``@functools.wraps``) wrap the handler. - Version policy: - * Missing ``version`` key → accepted as v1 (backward-compatible). + Version policy (``version`` is nested inside the ``validation`` payload): + * Missing ``version`` key -> accepted as v1 (backward-compatible). * Present and supported → accepted. * Present but malformed or unsupported → ``logger.warning()`` + continue walking. @@ -235,17 +234,21 @@ def _read_validation_hints(handler: Any) -> dict[str, Any] | None: """ current: Any = handler for _ in range(_MAX_WRAPPED_DEPTH): - toolkit_meta = getattr(current, _HANDLER_METADATA_ATTR, None) + toolkit_meta = getattr(current, HANDLER_METADATA_ATTR, None) if isinstance(toolkit_meta, dict): - # --- version gate --- - raw_version = toolkit_meta.get("version") - if raw_version is not None: - if type(raw_version) is not int or raw_version not in _SUPPORTED_VERSIONS: + hints = toolkit_meta.get(VALIDATION_NAMESPACE) + if isinstance(hints, dict): + # --- version gate (version is nested in the namespace payload) --- + raw_version = hints.get("version") + if raw_version is not None and ( + type(raw_version) is not int + or raw_version not in SUPPORTED_VALIDATION_VERSIONS + ): logger.warning( "Skipping metadata on %r: unsupported version %r (supported: %s)", current, raw_version, - ", ".join(str(v) for v in sorted(_SUPPORTED_VERSIONS)), + ", ".join(str(v) for v in sorted(SUPPORTED_VALIDATION_VERSIONS)), ) # Continue walking; an inner handler may have valid metadata. wrapped = getattr(current, "__wrapped__", None) @@ -253,8 +256,6 @@ def _read_validation_hints(handler: Any) -> dict[str, Any] | None: break current = wrapped continue - hints = toolkit_meta.get("validation") - if isinstance(hints, dict): return copy.deepcopy(hints) # Walk the __wrapped__ chain. diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 573ce37..03872d1 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -372,14 +372,13 @@ def test_missing_version_accepted(self) -> None: assert result["body"] is CreateBody def test_version_1_accepted(self) -> None: - """Explicit version=1 is accepted.""" + """Explicit nested version=1 is accepted.""" handler = lambda req: req # noqa: E731 setattr( handler, _HANDLER_METADATA_ATTR, { - "version": 1, - "validation": {"body": CreateBody}, + "validation": {"version": 1, "body": CreateBody}, }, ) @@ -394,8 +393,7 @@ def test_unsupported_version_skipped(self) -> None: handler, _HANDLER_METADATA_ATTR, { - "version": 999, - "validation": {"body": CreateBody}, + "validation": {"version": 999, "body": CreateBody}, }, ) @@ -409,8 +407,7 @@ def test_malformed_version_string_skipped(self) -> None: handler, _HANDLER_METADATA_ATTR, { - "version": "1", - "validation": {"body": CreateBody}, + "validation": {"version": "1", "body": CreateBody}, }, ) @@ -424,8 +421,7 @@ def test_malformed_version_float_skipped(self) -> None: handler, _HANDLER_METADATA_ATTR, { - "version": 1.0, - "validation": {"body": CreateBody}, + "validation": {"version": 1.0, "body": CreateBody}, }, ) @@ -439,8 +435,7 @@ def test_unsupported_version_warning_logged(self, caplog: pytest.LogCaptureFixtu handler, _HANDLER_METADATA_ATTR, { - "version": 42, - "validation": {"body": CreateBody}, + "validation": {"version": 42, "body": CreateBody}, }, ) @@ -457,8 +452,7 @@ def test_outer_invalid_version_inner_valid_discovered(self) -> None: inner, _HANDLER_METADATA_ATTR, { - "version": 1, - "validation": {"body": CreateBody}, + "validation": {"version": 1, "body": CreateBody}, }, ) @@ -467,8 +461,7 @@ def test_outer_invalid_version_inner_valid_discovered(self) -> None: outer, _HANDLER_METADATA_ATTR, { - "version": 999, - "validation": {"body": ResponseModel}, + "validation": {"version": 999, "body": ResponseModel}, }, ) outer.__wrapped__ = inner @@ -485,8 +478,7 @@ def test_boolean_version_rejected(self) -> None: handler, _HANDLER_METADATA_ATTR, { - "version": True, - "validation": {"body": CreateBody}, + "validation": {"version": True, "body": CreateBody}, }, ) @@ -679,3 +671,38 @@ def test_scan_skips_builders_without_function_or_handler() -> None: scan_validation_metadata(app) assert get_openapi_registry() == {} + + + +class TestNestedVersionGate: + """Regression: the version gate reads the nested namespace payload, not the top level.""" + + def test_top_level_version_is_ignored(self) -> None: + """A stray top-level 'version' must not gate; the nested v1 payload is accepted.""" + handler = lambda req: req # noqa: E731 + setattr( + handler, + _HANDLER_METADATA_ATTR, + { + "version": 999, # top-level: producers never set this — must be ignored + "validation": {"version": 1, "body": CreateBody}, + }, + ) + + result = _read_validation_hints(handler) + assert result is not None + assert result["body"] is CreateBody + + def test_nested_unsupported_version_rejected_despite_valid_top_level(self) -> None: + """Only the nested version gates; a valid top-level 'version' cannot rescue it.""" + handler = lambda req: req # noqa: E731 + setattr( + handler, + _HANDLER_METADATA_ATTR, + { + "version": 1, # top-level: ignored + "validation": {"version": 999, "body": CreateBody}, + }, + ) + + assert _read_validation_hints(handler) is None \ No newline at end of file diff --git a/tests/test_validation_contract.py b/tests/test_validation_contract.py new file mode 100644 index 0000000..758fbfd --- /dev/null +++ b/tests/test_validation_contract.py @@ -0,0 +1,27 @@ +"""Tests for the read-side validation metadata contract.""" + +from __future__ import annotations + +from azure_functions_openapi._validation_contract import ( + HANDLER_METADATA_ATTR, + SUPPORTED_VALIDATION_VERSIONS, + VALIDATION_NAMESPACE, + ValidationMetadata, +) + + +def test_handler_metadata_attr() -> None: + assert HANDLER_METADATA_ATTR == "_azure_functions_metadata" + + +def test_validation_namespace() -> None: + assert VALIDATION_NAMESPACE == "validation" + + +def test_supported_versions() -> None: + assert SUPPORTED_VALIDATION_VERSIONS == frozenset({1}) + + +def test_validation_metadata_shape() -> None: + meta: ValidationMetadata = {"version": 1, "body": object()} + assert meta["version"] == 1 From ccf0eacf09ebf644236c27a1abc0d0c97dddd498 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 18:00:14 +0900 Subject: [PATCH 4/7] docs: lead README Quick Start with Pydantic models and drop redundant route/method Rewrite the Quick Start and Before/After examples to showcase the recommended request_model=/response_model= path instead of hand-written raw JSON Schema dicts. The @openapi decorator already infers route and method from the adjacent @app.route binding, so those arguments (and the optional @app.function_name noise) are removed from the headline example. The raw JSON Schema path is preserved in a collapsible Advanced section, with a note that Pydantic v2 is optional. Closes #289 --- README.md | 95 ++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7cf7f64..0e9291b 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,23 @@ Spec drifts. Consumers guess. No Swagger UI. **✅ With azure-functions-openapi** — spec lives next to the handler ```python +from pydantic import BaseModel + + +class CreateUserRequest(BaseModel): + name: str + + +class UserResponse(BaseModel): + id: str + name: str + + @openapi( summary="Create user", - request_body={"type": "object", "properties": {"name": {"type": "string"}}}, - response={200: {"description": "User created"}}, - ) + request_model=CreateUserRequest, + response_model=UserResponse, +) @app.route(route="users", methods=["POST"]) def create_user(req): ... @@ -185,6 +197,7 @@ The version pin in `pyproject.toml` is `azure-functions>=1.21.0,<2.0.0`. The flo import json import azure.functions as func +from pydantic import BaseModel from azure_functions_openapi import ( get_openapi_json, @@ -193,35 +206,26 @@ from azure_functions_openapi import ( render_swagger_ui, ) - app = func.FunctionApp() -@app.function_name(name="http_trigger") +# Describe your API with plain Pydantic models. +class GreetRequest(BaseModel): + name: str + + +class GreetResponse(BaseModel): + message: str + + +# @openapi infers the route and method from the @app.route below — +# no need to repeat them here. @openapi( summary="Greet user", - route="/api/http_trigger", - method="post", - request_body={ - "type": "object", - "properties": {"name": {"type": "string"}}, - "required": ["name"], - }, - response={ - 200: { - "description": "Successful greeting", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {"message": {"type": "string"}}, - } - } - }, - } - }, tags=["Example"], - ) + request_model=GreetRequest, + response_model=GreetResponse, +) @app.route(route="http_trigger", auth_level=func.AuthLevel.ANONYMOUS, methods=["POST"]) def http_trigger(req: func.HttpRequest) -> func.HttpResponse: data = req.get_json() @@ -232,7 +236,7 @@ def http_trigger(req: func.HttpRequest) -> func.HttpResponse: ) -@app.function_name(name="openapi_json") +# Wire up the generated spec + Swagger UI as ordinary HTTP routes. @app.route(route="openapi.json", auth_level=func.AuthLevel.ANONYMOUS, methods=["GET"]) def openapi_json(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse( @@ -244,7 +248,6 @@ def openapi_json(req: func.HttpRequest) -> func.HttpResponse: ) -@app.function_name(name="openapi_yaml") @app.route(route="openapi.yaml", auth_level=func.AuthLevel.ANONYMOUS, methods=["GET"]) def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse( @@ -256,12 +259,46 @@ def openapi_yaml(req: func.HttpRequest) -> func.HttpResponse: ) -@app.function_name(name="swagger_ui") @app.route(route="docs", auth_level=func.AuthLevel.ANONYMOUS, methods=["GET"]) def swagger_ui(req: func.HttpRequest) -> func.HttpResponse: return render_swagger_ui() ``` +> **Pydantic v2 is optional.** `request_model=` / `response_model=` are the recommended path, but you can pass raw JSON Schema dicts instead (see below) if you'd rather not add a dependency. + +
+Advanced: describe the schema with raw JSON Schema instead of Pydantic + +```python +@openapi( + summary="Greet user", + tags=["Example"], + request_body={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + response={ + 200: { + "description": "Successful greeting", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + } + } + }, + } + }, +) +@app.route(route="http_trigger", auth_level=func.AuthLevel.ANONYMOUS, methods=["POST"]) +def http_trigger(req: func.HttpRequest) -> func.HttpResponse: + ... +``` + +
+ Run locally with Azure Functions Core Tools: ```bash From 6ba927b560d0dd5dc47f8bd78f6face3dbde045f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 18:16:17 +0900 Subject: [PATCH 5/7] docs: clarify @openapi documents but does not validate, flag stale translations Address review feedback: - Note in the Quick Start handler that @openapi documents the contract but does not perform runtime validation (points to azure-functions-validation), resolving the confusion of models being defined but not used to parse input. - Add an 'outdated translation' banner to README.ko/ja/zh-CN pointing readers to the canonical English README until translations are synced. --- README.ja.md | 2 ++ README.ko.md | 2 ++ README.md | 4 +++- README.zh-CN.md | 2 ++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index b8b63c9..0fcc6c1 100644 --- a/README.ja.md +++ b/README.ja.md @@ -13,6 +13,8 @@ 他の言語: [English](README.md) | [한국어](README.ko.md) | [简体中文](README.zh-CN.md) +> ⚠️ この翻訳は古い可能性があります。最新の内容は [README.md](README.md) を参照してください。 + **Azure Functions Python v2 プログラミング モデル**向けの OpenAPI(Swagger)ドキュメント生成と Swagger UI を提供します。 ## Why Use It diff --git a/README.ko.md b/README.ko.md index 3c79820..f007022 100644 --- a/README.ko.md +++ b/README.ko.md @@ -13,6 +13,8 @@ 다른 언어: [English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-CN.md) +> ⚠️ 이 번역은 오래되었을 수 있습니다. 최신 내용은 [README.md](README.md)를 참고하세요. + **Azure Functions Python v2 프로그래밍 모델**을 위한 OpenAPI(Swagger) 문서화와 Swagger UI를 제공합니다. ## Why Use It diff --git a/README.md b/README.md index 0e9291b..d8a1d62 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,9 @@ class GreetResponse(BaseModel): ) @app.route(route="http_trigger", auth_level=func.AuthLevel.ANONYMOUS, methods=["POST"]) def http_trigger(req: func.HttpRequest) -> func.HttpResponse: - data = req.get_json() + # @openapi documents the request/response contract — it does not validate. + # For runtime validation, see azure-functions-validation. +data = req.get_json() name = data.get("name", "world") return func.HttpResponse( json.dumps({"message": f"Hello, {name}!"}), diff --git a/README.zh-CN.md b/README.zh-CN.md index e2d415e..2916e25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,6 +13,8 @@ 其他语言: [English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) +> ⚠️ 此翻译可能已过时。最新内容请参阅 [README.md](README.md)。 + 为 **Azure Functions Python v2 编程模型**提供 OpenAPI(Swagger)文档生成和 Swagger UI。 ## Why Use It From 4bae30940730bfa8f5f377401eed2cb97f3cd009 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Wed, 22 Jul 2026 18:48:45 +0900 Subject: [PATCH 6/7] docs: fold spec/Swagger UI plumbing into a collapsible section Keep the Quick Start headline focused on the @openapi decorator (the 'aha' moment) by moving the openapi.json / openapi.yaml / docs route registration into a collapsible
block. The wiring stays fully visible on click. Also fix a lost indentation on the handler's req.get_json() line. --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d8a1d62..50d86e1 100644 --- a/README.md +++ b/README.md @@ -230,15 +230,19 @@ class GreetResponse(BaseModel): def http_trigger(req: func.HttpRequest) -> func.HttpResponse: # @openapi documents the request/response contract — it does not validate. # For runtime validation, see azure-functions-validation. -data = req.get_json() + data = req.get_json() name = data.get("name", "world") return func.HttpResponse( json.dumps({"message": f"Hello, {name}!"}), mimetype="application/json", ) +``` +
+Wire up the spec + Swagger UI endpoints (openapi.json / openapi.yaml / docs) -# Wire up the generated spec + Swagger UI as ordinary HTTP routes. +```python +# Serve the generated spec and Swagger UI as ordinary HTTP routes. @app.route(route="openapi.json", auth_level=func.AuthLevel.ANONYMOUS, methods=["GET"]) def openapi_json(req: func.HttpRequest) -> func.HttpResponse: return func.HttpResponse( @@ -266,6 +270,8 @@ def swagger_ui(req: func.HttpRequest) -> func.HttpResponse: return render_swagger_ui() ``` +
+ > **Pydantic v2 is optional.** `request_model=` / `response_model=` are the recommended path, but you can pass raw JSON Schema dicts instead (see below) if you'd rather not add a dependency.
From fbcedf1a798b6ba671544a128c0012d561551a93 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Thu, 23 Jul 2026 10:46:50 +0900 Subject: [PATCH 7/7] docs: move 'Pydantic v2 is optional' note above the plumbing fold Answer the reader's immediate 'do I need Pydantic?' question right after the headline example, before the collapsed spec/Swagger UI wiring. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 50d86e1..7b6255d 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,8 @@ def http_trigger(req: func.HttpRequest) -> func.HttpResponse: ) ``` +> **Pydantic v2 is optional.** `request_model=` / `response_model=` are the recommended path, but you can pass raw JSON Schema dicts instead (see below) if you'd rather not add a dependency. +
Wire up the spec + Swagger UI endpoints (openapi.json / openapi.yaml / docs) @@ -272,8 +274,6 @@ def swagger_ui(req: func.HttpRequest) -> func.HttpResponse:
-> **Pydantic v2 is optional.** `request_model=` / `response_model=` are the recommended path, but you can pass raw JSON Schema dicts instead (see below) if you'd rather not add a dependency. -
Advanced: describe the schema with raw JSON Schema instead of Pydantic