diff --git a/DESIGN.md b/DESIGN.md index 43f6cb25..b6744123 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -68,6 +68,25 @@ Invalid configuration or an entry-snapshot failure before compilation starts lea the previous diagnostics unchanged. Discovery warnings and empty discoveries retain their scan lifetime and are never cleared by this publication. +Reports and the CLI consume an internal generation result containing the document, +invocation-local diagnostic tuple, pre-transform validation messages and an +operation-traversal change flag. They do not reconstruct generation events from the +registry's mutable compatibility snapshot. Scan-lifetime warnings are captured at +the start of that invocation; unresolved-route warnings still come from facade/CLI +preparation and remain advisory. + +Before: compile -> validate -> relocate/drop -> publish -> report/CLI reads registry +channels -> validate final document again. + +After: compile -> validate -> relocate/drop -> publish -> report/CLI consumes local +result -> reuse validation only when operation traversal is unchanged. Custom-method +relocation (including 3.2) and pre-3.2 QUERY removal require final-document validation: +relocation can change duplicate-operation-id encounter order. Generation logs and +strict-mode failures retain pre-transform messages plus downgrade findings. Plain +runtime JSON/YAML generation never performs this final report-only validation walk. +Standalone `collect_spec_warnings(spec, registry)` always validates its supplied +document, including one modified after generation or supplied by a mock. + Protected docs can opt into browser-owned key forwarding: the library's header-mode handler does not read or echo the query value, while the Functions host necessarily receives the initial URL. A nonce-protected script removes `code` from the diff --git a/src/azure_functions_openapi/_generation_result.py b/src/azure_functions_openapi/_generation_result.py new file mode 100644 index 00000000..7453d47e --- /dev/null +++ b/src/azure_functions_openapi/_generation_result.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from azure_functions_openapi._warnings import SpecWarning + + +@dataclass(frozen=True, slots=True) +class GenerationResult: + """Invocation-owned findings; final validation is deferred until a report is requested.""" + + spec: dict[str, Any] + diagnostics: tuple[SpecWarning, ...] + validation_messages: tuple[str, ...] + traversal_changed: bool diff --git a/src/azure_functions_openapi/cli.py b/src/azure_functions_openapi/cli.py index 8152393b..fd665940 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._generation_result import GenerationResult from azure_functions_openapi._warnings import WarningCode from azure_functions_openapi.bridge import scan_endpoint_metadata from azure_functions_openapi.exceptions import OpenAPISpecConfigError @@ -18,8 +19,11 @@ OPENAPI_VERSION_3_0, OPENAPI_VERSION_3_1, OPENAPI_VERSION_3_2, + _report_generation_result, collect_spec_warnings, - generate_openapi_spec, +) +from azure_functions_openapi.spec import ( + _generate_openapi_result as generate_openapi_spec, ) @@ -313,7 +317,7 @@ def handle_generate(args: argparse.Namespace) -> int: getattr(args, "route_prefix", "/api"), active_registry, ) - spec = generate_openapi_spec( + result = generate_openapi_spec( args.title, args.version, openapi_version, @@ -323,7 +327,14 @@ def handle_generate(args: argparse.Namespace) -> int: registry=active_registry, infer_auth_level=getattr(args, "infer_auth_level", False) is True, ) - warnings = (*collect_spec_warnings(spec, registry=active_registry), *unresolved) + if isinstance(result, GenerationResult): + report = _report_generation_result(result) + spec = report.spec + warnings = (*report.warnings, *unresolved) + else: + # Preserve the plain-document seam used by embedded/mocked callers. + spec = result + warnings = (*collect_spec_warnings(spec, registry=active_registry), *unresolved) # 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/src/azure_functions_openapi/spec.py b/src/azure_functions_openapi/spec.py index 0b78dc87..655ba6ce 100644 --- a/src/azure_functions_openapi/spec.py +++ b/src/azure_functions_openapi/spec.py @@ -13,6 +13,7 @@ from pydantic import BaseModel import yaml +from azure_functions_openapi._generation_result import GenerationResult from azure_functions_openapi._warnings import SpecWarning, WarningCode from azure_functions_openapi.exceptions import OpenAPISpecConfigError from azure_functions_openapi.registry import OpenAPIRegistry @@ -871,6 +872,45 @@ def generate_openapi_spec( Returns: OpenAPI specification dictionary """ + return _generate_openapi_result( + title, + version, + openapi_version, + description, + security_schemes, + route_prefix, + strict, + registry, + hoist_flat_schemas, + infer_auth_level, + servers, + contact, + license, + external_docs, + tags, + collect_diagnostics=False, + ).spec + + +def _generate_openapi_result( + title: str = "API", + version: str = "1.0.0", + openapi_version: str = OPENAPI_VERSION_3_1, + description: str = DEFAULT_OPENAPI_INFO_DESCRIPTION, + security_schemes: dict[str, dict[str, Any]] | None = None, + route_prefix: str = DEFAULT_ROUTE_PREFIX, + strict: bool = False, + registry: OpenAPIRegistry | None = None, + hoist_flat_schemas: bool = False, + infer_auth_level: bool = False, + servers: list[dict[str, Any]] | None = None, + contact: dict[str, Any] | None = None, + license: dict[str, Any] | None = None, + external_docs: dict[str, Any] | None = None, + tags: list[dict[str, Any]] | None = None, + *, + collect_diagnostics: bool = True, +) -> GenerationResult: if openapi_version not in (OPENAPI_VERSION_3_0, OPENAPI_VERSION_3_1, OPENAPI_VERSION_3_2): raise OpenAPISpecConfigError( f"Unsupported OpenAPI version: {openapi_version}. Supported: " @@ -891,6 +931,18 @@ def generate_openapi_spec( else: registry_entries = get_openapi_registry() diagnostics_ready = True + scan_warnings = ( + ( + *_collect_skew_warnings(registry), + *_collect_discovery_warnings(registry), + *_collect_empty_discovery_warnings(registry), + ) + if collect_diagnostics + else () + ) + binding_warnings = ( + tuple(_collect_binding_mismatch_warnings(registry)) if collect_diagnostics else () + ) paths: dict[str, dict[str, Any]] = {} components: dict[str, Any] = {"schemas": {}} @@ -1000,7 +1052,7 @@ def generate_openapi_spec( warnings.warn( downgrade_message, RuntimeWarning, - stacklevel=2, + stacklevel=2 if collect_diagnostics else 3, ) media_obj = new_media_obj hoisted_content[media] = media_obj @@ -1467,6 +1519,14 @@ def generate_openapi_spec( spec = _normalize_spec_output(spec) validation_warnings = _validate_spec(spec) + pre_transform_validation = tuple(validation_warnings) + traversal_changed = collect_diagnostics and any( + method not in STANDARD_OPENAPI_METHODS + and method not in _RESERVED_PATH_ITEM_FIELDS + or (method == "query" and openapi_version != OPENAPI_VERSION_3_2) + for path_item in spec["paths"].values() + for method in path_item + ) # Custom (non-standard) HTTP methods are validated above as ordinary # operations, then relocated: under 3.2 into each path item's # ``additionalOperations`` map, and under 3.0/3.1 dropped (the format has @@ -1497,7 +1557,19 @@ def generate_openapi_spec( f"Generated OpenAPI {openapi_version} spec with {len(paths)} paths " f"for {len(registry_entries)} functions" ) - return spec + diagnostics = ( + ( + *scan_warnings, + *_collect_duplicate_operation_warnings(registry, duplicate_operations), + *_collect_downgrade_drop_warnings(registry, generation_drops), + *_collect_schema_substitution_warnings(registry, schema_substitutions), + *_collect_skipped_operation_warnings(registry, skipped_operations), + *binding_warnings, + ) + if collect_diagnostics + else () + ) + return GenerationResult(spec, diagnostics, pre_transform_validation, traversal_changed) except OpenAPISpecConfigError: raise @@ -2058,6 +2130,7 @@ def _collect_empty_discovery_warnings( def _collect_duplicate_operation_warnings( registry: OpenAPIRegistry | None = None, + events: list[tuple[str, str]] | None = None, ) -> list[SpecWarning]: """Derive duplicate-operation warnings from the registry's recorded collisions. @@ -2077,12 +2150,17 @@ def _collect_duplicate_operation_warnings( message=f"{_DUPLICATE_OPERATION_MESSAGE}: {operation}", function_name=None, ) - for operation in reg.duplicate_operations + for operation in ( + reg.duplicate_operations + if events is None + else sorted({f"{method.upper()} {path}" for method, path in events}) + ) ] def _collect_downgrade_drop_warnings( registry: OpenAPIRegistry | None = None, + events: list[str] | None = None, ) -> list[SpecWarning]: """Derive version-downgrade-drop warnings from the registry's recorded drops. @@ -2102,12 +2180,13 @@ def _collect_downgrade_drop_warnings( message=message, function_name=None, ) - for message in reg.downgrade_drops + for message in (reg.downgrade_drops if events is None else sorted(set(events))) ] def _collect_schema_substitution_warnings( registry: OpenAPIRegistry | None = None, + events: list[SpecWarning] | None = None, ) -> list[SpecWarning]: """Derive schema-substitution warnings from the registry's recorded failures. @@ -2120,11 +2199,23 @@ def _collect_schema_substitution_warnings( channel is empty there. """ reg = registry if registry is not None else _default_registry - return reg.schema_substitutions + if events is None: + return reg.schema_substitutions + return sorted( + set(events), + key=lambda warning: ( + warning.path or "", + warning.method or "", + warning.location or "", + warning.function_name or "", + warning.message, + ), + ) def _collect_skipped_operation_warnings( registry: OpenAPIRegistry | None = None, + events: list[str] | None = None, ) -> list[SpecWarning]: """Derive skipped-operation warnings from the registry's recorded omissions. @@ -2141,7 +2232,7 @@ def _collect_skipped_operation_warnings( message=message, function_name=message.split(":", 1)[0] or None, ) - for message in reg.skipped_operations + for message in (reg.skipped_operations if events is None else sorted(set(events))) ] @@ -2214,7 +2305,7 @@ def generate_openapi_report( Returns: A :class:`SpecReport` with ``spec`` and a deterministic ``warnings`` tuple. """ - spec = generate_openapi_spec( + result = _generate_openapi_result( title, version, openapi_version, @@ -2231,8 +2322,23 @@ def generate_openapi_report( tags=tags, registry=registry, ) - warnings_list = collect_spec_warnings(spec, registry=registry) - return SpecReport(spec=spec, warnings=warnings_list) + return _report_generation_result(result) + + +def _report_generation_result(result: GenerationResult) -> SpecReport: + messages = ( + _validate_spec(result.spec) if result.traversal_changed else result.validation_messages + ) + return SpecReport( + spec=result.spec, + warnings=( + *result.diagnostics, + *( + SpecWarning(code=WarningCode.SPEC_VALIDATION, message=message) + for message in messages + ), + ), + ) def collect_spec_warnings( diff --git a/tests/test_spec_report_local_diagnostics.py b/tests/test_spec_report_local_diagnostics.py new file mode 100644 index 00000000..b555b202 --- /dev/null +++ b/tests/test_spec_report_local_diagnostics.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import argparse +from collections.abc import Callable +from pathlib import Path +from unittest.mock import patch + +import pytest + +import azure_functions_openapi as api +from azure_functions_openapi import cli +from azure_functions_openapi import spec as compiler +from azure_functions_openapi._warnings import WarningCode +from azure_functions_openapi.registry import OpenAPIRegistry + + +def test_report_keeps_generation_events_when_publication_is_overwritten() -> None: + # Given: another invocation replaces the compatibility snapshot at publication. + class InterleavedRegistry(OpenAPIRegistry): + def add_downgrade_drop(self, message: str) -> None: + super().add_downgrade_drop(message) + self.clear_downgrade_drops() + + registry = InterleavedRegistry() + registry.set("custom", {"route": "custom", "method": "purge"}) + # When + report = compiler.generate_openapi_report(registry=registry) + # Then + assert [warning.code for warning in report.warnings] == [WarningCode.VERSION_DOWNGRADE_DROP] + + +def test_cli_keeps_local_events_after_registry_publication(tmp_path: Path) -> None: + # Given + class InterleavedRegistry(OpenAPIRegistry): + def add_downgrade_drop(self, message: str) -> None: + super().add_downgrade_drop(message) + self.clear_downgrade_drops() + + registry = InterleavedRegistry() + registry.set("custom", {"route": "custom", "method": "purge"}) + artifact = tmp_path / "spec.yaml" + args = argparse.Namespace( + title="API", + version="1", + openapi_version="3.1", + app=None, + format="yaml", + output=str(artifact), + fail_on_warnings=True, + ) + # When + with ( + patch.object(compiler, "_default_registry", registry), + patch.object(cli, "_prepare_generation", return_value=()), + ): + code = cli.handle_generate(args) + # Then + assert code == 2 + assert not artifact.exists() + + +@pytest.mark.parametrize("serializer", [compiler.get_openapi_json, compiler.get_openapi_yaml]) +def test_runtime_serialization_never_adds_final_validation(serializer: Callable[..., str]) -> None: + # Given + registry = OpenAPIRegistry() + registry.set("custom", {"route": "custom", "method": "purge"}) + # When + with patch.object(compiler, "_validate_spec", wraps=compiler._validate_spec) as validate: + serializer(registry=registry, openapi_version="3.2.0") + # Then + assert validate.call_count == 1 + + +@pytest.mark.parametrize("target", ["3.0.0", "3.1.0", "3.2.0"]) +@pytest.mark.parametrize("method", ["get", "query", "purge"]) +def test_report_validates_again_only_when_operation_traversal_changes( + target: str, method: str +) -> None: + # Given + registry = OpenAPIRegistry() + registry.set("operation", {"route": "operation", "method": method}) + # When + with patch.object(compiler, "_validate_spec", wraps=compiler._validate_spec) as validate: + report = compiler.generate_openapi_report(registry=registry, openapi_version=target) + # Then + changed = method == "purge" or (method == "query" and target != "3.2.0") + assert validate.call_count == (2 if changed else 1) + assert report.warnings == compiler.collect_spec_warnings(report.spec, registry) + + +def test_final_validation_names_first_operation_after_custom_relocation() -> None: + # Given: relocation changes which duplicate operation is encountered first. + registry = OpenAPIRegistry() + registry.set("custom", {"route": "same", "method": "purge", "operation_id": "shared"}) + registry.set("standard", {"route": "same", "method": "get", "operation_id": "shared"}) + # When + report = compiler.generate_openapi_report(registry=registry, openapi_version="3.2.0") + # Then + assert report.warnings == compiler.collect_spec_warnings(report.spec, registry) + assert [w.message for w in report.warnings] == [ + "Duplicate operationId 'shared': used by GET /api/same and PURGE /api/same" + ] + + +def test_public_collection_validates_report_spec_modified_after_generation() -> None: + # Given + registry = OpenAPIRegistry() + registry.set("operation", {"route": "operation"}) + report = compiler.generate_openapi_report(registry=registry) + report.spec["paths"]["/api/operation"]["get"]["operationId"] = "" + # When + collected = compiler.collect_spec_warnings(report.spec, registry) + # Then + assert report.warnings == () + assert [(w.code, w.message) for w in collected] == [ + (WarningCode.SPEC_VALIDATION, "Empty operationId in GET /api/operation") + ] + + +def test_item_schema_runtime_and_structured_messages_match() -> None: + # Given + registry = OpenAPIRegistry() + registry.set( + "events", + { + "route": "events", + "response": { + 200: {"content": {"text/event-stream": {"itemSchema": {"type": "string"}}}} + }, + }, + ) + # When + with pytest.warns(RuntimeWarning) as runtime: + report = compiler.generate_openapi_report(registry=registry) + # Then + assert [w.message for w in report.warnings] == [str(w.message) for w in runtime] + assert report.spec["paths"]["/api/events"]["get"]["responses"]["200"]["content"][ + "text/event-stream" + ]["itemSchema"] == {"type": "string"} + + +@pytest.mark.parametrize("empty_gate,expected", [(False, 2), (True, 1)]) +def test_cli_warning_gate_preserves_exit_precedence_and_existing_artifact( + tmp_path: Path, empty_gate: bool, expected: int +) -> None: + # Given + registry = OpenAPIRegistry() + registry.add_empty_discovery("EmptyApp") + artifact = tmp_path / "spec.json" + artifact.write_text("previous", encoding="utf-8") + args = argparse.Namespace( + title="API", + version="1", + openapi_version="3.1", + app=None, + format="json", + output=str(artifact), + fail_on_warnings=True, + fail_on_empty_paths=empty_gate, + ) + # When + with ( + patch.object(cli, "default_registry", registry), + patch.object(compiler, "_default_registry", registry), + patch.object(cli, "_prepare_generation", return_value=()), + ): + code = cli.handle_generate(args) + # Then + assert code == expected + assert artifact.read_text(encoding="utf-8") == "previous" + + +def test_facade_unresolved_routes_remain_advisory_in_cli(tmp_path: Path) -> None: + # Given + registry = OpenAPIRegistry() + registry.set("implicit", {"function_name": "implicit"}) + with pytest.warns(RuntimeWarning): + report = api.generate_openapi_report(registry=registry) + unresolved = tuple(w for w in report.warnings if w.code == WarningCode.UNRESOLVED_ROUTE) + artifact = tmp_path / "spec.json" + args = argparse.Namespace( + title="API", + version="1", + openapi_version="3.1", + app=None, + format="json", + output=str(artifact), + fail_on_warnings=True, + ) + # When + with ( + patch.object(compiler, "_default_registry", registry), + patch.object(cli, "_prepare_generation", return_value=unresolved), + ): + code = cli.handle_generate(args) + # Then + assert len(unresolved) == 1 + assert code == 0 + assert artifact.is_file()