Skip to content
2 changes: 2 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 74 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
...
Expand Down Expand Up @@ -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,
Expand All @@ -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")
<details>
<summary>Wire up the spec + Swagger UI endpoints (openapi.json / openapi.yaml / docs)</summary>

```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(
Expand All @@ -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(
Expand All @@ -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()
```

</details>

<details>
<summary>Advanced: describe the schema with raw JSON Schema instead of Pydantic</summary>

```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:
...
```

</details>

Run locally with Azure Functions Core Tools:

```bash
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions src/azure_functions_openapi/_validation_contract.py
Original file line number Diff line number Diff line change
@@ -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
86 changes: 53 additions & 33 deletions src/azure_functions_openapi/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -235,26 +234,28 @@ 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)
if wrapped is None or wrapped is current:
break
current = wrapped
continue
hints = toolkit_meta.get("validation")
if isinstance(hints, dict):
return copy.deepcopy(hints)

# Walk the __wrapped__ chain.
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading
Loading