diff --git a/DESIGN.md b/DESIGN.md index be9b8b4c..cc377231 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -53,6 +53,13 @@ flowchart TD Note: `render_swagger_ui()` does not embed or generate the OpenAPI spec. It returns HTML that instructs the browser to fetch the spec from a configured URL. +An explicit Python `app=` or CLI `module:variable` selects an app-scoped registry. +Discovery reuses the existing seed-on-scan mechanism, copying decorator metadata +by canonical handler identity and reconciling only the selected app's bindings. +The default global registry is not mutated. Module-only CLI generation and Python +generation without `app` retain global aggregation. An explicitly injected Python +registry retains caller-owned additive behavior, including programmatic entries. + ### Schema Version Conversion Before #819: `spec.py` assembled operations, recursively translated schemas, diff --git a/README.md b/README.md index bbfb8565..558431dc 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,13 @@ correct default route: azure-functions-openapi generate --app function_app:app --title "My API" ``` +Selecting `module:variable` scopes generation to that app's reachable handlers. +Likewise, the Python facades' `app=app` uses a fresh `OpenAPIRegistry` by default, +without changing the process-wide registry. Unrelated decorated handlers and +unbound programmatic metadata are excluded. For deliberate aggregation, omit +`app` (CLI: use a module without `:variable`), or supply an explicit `registry=` +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. diff --git a/docs/cli.md b/docs/cli.md index 6588e326..54b50134 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -168,7 +168,7 @@ Discovery semantics: | `--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 | | `--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` | Scan the `--app` `FunctionApp` into a fresh, app-scoped registry instead of the shared global one. Requires `--app module:variable`. Use when several apps are imported in one process to avoid cross-app route leakage | +| `--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 | ## Exit codes diff --git a/src/azure_functions_openapi/__init__.py b/src/azure_functions_openapi/__init__.py index 72bf7889..3b809e3b 100644 --- a/src/azure_functions_openapi/__init__.py +++ b/src/azure_functions_openapi/__init__.py @@ -78,7 +78,9 @@ def generate_openapi_spec( tags: list[dict[str, Any]] | None = None, app: object | None = None, ) -> dict[str, Any]: - """Compile a spec, optionally reconciling routes from a FunctionApp first.""" + """Compile a spec scoped to app, unless an explicit registry is supplied.""" + if app is not None and registry is None: + registry = OpenAPIRegistry() _prepare_generation(app, route_prefix, registry) return _generate_openapi_spec( title=title, @@ -207,7 +209,9 @@ def generate_openapi_report( tags: list[dict[str, Any]] | None = None, app: object | None = None, ) -> SpecReport: - """Return a spec report after optionally reconciling a completed FunctionApp.""" + """Return a report scoped to app, unless an explicit registry is supplied.""" + if app is not None and registry is None: + registry = OpenAPIRegistry() unresolved = _prepare_generation(app, route_prefix, registry) report = _generate_openapi_report( title, diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index fd665940..032e40b5 100644 --- a/src/azure_functions_openapi/cli.py +++ b/src/azure_functions_openapi/cli.py @@ -241,9 +241,6 @@ def main() -> int: def handle_generate(args: argparse.Namespace) -> int: """Handle generate command.""" try: - # An app-scoped registry keeps a --isolate-app run limited to the routes - # of the given FunctionApp, avoiding cross-app leakage when several apps - # are imported in one process. Left None for the default shared-global flow. active_registry: OpenAPIRegistry | None = None isolate = getattr(args, "isolate_app", False) is True # Fail closed: an explicit --isolate-app that cannot be honored must not @@ -270,8 +267,7 @@ def handle_generate(args: argparse.Namespace) -> int: return 1 if variable_given and resolved_app is not None: - if isolate: - active_registry = OpenAPIRegistry() + active_registry = OpenAPIRegistry() # One-shot discovery through the #325 adapter. `build()` is # idempotent; we never call the non-idempotent `get_functions()`. scan_endpoint_metadata( diff --git a/tests/test_cli.py b/tests/test_cli.py index 577d25d2..4a27380c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -24,8 +24,10 @@ def test_installed_console_script_imports_app_from_invocation_directory( # Given: a project outside the installed console script's directory. (tmp_path / "function_app.py").write_text( "from azure_functions_openapi import openapi\n" - "app = object()\n" + "import azure.functions as func\n" + "app = func.FunctionApp()\n" "@openapi(route='items', method='get', summary='Items')\n" + "@app.route(route='items', methods=['GET'])\n" "def items(): pass\n", encoding="utf-8", ) diff --git a/tests/test_cli_auth_level.py b/tests/test_cli_auth_level.py index f7b88bc2..b948dc7d 100644 --- a/tests/test_cli_auth_level.py +++ b/tests/test_cli_auth_level.py @@ -217,7 +217,7 @@ def test_cli_inference_fails_closed_when_discovery_is_empty_or_skipped( assert "auth-level inference" in capsys.readouterr().err -def test_cli_inference_fails_when_any_operation_lacks_auth_and_explicit_security( +def test_cli_inference_excludes_unrelated_operation_without_auth( tmp_path: Path, capsys: pytest.CaptureFixture[str], ) -> None: @@ -250,6 +250,6 @@ def unresolved() -> None: result = main() # Then - assert result != 0 - assert not output.exists() - assert "unresolved" in capsys.readouterr().err + assert result == 0 + assert "/api/unresolved" not in json.loads(output.read_text(encoding="utf-8"))["paths"] + assert "unresolved" not in capsys.readouterr().err diff --git a/tests/test_cli_discovery.py b/tests/test_cli_discovery.py index 4b5ded86..caca6f82 100644 --- a/tests/test_cli_discovery.py +++ b/tests/test_cli_discovery.py @@ -165,21 +165,17 @@ def test_endpoint_only_app_is_discovered_via_module_variable(tmp_path: Path) -> assert "post" in spec["paths"]["/api/users"] -def test_decorator_only_app_is_included_via_module_variable(tmp_path: Path) -> None: - """Decorator-registered routes (populated on import) appear in the spec.""" - # Simulate an @openapi decorator firing during module import. +def test_unbound_metadata_is_excluded_via_module_variable(tmp_path: Path) -> None: register_openapi_metadata(path="/api/items", method="get", summary="List items") spec = _run_generate("function_app:app", _empty_app(), True, tmp_path / "openapi.json") - assert "/api/items" in spec["paths"] - assert "get" in spec["paths"]["/api/items"] + assert spec["paths"] == {} -def test_mixed_decorator_and_endpoint_routes_both_present(tmp_path: Path) -> None: - """Decorator-only and endpoint-metadata routes converge into one spec.""" +def test_endpoint_routes_exclude_unbound_metadata(tmp_path: Path) -> None: register_openapi_metadata(path="/api/items", method="get", summary="List items") app = _endpoint_app(route="users", methods=["POST"]) spec = _run_generate("function_app:app", app, True, tmp_path / "openapi.json") - assert "/api/items" in spec["paths"] + assert "/api/items" not in spec["paths"] assert "/api/users" in spec["paths"] diff --git a/tests/test_selected_app_scope.py b/tests/test_selected_app_scope.py new file mode 100644 index 00000000..c7611922 --- /dev/null +++ b/tests/test_selected_app_scope.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from collections.abc import Iterator +import importlib +import json +from pathlib import Path +import sys + +import azure.functions as func +import pytest + +from azure_functions_openapi import clear_openapi_registry, generate_openapi_report +from azure_functions_openapi.cli import main +from azure_functions_openapi.registry import registry + + +@pytest.fixture +def two_apps( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[tuple[func.FunctionApp, ...]]: + # Given: separate imported modules contain identically named handlers. + clear_openapi_registry() + monkeypatch.syspath_prepend(str(tmp_path)) + names = ("scope_app_a", "scope_app_b") + for name in names: + (tmp_path / f"{name}.py").write_text( + "import azure.functions as func\n" + "from azure_functions_openapi.decorator import openapi\n" + "app = func.FunctionApp()\n" + f"@openapi(summary='{name}')\n" + f"@app.route(route='{name}', methods=['GET'])\n" + "def handler(req):\n" + " return func.HttpResponse('ok')\n", + encoding="utf-8", + ) + apps = tuple(importlib.import_module(name).app for name in names) + yield apps + clear_openapi_registry() + for name in names: + sys.modules.pop(name, None) + + +@pytest.mark.parametrize("selected", [0, 1]) +def test_report_contains_only_selected_app_when_handler_names_collide( + two_apps: tuple[func.FunctionApp, ...], selected: int +) -> None: + before = registry.snapshot() + # When: the public facade generates a report for one completed app. + report = generate_openapi_report(app=two_apps[selected], strict=True) + # Then: only that app is documented, with its own metadata and no global mutation. + path = f"/api/scope_app_{'ab'[selected]}" + assert set(report.spec["paths"]) == {path} + assert report.spec["paths"][path]["get"]["summary"] == f"scope_app_{'ab'[selected]}" + assert not report.warnings + assert registry.snapshot() == before + + +@pytest.mark.parametrize("isolate", [False, True]) +def test_cli_contains_only_selected_app_when_both_modules_are_imported( + two_apps: tuple[func.FunctionApp, ...], + capsys: pytest.CaptureFixture[str], + isolate: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # When: CLI generation selects an app, with or without the legacy isolation flag. + args = ["generate", "--app", "scope_app_a:app", "--format", "json"] + if isolate: + args.append("--isolate-app") + monkeypatch.setattr(sys, "argv", ["azure-functions-openapi", *args]) + result = main() + # Then: selected-app CLI and Python facade agree. + captured = capsys.readouterr() + assert result == 0, captured.err + assert set(json.loads(captured.out)["paths"]) == {"/api/scope_app_a"} + + +def test_report_keeps_global_collection_when_no_app_is_selected( + two_apps: tuple[func.FunctionApp, ...], +) -> None: + # When: the caller deliberately generates from the process-wide registry. + report = generate_openapi_report() + # Then: legacy module-only aggregation remains available, with collision diagnostics. + assert set(report.spec["paths"]) == {"/api/scope_app_a", "/api/scope_app_b"} + assert any("operationId" in warning.message for warning in report.warnings)