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
7 changes: 7 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/azure_functions_openapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 1 addition & 5 deletions src/azure_functions_openapi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down
8 changes: 4 additions & 4 deletions tests/test_cli_auth_level.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
12 changes: 4 additions & 8 deletions tests/test_cli_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]


Expand Down
84 changes: 84 additions & 0 deletions tests/test_selected_app_scope.py
Original file line number Diff line number Diff line change
@@ -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)
Loading