From 32d069a83b57a9e8ab0fd447bb6ef193837cd11b Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:29 +0900 Subject: [PATCH 01/10] feat(routes): add resolve_route_prefix to read the host route prefix Add an opt-in helper that returns the effective route prefix without duplicating it in code. The AzureFunctionsJobHost__extensions__http__routePrefix app setting wins, then extensions.http.routePrefix from the given host.json (default ./host.json), then /api. The result is normalized for every existing route_prefix parameter, an empty prefix is honored at each level, and an unreadable or malformed file warns with RuntimeWarning and falls back. No existing default or signature changes. Refs #859 Co-authored-by: sisyphus-dev-ai --- src/azure_functions_openapi/__init__.py | 2 + src/azure_functions_openapi/host_config.py | 41 ++++++++ tests/test_docs_routes_host_config.py | 27 +++++ tests/test_host_config.py | 113 +++++++++++++++++++++ tests/test_public_api.py | 2 + 5 files changed, 185 insertions(+) create mode 100644 src/azure_functions_openapi/host_config.py create mode 100644 tests/test_docs_routes_host_config.py create mode 100644 tests/test_host_config.py diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index e7470718..1122b9a7 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -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 ( @@ -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", ] diff --git a/src/azure_functions_openapi/host_config.py b/src/azure_functions_openapi/host_config.py new file mode 100644 index 00000000..2f212c20 --- /dev/null +++ b/src/azure_functions_openapi/host_config.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import warnings + +from azure_functions_openapi.routes import DEFAULT_ROUTE_PREFIX, normalize_route_prefix + + +def _read_route_prefix(path: Path) -> tuple[str | None, str | None]: + try: + document = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + return None, None + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + return None, str(error) + + match document: + case {"extensions": {"http": {"routePrefix": str(prefix)}}}: + return normalize_route_prefix(prefix), None + case {"extensions": {"http": {"routePrefix": _}}}: + return None, "extensions.http.routePrefix must be a string" + case _: + return None, None + + +def resolve_route_prefix(host_json: str | os.PathLike[str] | None = None) -> str: + """Resolve a normalized prefix from the app setting, host.json, or the /api default.""" + setting = "AzureFunctionsJobHost__extensions__http__routePrefix" + if setting in os.environ: + return normalize_route_prefix(os.environ[setting]) + path = Path(host_json) if host_json is not None else Path("host.json") + prefix, reason = _read_route_prefix(path) + if reason is not None: + warnings.warn( + f"Could not read route prefix from {path}: {reason}; using {DEFAULT_ROUTE_PREFIX!r}.", + RuntimeWarning, + stacklevel=2, + ) + return normalize_route_prefix(DEFAULT_ROUTE_PREFIX) if prefix is None else prefix diff --git a/tests/test_docs_routes_host_config.py b/tests/test_docs_routes_host_config.py new file mode 100644 index 00000000..4e7fc383 --- /dev/null +++ b/tests/test_docs_routes_host_config.py @@ -0,0 +1,27 @@ +import json +from pathlib import Path + +import azure.functions as func +import pytest + +from azure_functions_openapi import register_openapi_routes, resolve_route_prefix + + +@pytest.mark.parametrize("case", [("v1", "/v1/openapi.json"), ("", "/openapi.json")]) +def test_swagger_uses_resolved_host_prefix( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: tuple[str, str] +) -> None: + # Given + prefix, expected_url = case + monkeypatch.delenv("AzureFunctionsJobHost__extensions__http__routePrefix", raising=False) + path = tmp_path / "host.json" + path.write_text(json.dumps({"extensions": {"http": {"routePrefix": prefix}}}), encoding="utf-8") + routes = register_openapi_routes( + func.FunctionApp(), title="API", version="1.0.0", route_prefix=resolve_route_prefix(path) + ) + assert routes.docs is not None + request = func.HttpRequest(method="GET", url="/docs", body=b"") + # When + response = routes.docs(request) + # Then + assert f'url: "{expected_url}"' in response.get_body().decode() diff --git a/tests/test_host_config.py b/tests/test_host_config.py new file mode 100644 index 00000000..3eb2bf39 --- /dev/null +++ b/tests/test_host_config.py @@ -0,0 +1,113 @@ +import json +from pathlib import Path +import warnings + +import pytest + +from azure_functions_openapi import resolve_route_prefix + +SETTING = "AzureFunctionsJobHost__extensions__http__routePrefix" + + +@pytest.fixture(autouse=True) +def clear_setting(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(SETTING, raising=False) + + +@pytest.mark.parametrize("prefix", ["gateway", ""]) +def test_environment_beats_host_json( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, prefix: str +) -> None: + # Given + path = tmp_path / "host.json" + path.write_text('{"extensions":{"http":{"routePrefix":"v1"}}}', encoding="utf-8") + monkeypatch.setenv(SETTING, prefix) + # When + result = resolve_route_prefix(path) + # Then + assert result == ("/gateway" if prefix else "") + + +@pytest.mark.parametrize("case", [("", ""), ("api", "/api"), ("/v1/", "/v1"), ("v1", "/v1")]) +def test_host_json_prefix_is_normalized(tmp_path: Path, case: tuple[str, str]) -> None: + # Given + prefix, expected = case + path = tmp_path / "host.json" + path.write_text(json.dumps({"extensions": {"http": {"routePrefix": prefix}}}), encoding="utf-8") + # When + result = resolve_route_prefix(str(path)) + # Then + assert result == expected + + +@pytest.mark.parametrize("content", [None, "{}", '{"extensions":{"http":{}}}', "[]"]) +def test_absent_configuration_silently_uses_default(tmp_path: Path, content: str | None) -> None: + # Given + path = tmp_path / "host.json" + if content is not None: + path.write_text(content, encoding="utf-8") + # When + with warnings.catch_warnings(record=True) as captured: + result = resolve_route_prefix(path) + # Then + assert result == "/api" + assert captured == [] + + +@pytest.mark.parametrize( + "case", + [ + ("{", "Could not read route prefix"), + ('{"extensions":{"http":{"routePrefix":null}}}', "must be a string"), + ('{"extensions":{"http":{"routePrefix":42}}}', "must be a string"), + ('{"extensions":{"http":{"routePrefix":false}}}', "must be a string"), + ], +) +def test_invalid_configuration_warns_and_falls_back(tmp_path: Path, case: tuple[str, str]) -> None: + # Given + content, reason = case + path = tmp_path / "host.json" + path.write_text(content, encoding="utf-8") + # When + with pytest.warns(RuntimeWarning, match=reason) as captured: + result = resolve_route_prefix(path) + # Then + assert result == "/api" + assert str(path) in str(captured[0].message) + assert captured[0].filename == __file__ + + +def test_unreadable_directory_warns_and_falls_back(tmp_path: Path) -> None: + # Given + path = tmp_path + # When + with pytest.warns(RuntimeWarning, match="Could not read route prefix") as captured: + result = resolve_route_prefix(path) + # Then + assert result == "/api" + assert str(path) in str(captured[0].message) + + +def test_invalid_encoding_warns_and_falls_back(tmp_path: Path) -> None: + # Given + path = tmp_path / "host.json" + path.write_bytes(b"\xff") + # When + with pytest.warns(RuntimeWarning, match="Could not read route prefix"): + result = resolve_route_prefix(path) + # Then + assert result == "/api" + + +def test_default_location_is_current_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Given + monkeypatch.chdir(tmp_path) + (tmp_path / "host.json").write_text( + '{"extensions":{"http":{"routePrefix":"local"}}}', encoding="utf-8" + ) + # When + result = resolve_route_prefix() + # Then + assert result == "/local" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 5a10dcd5..8603c2f6 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -29,6 +29,7 @@ def test_all_exports(self) -> None: "register_openapi_metadata", "register_openapi_routes", "render_swagger_ui", + "resolve_route_prefix", "scan_endpoint_metadata", "scan_validation_metadata", "scan_validation_metadata", @@ -63,6 +64,7 @@ def test_public_names_are_importable(self) -> None: register_openapi_metadata, register_openapi_routes, render_swagger_ui, + resolve_route_prefix, scan_endpoint_metadata, # noqa: F401 scan_validation_metadata, ) From 7a46efdbe400162d222bc2ab0368b028e1492594 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:29 +0900 Subject: [PATCH 02/10] feat(cli): accept --route-prefix auto and warn on a host.json prefix mismatch Let --route-prefix auto resolve the prefix from the host.json beside the imported --app module (current directory otherwise), honoring the app-setting override. Without auto, a host.json prefix that differs from the effective one now produces a structured route-prefix-mismatch warning that gates --fail-on-warnings. The check reads the file only, and missing, malformed or unreadable files never produce it. The default stays /api. Refs #859 Co-authored-by: sisyphus-dev-ai --- .../_cli_route_prefix.py | 41 ++++ src/azure_functions_openapi/_warnings.py | 1 + src/azure_functions_openapi/cli.py | 29 ++- tests/test_cli_host_config.py | 184 ++++++++++++++++++ tests/test_spec_warnings.py | 8 + 5 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 src/azure_functions_openapi/_cli_route_prefix.py create mode 100644 tests/test_cli_host_config.py diff --git a/src/azure_functions_openapi/_cli_route_prefix.py b/src/azure_functions_openapi/_cli_route_prefix.py new file mode 100644 index 00000000..81650319 --- /dev/null +++ b/src/azure_functions_openapi/_cli_route_prefix.py @@ -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." + ), + ), + ) diff --git a/src/azure_functions_openapi/_warnings.py b/src/azure_functions_openapi/_warnings.py index f36cb8df..1131e19b 100644 --- a/src/azure_functions_openapi/_warnings.py +++ b/src/azure_functions_openapi/_warnings.py @@ -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 diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index 032e40b5..060ed35a 100644 --- a/src/azure_functions_openapi/cli.py +++ b/src/azure_functions_openapi/cli.py @@ -8,6 +8,7 @@ from azure_functions_openapi import _prepare_generation from azure_functions_openapi._atomic import write_text_atomic +from azure_functions_openapi._cli_route_prefix import _resolve_cli_route_prefix from azure_functions_openapi._generation_result import GenerationResult from azure_functions_openapi._warnings import WarningCode from azure_functions_openapi.bridge import scan_endpoint_metadata @@ -155,11 +156,11 @@ def main() -> int: ) generate_parser.add_argument( "--route-prefix", - default="/api", + default=None, help=( - "HTTP route prefix from host.json extensions.http.routePrefix " - "(default: /api). Pass an empty string for hosts that disable " - "the prefix, or a custom value such as /v1 for a custom deployment." + "HTTP route prefix (default: /api). Use auto to resolve the app-setting " + "override or host.json beside the imported --app module (otherwise cwd). " + "Pass an empty string for no prefix, or /auto for a literal auto prefix." ), ) generate_parser.add_argument( @@ -177,7 +178,8 @@ def main() -> int: default=False, help=( "Exit with code 2 if the generator emits any structured warnings " - "(version skew, namespace fallback, or spec-validation issues). " + "(version skew, namespace fallback, spec-validation issues, or a route-prefix " + "mismatch; unresolved-route is advisory). " "Use in CI to stop a wrong-but-plausible spec from being published." ), ) @@ -266,13 +268,14 @@ def handle_generate(args: argparse.Namespace) -> int: ) return 1 + route_prefix, prefix_warnings = _resolve_cli_route_prefix(args) if variable_given and resolved_app is not None: active_registry = OpenAPIRegistry() # One-shot discovery through the #325 adapter. `build()` is # idempotent; we never call the non-idempotent `get_functions()`. scan_endpoint_metadata( resolved_app, - route_prefix=getattr(args, "route_prefix", "/api"), + route_prefix=route_prefix, registry=active_registry, ) else: @@ -296,6 +299,8 @@ def handle_generate(args: argparse.Namespace) -> int: "function_app:app) to also discover endpoint-metadata routes.", file=sys.stderr, ) + else: + route_prefix, prefix_warnings = _resolve_cli_route_prefix(args) if args.openapi_version == "3.2": openapi_version = OPENAPI_VERSION_3_2 @@ -310,7 +315,7 @@ def handle_generate(args: argparse.Namespace) -> int: unresolved = _prepare_generation( None, - getattr(args, "route_prefix", "/api"), + route_prefix, active_registry, ) result = generate_openapi_spec( @@ -318,7 +323,7 @@ def handle_generate(args: argparse.Namespace) -> int: args.version, openapi_version, description=description, - route_prefix=getattr(args, "route_prefix", "/api"), + route_prefix=route_prefix, strict=getattr(args, "strict", False), registry=active_registry, infer_auth_level=getattr(args, "infer_auth_level", False) is True, @@ -326,11 +331,15 @@ def handle_generate(args: argparse.Namespace) -> int: if isinstance(result, GenerationResult): report = _report_generation_result(result) spec = report.spec - warnings = (*report.warnings, *unresolved) + warnings = (*report.warnings, *unresolved, *prefix_warnings) else: # Preserve the plain-document seam used by embedded/mocked callers. spec = result - warnings = (*collect_spec_warnings(spec, registry=active_registry), *unresolved) + warnings = ( + *collect_spec_warnings(spec, registry=active_registry), + *unresolved, + *prefix_warnings, + ) # Surface structured warnings (version skew / namespace fallback / # spec-validation) as JSON lines on stderr so CI can parse them, and # gate the exit code on them when --fail-on-warnings is set. diff --git a/tests/test_cli_host_config.py b/tests/test_cli_host_config.py new file mode 100644 index 00000000..db085205 --- /dev/null +++ b/tests/test_cli_host_config.py @@ -0,0 +1,184 @@ +from collections.abc import Callable, Iterator +import json +from pathlib import Path +import sys +from types import ModuleType + +import pytest + +from azure_functions_openapi import clear_openapi_registry, register_openapi_metadata +from azure_functions_openapi.cli import main + +RunCLI = Callable[[list[str], str | None], tuple[int, str, str]] +SETTING = "AzureFunctionsJobHost__extensions__http__routePrefix" + + +@pytest.fixture +def run_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> Iterator[RunCLI]: + monkeypatch.delenv(SETTING, raising=False) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "path", list(sys.path)) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "prefix_app", raising=False) + clear_openapi_registry() + (tmp_path / "prefix_app.py").write_text( + "import azure.functions as func\n" + "from azure_functions_openapi import openapi\n" + "app = func.FunctionApp()\n" + "@app.route(route='widgets', methods=['GET'])\n" + "@openapi(summary='Widgets')\n" + "def list_widgets(req: func.HttpRequest) -> func.HttpResponse:\n" + " return func.HttpResponse('[]')\n", + encoding="utf-8", + ) + + def run(options: list[str], host: str | None) -> tuple[int, str, str]: + if host is not None: + (tmp_path / "host.json").write_text(host, encoding="utf-8") + monkeypatch.setattr( + sys, + "argv", + ["azure-functions-openapi", "generate", "--app", "prefix_app:app", *options], + ) + code = main() + captured = capsys.readouterr() + return code, captured.out, captured.err + + try: + yield run + finally: + sys.modules.pop("prefix_app", None) + clear_openapi_registry() + + +@pytest.mark.parametrize("prefix", ["v1", ""]) +def test_auto_uses_host_prefix(run_cli: RunCLI, prefix: str) -> None: + # Given + host = json.dumps({"extensions": {"http": {"routePrefix": prefix}}}) + # When + code, output, error = run_cli(["--route-prefix", "auto", "--fail-on-warnings"], host) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {f"/{prefix}/widgets" if prefix else "/widgets"} + assert error == "" + + +@pytest.mark.parametrize("options", [[], ["--route-prefix", "/api"]]) +def test_mismatch_is_structured_warning(run_cli: RunCLI, options: list[str]) -> None: + # Given + host = '{"extensions":{"http":{"routePrefix":"v1"}}}' + # When + code, output, error = run_cli(options, host) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {"/api/widgets"} + warning = json.loads(error) + assert warning["code"] == "route-prefix-mismatch" + assert all( + value in warning["message"] for value in ["/api", "/v1", "--route-prefix /v1", "auto"] + ) + + +def test_mismatch_gates_output(run_cli: RunCLI) -> None: + # Given + host = '{"extensions":{"http":{"routePrefix":"v1"}}}' + # When + code, output, error = run_cli(["--fail-on-warnings", "--output", "blocked.json"], host) + # Then + assert code == 2 + assert output == "" + assert not Path("blocked.json").exists() + assert json.loads(error)["code"] == "route-prefix-mismatch" + + +@pytest.mark.parametrize( + "case", + [ + (["--route-prefix", "/v1/"], '{"extensions":{"http":{"routePrefix":"v1"}}}', "/v1"), + ([], None, "/api"), + ([], "{}", "/api"), + ([], "{", "/api"), + ([], '{"extensions":{"http":{"routePrefix":17}}}', "/api"), + (["--route-prefix", ""], '{"extensions":{"http":{"routePrefix":""}}}', ""), + (["--route-prefix", "/auto"], '{"extensions":{"http":{"routePrefix":"auto"}}}', "/auto"), + ], +) +def test_matching_or_absent_prefix_does_not_warn( + run_cli: RunCLI, case: tuple[list[str], str | None, str] +) -> None: + # Given + options, host, prefix = case + # When + code, output, error = run_cli([*options, "--fail-on-warnings"], host) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {f"{prefix}/widgets"} + assert error == "" + + +def test_mismatch_ignores_environment(run_cli: RunCLI, monkeypatch: pytest.MonkeyPatch) -> None: + # Given + monkeypatch.setenv(SETTING, "api") + # When + code, _, error = run_cli([], '{"extensions":{"http":{"routePrefix":"v1"}}}') + # Then + assert code == 0 + assert json.loads(error)["code"] == "route-prefix-mismatch" + + +def test_auto_honors_environment(run_cli: RunCLI, monkeypatch: pytest.MonkeyPatch) -> None: + # Given + monkeypatch.setenv(SETTING, "override") + # When + code, output, error = run_cli( + ["--route-prefix", "auto"], '{"extensions":{"http":{"routePrefix":"v1"}}}' + ) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {"/override/widgets"} + assert error == "" + + +def test_auto_looks_beside_module_not_cwd(run_cli: RunCLI, monkeypatch: pytest.MonkeyPatch) -> None: + # Given + caller = Path("caller") + caller.mkdir() + (Path.cwd() / "host.json").write_text( + '{"extensions":{"http":{"routePrefix":"v1"}}}', encoding="utf-8" + ) + monkeypatch.chdir(caller) + Path("host.json").write_text( + '{"extensions":{"http":{"routePrefix":"wrong"}}}', encoding="utf-8" + ) + # When + code, output, error = run_cli(["--route-prefix", "auto"], None) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {"/v1/widgets"} + assert error == "" + + +@pytest.mark.parametrize("app", [None, "fileless_prefix_app"]) +def test_auto_falls_back_to_cwd( + run_cli: RunCLI, monkeypatch: pytest.MonkeyPatch, app: str | None +) -> None: + # Given + Path("host.json").write_text( + '{"extensions":{"http":{"routePrefix":"local"}}}', encoding="utf-8" + ) + register_openapi_metadata(path="widgets", method="get") + if app is not None: + monkeypatch.setitem(sys.modules, app, ModuleType(app)) + options = [] if app is None else ["--app", app] + monkeypatch.setattr( + sys, + "argv", + ["cli", "generate", "--route-prefix", "auto", "--output", "result.json", *options], + ) + # When + code = main() + # Then + assert code == 0 + assert set(json.loads(Path("result.json").read_text())["paths"]) == {"/local/widgets"} diff --git a/tests/test_spec_warnings.py b/tests/test_spec_warnings.py index 83ad19c8..cd04bce6 100644 --- a/tests/test_spec_warnings.py +++ b/tests/test_spec_warnings.py @@ -128,6 +128,14 @@ def _isolate_registry() -> Any: class TestSpecWarning: + def test_route_prefix_mismatch_code_is_stable(self) -> None: + # Given + warning = SpecWarning(code=WarningCode.ROUTE_PREFIX_MISMATCH, message="mismatch") + # When + payload = warning.to_dict() + # Then + assert payload["code"] == "route-prefix-mismatch" + def test_warning_code_value_is_stable(self) -> None: assert WarningCode.VERSION_SKEW.value == "version-skew" From c6e1b5e58245b93368030ecdf3b7040892310de1 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:30 +0900 Subject: [PATCH 03/10] docs(routes): document resolving the route prefix from host.json Add the resolution section to the route prefix guide with the precedence, the function_app.py usage and the app-setting override, point the getting-started guide at it, and add the helper to the API reference. Refs #859 Co-authored-by: sisyphus-dev-ai --- docs/api.md | 24 ++++++++++++++++ docs/getting-started.md | 10 +++++-- docs/route-prefix.md | 61 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3c8645fb..219ba20b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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, ) @@ -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 | @@ -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 @@ -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 @@ -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 `). diff --git a/docs/getting-started.md b/docs/getting-started.md index 2decc1e1..81decc8c 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -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") @@ -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 diff --git a/docs/route-prefix.md b/docs/route-prefix.md index f7d19992..984f01d0 100644 --- a/docs/route-prefix.md +++ b/docs/route-prefix.md @@ -28,6 +28,65 @@ 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, including an empty +environment variable. 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 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. 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 ` +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 @@ -63,7 +122,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): ... ``` From 156bb875cff5f8f40463a0d574dcd1681d76237e Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:30 +0900 Subject: [PATCH 04/10] docs(cli): document --route-prefix auto and the mismatch warning Describe the auto value, the literal /auto prefix, the route-prefix-mismatch warning and its exit code 2 behavior under --fail-on-warnings. Refs #859 Co-authored-by: sisyphus-dev-ai --- docs/cli.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 54b50134..31e9715e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -163,10 +163,10 @@ Discovery semantics: | `--format` | `-f` | `json`, `yaml` | `json` | Output serialization format | | `--openapi-version` | - | `3.0`, `3.1`, `3.2` | `3.1` | OpenAPI schema version | | `--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 | @@ -176,7 +176,16 @@ 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 ` 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. See [Route Prefix](route-prefix.md) for precedence. ## Validate generated output From 230575273391929e848dd7556858d240fa961c4e Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:30 +0900 Subject: [PATCH 05/10] docs: record opt-in route prefix resolution in the README and design notes Mention the opt-in helper and the CLI auto value in the README, and record in the design notes that detection is opt-in and that Python APIs do not warn about mismatches. Refs #859 Co-authored-by: sisyphus-dev-ai --- DESIGN.md | 9 +++++++++ README.md | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index 91218f91..4425cd74 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -169,6 +169,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 diff --git a/README.md b/README.md index a857541f..4fa6cbfc 100644 --- a/README.md +++ b/README.md @@ -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 From 3706ec41f472935bd67f545205315c39c709f7ee Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 19:48:31 +0900 Subject: [PATCH 06/10] docs(llms): add resolve_route_prefix to the LLM references List the new public helper and the CLI behavior in llms.txt and llms-full.txt. Refs #859 Co-authored-by: sisyphus-dev-ai --- llms-full.txt | 21 +++++++++++++++++++++ llms.txt | 1 + 2 files changed, 22 insertions(+) diff --git a/llms-full.txt b/llms-full.txt index e629a25d..13f5b1bb 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -214,6 +214,27 @@ 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. 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 diff --git a/llms.txt b/llms.txt index 6ba493a8..0eb83dbf 100644 --- a/llms.txt +++ b/llms.txt @@ -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` From 991ee182e242e89f8ce9f0cc4e46c17a4ea95ece Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 20:27:40 +0900 Subject: [PATCH 07/10] fix(routes): read host.json with an optional UTF-8 byte order mark A host.json saved with a UTF-8 byte order mark, as some Windows editors do, made resolve_route_prefix warn and fall back to /api. Decode with utf-8-sig so the file is read, add a regression test, and document that the file is parsed as strict JSON, so comments and trailing commas are still reported as unreadable. Refs #859 Co-authored-by: sisyphus-dev-ai --- docs/route-prefix.md | 2 ++ src/azure_functions_openapi/host_config.py | 2 +- tests/test_host_config.py | 13 +++++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/route-prefix.md b/docs/route-prefix.md index 984f01d0..086ee482 100644 --- a/docs/route-prefix.md +++ b/docs/route-prefix.md @@ -46,6 +46,8 @@ environment variable. 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: diff --git a/src/azure_functions_openapi/host_config.py b/src/azure_functions_openapi/host_config.py index 2f212c20..d9982c92 100644 --- a/src/azure_functions_openapi/host_config.py +++ b/src/azure_functions_openapi/host_config.py @@ -10,7 +10,7 @@ def _read_route_prefix(path: Path) -> tuple[str | None, str | None]: try: - document = json.loads(path.read_text(encoding="utf-8")) + document = json.loads(path.read_text(encoding="utf-8-sig")) except FileNotFoundError: return None, None except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: diff --git a/tests/test_host_config.py b/tests/test_host_config.py index 3eb2bf39..a018ab90 100644 --- a/tests/test_host_config.py +++ b/tests/test_host_config.py @@ -40,6 +40,19 @@ def test_host_json_prefix_is_normalized(tmp_path: Path, case: tuple[str, str]) - assert result == expected +def test_utf8_byte_order_mark_is_ignored(tmp_path: Path) -> None: + # Given + path = tmp_path / "host.json" + path.write_bytes(b"\xef\xbb\xbf" + b'{"extensions":{"http":{"routePrefix":"v1"}}}') + # When + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + result = resolve_route_prefix(path) + # Then + assert result == "/v1" + assert captured == [] + + @pytest.mark.parametrize("content", [None, "{}", '{"extensions":{"http":{}}}', "[]"]) def test_absent_configuration_silently_uses_default(tmp_path: Path, content: str | None) -> None: # Given From 9fee54223a024628299a4fb67eee4ee1770922bb Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 20:44:45 +0900 Subject: [PATCH 08/10] test(routes): cover the auto fallback and make the no-warning check filter-independent Pin that --route-prefix auto falls back to /api with a RuntimeWarning and does not gate --fail-on-warnings when host.json is unreadable, so any change to that policy is deliberate. Record warnings with the always filter in the absent-configuration test so its empty-list assertion cannot pass because a filter hid the warning. Refs #859 Co-authored-by: sisyphus-dev-ai --- tests/test_cli_host_config.py | 15 +++++++++++++++ tests/test_host_config.py | 1 + 2 files changed, 16 insertions(+) diff --git a/tests/test_cli_host_config.py b/tests/test_cli_host_config.py index db085205..e07e5578 100644 --- a/tests/test_cli_host_config.py +++ b/tests/test_cli_host_config.py @@ -141,6 +141,21 @@ def test_auto_honors_environment(run_cli: RunCLI, monkeypatch: pytest.MonkeyPatc assert error == "" +@pytest.mark.parametrize("host", ["{", '{"extensions":{"http":{"routePrefix":17}}}']) +def test_auto_with_unreadable_host_json_falls_back_without_gating( + run_cli: RunCLI, host: str +) -> None: + # Given + options = ["--route-prefix", "auto", "--fail-on-warnings"] + # When + with pytest.warns(RuntimeWarning, match="Could not read route prefix"): + code, output, error = run_cli(options, host) + # Then + assert code == 0 + assert set(json.loads(output)["paths"]) == {"/api/widgets"} + assert error == "" + + def test_auto_looks_beside_module_not_cwd(run_cli: RunCLI, monkeypatch: pytest.MonkeyPatch) -> None: # Given caller = Path("caller") diff --git a/tests/test_host_config.py b/tests/test_host_config.py index a018ab90..bec32c6e 100644 --- a/tests/test_host_config.py +++ b/tests/test_host_config.py @@ -61,6 +61,7 @@ def test_absent_configuration_silently_uses_default(tmp_path: Path, content: str path.write_text(content, encoding="utf-8") # When with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") result = resolve_route_prefix(path) # Then assert result == "/api" From c5683c036689a86ff6cade064a507fe2889ecfbd Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 20:44:45 +0900 Subject: [PATCH 09/10] docs(routes): state the empty app setting and auto fallback limits Say that the helper treats a present but empty app setting as no prefix without claiming how a hosting environment delivers it, and document that --route-prefix auto falls back to /api with a RuntimeWarning that does not gate --fail-on-warnings when host.json cannot be read. Refs #859 Co-authored-by: sisyphus-dev-ai --- docs/cli.md | 4 +++- docs/route-prefix.md | 20 +++++++++++++------- llms-full.txt | 4 +++- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 31e9715e..7c928911 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -185,7 +185,9 @@ file's normalized `extensions.http.routePrefix` differs from the effective default or explicit CLI prefix. Fix it with `--route-prefix ` 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. See [Route Prefix](route-prefix.md) for precedence. +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 diff --git a/docs/route-prefix.md b/docs/route-prefix.md index 086ee482..a9705e21 100644 --- a/docs/route-prefix.md +++ b/docs/route-prefix.md @@ -41,11 +41,15 @@ Resolution uses the first available value: `./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, including an empty -environment variable. 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`. +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. @@ -73,8 +77,10 @@ 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. A **literal** prefix -named `auto` must be written `--route-prefix /auto`. +The app-setting override above still wins in `auto` mode. If the file cannot be +read, `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 diff --git a/llms-full.txt b/llms-full.txt index 13f5b1bb..81253776 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -230,7 +230,9 @@ 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. A literal auto prefix is `/auto`. +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. From 1c5b4fa2138e8a0e29eff0298375cffe7d76be35 Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Fri, 30 Oct 2026 20:47:54 +0900 Subject: [PATCH 10/10] docs(routes): say unreadable or malformed where auto falls back Align the route prefix guide with the CLI guide so a missing host.json is not read as one of the cases that prints the RuntimeWarning in --route-prefix auto mode. Refs #859 Co-authored-by: sisyphus-dev-ai --- docs/route-prefix.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/route-prefix.md b/docs/route-prefix.md index a9705e21..dd18a62c 100644 --- a/docs/route-prefix.md +++ b/docs/route-prefix.md @@ -77,10 +77,10 @@ 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 cannot be -read, `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`. +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