Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ URL after `history.replaceState`.

## Compatibility Policy

Route-prefix configuration detection is opt-in: existing Python parameters and
the CLI's effective default remain `/api`. `resolve_route_prefix` reads only the
explicitly located host file (cwd by default), with a present Azure app-setting
override taking precedence, and returns a normalized string for existing APIs.
The CLI's `auto` mode locates the file beside the imported app module. Non-auto
CLI generation diagnoses file-prefix mismatches through structured warnings that
gate `--fail-on-warnings`; Python APIs do not infer caller intent or warn about
mismatches. Invalid host configuration warns and falls back without execution.

- Minimum supported Python version: `3.11`
- Supported runtime target: Azure Functions Python v2 programming model
- Public APIs follow semantic versioning expectations
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ to the Python facade; that registry remains caller-owned and additive.
See the [CLI Guide](docs/cli.md) for all options, CI integration examples, and
the warning that `--app` imports execute module-level code.

Route-prefix detection is opt-in: use `--route-prefix auto` in the CLI, or pass
`resolve_route_prefix(Path(__file__).with_name("host.json"))` to `route_prefix=`
in Python (`Path` comes from `pathlib`; the helper is exported at the package
root). Existing defaults stay `/api`. See [Route Prefix](docs/route-prefix.md)
for app-setting precedence and the CLI's `route-prefix-mismatch` warning.

## Installation

```bash
Expand Down
24 changes: 24 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ from azure_functions_openapi import (
register_openapi_metadata,
register_openapi_routes,
render_swagger_ui,
resolve_route_prefix,
scan_endpoint_metadata,
scan_validation_metadata,
)
Expand All @@ -47,6 +48,7 @@ from azure_functions_openapi import (
| `get_openapi_json` | function | Build OpenAPI and serialize to JSON string |
| `get_openapi_yaml` | function | Build OpenAPI and serialize to YAML string |
| `render_swagger_ui` | function | Return Swagger UI `HttpResponse` |
| `resolve_route_prefix` | function | Opt into normalized app-setting / host.json prefix resolution |
| `OpenAPIOperationMetadata` | dataclass | Frozen dataclass for operation metadata |
| `OpenAPIRegistry` | class | Thread-safe registry backing the decorator; pass an instance for isolated (test-friendly) registration |
| `SpecReport` | dataclass | Result of `generate_openapi_report`: the `spec` dict plus a `warnings` tuple |
Expand All @@ -58,6 +60,21 @@ from azure_functions_openapi import (
| `OPENAPI_VERSION_3_1` | constant | OpenAPI version string `"3.1.0"` |
| `OPENAPI_VERSION_3_2` | constant | OpenAPI version string `"3.2.0"` |

## Route-prefix resolution

```python
def resolve_route_prefix(host_json: str | os.PathLike[str] | None = None) -> str: ...
```

Reads the present `AzureFunctionsJobHost__extensions__http__routePrefix`
environment variable first, then `extensions.http.routePrefix` from the supplied
file (default `./host.json`), then `/api`. Empty strings are honored and the
result is normalized for any `route_prefix=` parameter. Missing files/keys are
silent; unreadable files, malformed JSON, or a non-string prefix cause a
`RuntimeWarning` and fallback. No application code is imported or executed.
Existing Python defaults stay `/api`; this helper is explicitly opt-in.
See [Route Prefix](route-prefix.md) for a `function_app.py` example.

## Documentation route registration

```python
Expand Down Expand Up @@ -281,6 +298,9 @@ The sections below are generated directly from source docstrings.

::: azure_functions_openapi.render_swagger_ui

### `resolve_route_prefix`

::: azure_functions_openapi.host_config.resolve_route_prefix

## Bridge: Auto-discover validation metadata

Expand Down Expand Up @@ -364,6 +384,10 @@ spec = report.spec
| `SpecWarning` | Frozen dataclass: `code: WarningCode`, `message: str`, `function_name: str \| None`, plus optional `path`, `method`, and `location`; `to_dict()` includes every field for JSON |
| `WarningCode` | `str`-based enum of stable codes: `version-skew`, `ambiguous-namespace`, `duplicate-operation`, `spec-validation`, `discovery-skipped`, `empty-discovery`, `method-binding-mismatch` |

The CLI also emits `route-prefix-mismatch` for a file prefix differing from its
effective prefix; it gates `--fail-on-warnings`. Python generation does not
perform this mismatch check. `unresolved-route` remains advisory in the CLI.

