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 7cf7f64..7b6255d 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,46 +206,45 @@ 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:
+ # @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}!"}),
mimetype="application/json",
)
+```
+> **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.
-@app.function_name(name="openapi_json")
+
+Wire up the spec + Swagger UI endpoints (openapi.json / openapi.yaml / docs)
+
+```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(
@@ -244,7 +256,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 +267,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()
```
+
+
+
+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
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
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/_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 409aefe..95940e4 100644
--- a/src/azure_functions_openapi/bridge.py
+++ b/src/azure_functions_openapi/bridge.py
@@ -7,9 +7,14 @@
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 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
@@ -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.
@@ -294,6 +295,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 +312,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..ed710b3 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
-------
@@ -292,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,
@@ -299,7 +323,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_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_deprecation.py b/tests/test_deprecation.py
new file mode 100644
index 0000000..502bf59
--- /dev/null
+++ b/tests/test_deprecation.py
@@ -0,0 +1,114 @@
+"""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
+
+
+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
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()
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