When conversion of an explicit request or response model fails, strict generation
raises an error naming the HTTP method, normalized emitted path, function or
registry identity, and failure location (`request body` or `response <status>`).
Expand Down
17 changes: 14 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,10 @@ Discovery semantics:
| `--format` | `-f` | `json`, `yaml` | `json` | Output serialization format |
| `--openapi-version` | - | `3.0`, `3.1`, `3.2` | `3.1` | OpenAPI schema version. See [version selection guidance](faq.md#openapi-30-vs-31-vs-32-which-one-should-i-use). |
| `--pretty` | `-p` | flag | `false` | Pretty-print JSON output (adds indentation); no effect on YAML |
| `--route-prefix` | - | any string (or `""`) | `/api` | HTTP route prefix from `host.json` `extensions.http.routePrefix`. See [Route Prefix](route-prefix.md). |
| `--route-prefix` | - | `auto`, any literal prefix (or `""`) | `/api` | Opt into app-setting / `host.json` resolution with `auto`; the file is beside the imported app module, otherwise cwd. Write `/auto` for a literal prefix named auto. See [Route Prefix](route-prefix.md). |
| `--fail-on-empty-paths` | - | flag | `false` | Exit with code 1 if the generated spec has no paths |
| `--strict` | - | flag | `false` | Fail on any malformed registry entry instead of skipping it. Recommended for CI where a missing path should break the build |
| `--fail-on-warnings` | - | flag | `false` | Exit with code 2 if the generator emits any structured warnings (version skew, namespace fallback, or spec-validation issues; the advisory `unresolved-route` warning never fails). Use in CI to stop a wrong-but-plausible spec from being published |
| `--fail-on-warnings` | - | flag | `false` | Exit with code 2 if the generator emits any structured warnings (version skew, namespace fallback, spec-validation issues, or a `route-prefix-mismatch`; the advisory `unresolved-route` warning never fails). Use in CI to stop a wrong-but-plausible spec from being published |
| `--infer-auth-level` | - | flag | `false` | Infer operation security from scanned Azure Functions `auth_level` bindings. Requires `--app module:variable`; explicit operation security wins |
| `--isolate-app` | - | flag | `false` | Compatibility flag requiring `--app module:variable`; selecting a variable now always uses a fresh, app-scoped registry, even without this flag |

Expand All @@ -200,7 +200,18 @@ Discovery semantics:
| --- | --- |
| `0` | Success |
| `1` | Runtime or generation error (including `--fail-on-empty-paths` with no paths, or an `--isolate-app` that cannot be honored) |
| `2` | Either invalid CLI arguments (argparse parse error) **or** structured warnings were emitted while `--fail-on-warnings` is set |
| `2` | Either invalid CLI arguments (argparse parse error) **or** gating structured warnings (including `route-prefix-mismatch`) were emitted while `--fail-on-warnings` is set |

### Route-prefix warnings

`route-prefix-mismatch` is printed as a structured JSON line on stderr when a
file's normalized `extensions.http.routePrefix` differs from the effective
default or explicit CLI prefix. Fix it with `--route-prefix <value>` or opt into
`--route-prefix auto`. Equal values, `auto`, missing keys/files, and malformed or
unreadable files do not produce this warning. The comparison ignores environment
overrides; `auto` honors them. With `auto`, a malformed or unreadable file falls
back to `/api` with a `RuntimeWarning` that does not gate `--fail-on-warnings`.
See [Route Prefix](route-prefix.md) for precedence.

## Validate generated output

Expand Down
10 changes: 7 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class HelloResponse(BaseModel):
},
400: {"description": "Missing name"},
},
)
)
@app.route(route="http_trigger", methods=["GET"], auth_level=func.AuthLevel.ANONYMOUS)
def http_trigger(req: func.HttpRequest) -> func.HttpResponse:
name = req.params.get("name")
Expand Down Expand Up @@ -104,8 +104,12 @@ register_openapi_routes(app, title="Hello API", version="1.0.0")
```

Pass the same `route_prefix` configured in `host.json` when it differs from
the default `/api`. Use `enabled=False` to register nothing in environments
where runtime documentation must stay unavailable.
the default `/api`, or read it without duplicating it by opting into
`route_prefix=resolve_route_prefix(Path(__file__).with_name("host.json"))`.
Import `Path` from `pathlib` and `resolve_route_prefix` from the package root;
see [Resolving the prefix](route-prefix.md#resolving-the-prefix-from-hostjson-opt-in)
for precedence and the app-setting override. The default remains `/api`.
Use `enabled=False` to register nothing where runtime documentation must stay unavailable.

### Manual alternative

Expand Down
69 changes: 68 additions & 1 deletion docs/route-prefix.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,73 @@ To match this default, `azure-functions-openapi` uses `"/api"` as the default
- `scan_endpoint_metadata(app, route_prefix="/api")`
- The CLI flag `azure-functions-openapi generate --route-prefix /api`

## Resolving the prefix from host.json (opt-in)

Existing Python entry points and the CLI still default to `/api`; detection is
never enabled implicitly. Call `resolve_route_prefix()` and pass its result to
any existing `route_prefix=` parameter to avoid duplicating configuration.
Resolution uses the first available value:

1. The app-setting override environment variable
`AzureFunctionsJobHost__extensions__http__routePrefix`, when present.
2. `extensions.http.routePrefix` in the supplied `host.json` path (or
`./host.json` in the current working directory when no path is supplied).
3. The default `/api`.

An empty string at either configured level means no prefix: an empty
`routePrefix` in `host.json` removes the host's prefix, and the helper applies
the same rule to a present but empty app setting. Whether a given hosting
environment delivers an empty app setting to the host is outside the helper's
control, so confirm that before relying on it. Values are normalized: `api`
becomes `/api`, `/v1/` becomes `/v1`, and `""` stays empty. Missing files and
missing keys silently fall back. Unreadable files, invalid JSON, and non-string
`routePrefix` values emit a `RuntimeWarning` naming the file and reason, then
fall back to `/api`.
The file is parsed as strict JSON with an optional UTF-8 byte order mark, so a
`host.json` that contains comments or trailing commas is reported as unreadable.
The helper only reads configuration; it never imports or executes application code.

In `function_app.py`, explicitly locate the file beside your module:

```python
from pathlib import Path

from azure_functions_openapi import register_openapi_routes, resolve_route_prefix

route_prefix = resolve_route_prefix(Path(__file__).with_name("host.json"))
register_openapi_routes(app, title="My API", version="1.0.0", route_prefix=route_prefix)
```

This keeps generated paths and Swagger UI's specification URL aligned. Reuse
the resolved value for standalone generation or scanning calls as needed.
Python API calls do not emit mismatch warnings.

For CLI generation, opt in with `auto`:

```bash
azure-functions-openapi generate --app function_app:app --route-prefix auto
```

The CLI reads `host.json` beside the imported `--app` module, falling back to
the current working directory if there is no app or the module has no `__file__`.
The app-setting override above still wins in `auto` mode. If the file is
unreadable or malformed, `auto` falls back to `/api` and prints the same
`RuntimeWarning` on stderr; that warning does not gate `--fail-on-warnings`. A
**literal** prefix named `auto` must be written `--route-prefix /auto`.

Without `auto`, a configured file prefix that differs from the effective
prefix produces a structured `route-prefix-mismatch` warning as a JSON line on
stderr. This applies both to the unchanged `/api` default and an explicit
prefix. The message names both values and suggests `--route-prefix <value>`
or `--route-prefix auto`. It **gates `--fail-on-warnings` (exit 2)** before
writing output, unlike the advisory `unresolved-route` warning. This comparison
checks the file only, not the environment; missing, malformed, or unreadable
configuration does not produce a mismatch warning. Equal prefixes and `auto`
never produce it. An explicit value that differs from the file is still honored
for the generated paths, but it also produces the warning, so a job that
intentionally documents a gateway-facing prefix should not use
`--fail-on-warnings`.

## Authoring routes with `@openapi`

You can author routes in `@openapi(route=...)` either with or without the
Expand Down Expand Up @@ -63,7 +130,7 @@ the prefix:

```python
@openapi(summary="List users")
@app.route(route="api/users", methods=["GET"]) # → /api/api/users, not /api/users
@app.route(route="api/users", methods=["GET"]) # → /api/api/users, not /api/users
def list_users(req): ...
```

Expand Down
23 changes: 23 additions & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,29 @@ from azure_functions_openapi import scan_endpoint_metadata
# Call after all routes are registered
scan_endpoint_metadata(app)
```

### resolve_route_prefix

```python
def resolve_route_prefix(host_json: str | os.PathLike[str] | None = None) -> str: ...
```

Opt-in prefix resolution: present environment variable
`AzureFunctionsJobHost__extensions__http__routePrefix`, then the supplied
`host.json` (default `./host.json`), then `/api`. Returns a normalized prefix;
empty strings mean no prefix. Missing files/keys silently fall back; unreadable
files, malformed JSON and non-string prefixes emit `RuntimeWarning` then fall
back. Never imports or executes application code. Existing API defaults remain
`/api`. In function_app.py use
`resolve_route_prefix(Path(__file__).with_name("host.json"))` for `route_prefix=`.
The CLI's `--route-prefix auto` reads beside the imported app module (otherwise
cwd), honoring the environment override; an unreadable file falls back to `/api`
with the same RuntimeWarning, which does not gate `--fail-on-warnings`. A literal
auto prefix is `/auto`.
Without auto, a differing file prefix produces a structured stderr JSON-line
`route-prefix-mismatch` warning that gates `--fail-on-warnings` (exit 2).
Python APIs do not perform mismatch checks; `unresolved-route` stays advisory.

### generate_openapi_spec

```python
Expand Down
1 change: 1 addition & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Renders Swagger UI with enterprise security headers (CSP, HSTS).
- `@openapi(summary, description, ...)` — Decorator for HTTP handlers
- `register_openapi_metadata(path, method, ...)` — Programmatic metadata registration
- `register_openapi_routes(app, *, title, version, ...)` — Explicitly register JSON, YAML, and Swagger UI routes
- `resolve_route_prefix(host_json=None) -> str` — Opt-in: present app-setting override, then host.json (default ./host.json), then /api; normalized, including empty strings. Pass to route_prefix=. CLI: --route-prefix auto; /auto is literal. route-prefix-mismatch gates --fail-on-warnings.
- `clear_openapi_registry()` — Clear registry for fresh start
- Package-root `generate_openapi_spec(..., app=app)` — scan a completed FunctionApp, then generate a spec dict
- Package-root `get_openapi_json(..., app=app)` / `get_openapi_yaml(..., app=app)` — scan then serialize; pass `app=` when `@openapi` is below `@app.route`
Expand Down
2 changes: 2 additions & 0 deletions src/azure_functions_openapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from azure_functions_openapi.docs_routes import DocsRoutes, register_openapi_routes
from azure_functions_openapi.exceptions import OpenAPISpecConfigError, SDKIncompatibleError
from azure_functions_openapi.host_config import resolve_route_prefix
from azure_functions_openapi.registry import OpenAPIRegistry
from azure_functions_openapi.registry import registry as _default_registry
from azure_functions_openapi.spec import (
Expand Down Expand Up @@ -255,6 +256,7 @@ def generate_openapi_report(
"register_openapi_metadata",
"register_openapi_routes",
"render_swagger_ui",
"resolve_route_prefix",
"scan_endpoint_metadata",
"scan_validation_metadata",
]
41 changes: 41 additions & 0 deletions src/azure_functions_openapi/_cli_route_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from __future__ import annotations

import argparse
from pathlib import Path
import shlex
import sys

from azure_functions_openapi._warnings import SpecWarning, WarningCode
from azure_functions_openapi.host_config import _read_route_prefix, resolve_route_prefix
from azure_functions_openapi.routes import DEFAULT_ROUTE_PREFIX, normalize_route_prefix


def _resolve_cli_route_prefix(args: argparse.Namespace) -> tuple[str, tuple[SpecWarning, ...]]:
path = Path("host.json")
app_target = getattr(args, "app", None)
if isinstance(app_target, str):
module = sys.modules.get(app_target.partition(":")[0].strip())
module_file = getattr(module, "__file__", None)
if isinstance(module_file, str):
path = Path(module_file).parent / "host.json"

requested = getattr(args, "route_prefix", None)
if not isinstance(requested, str):
requested = DEFAULT_ROUTE_PREFIX
if requested == "auto":
return resolve_route_prefix(path), ()

prefix = normalize_route_prefix(requested)
configured, _ = _read_route_prefix(path)
if configured is None or configured == prefix:
return prefix, ()
return prefix, (
SpecWarning(
code=WarningCode.ROUTE_PREFIX_MISMATCH,
message=(
f"Effective route prefix {prefix!r} differs from {path} routePrefix "
f"{configured!r}. Use --route-prefix {shlex.quote(configured)} "
"or --route-prefix auto to match the host."
),
),
)
1 change: 1 addition & 0 deletions src/azure_functions_openapi/_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class WarningCode(StrEnum):
SCHEMA_SUBSTITUTION = "schema-substitution"
OPERATION_SKIPPED = "operation-skipped"
UNRESOLVED_ROUTE = "unresolved-route"
ROUTE_PREFIX_MISMATCH = "route-prefix-mismatch"

def __str__(self) -> str: # pragma: no cover - trivial
return self.value
Expand Down
Loading
Loading