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
22 changes: 22 additions & 0 deletions src/azure_functions_openapi/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,15 @@ def register_openapi_metadata(
the process-wide global registry (``None``); pass an isolated registry
to scope the metadata to a single application (see #381).

Notes
-----
A ``method`` + ``path`` pair is a single OpenAPI operation by definition, so
calling this again for the same pair replaces the prior entry
(last-writer-wins) rather than raising. This intentionally powers the
scan-then-enrich pattern: :func:`scan_endpoint_metadata` seeds a minimal
entry from bridge/validation metadata, and a subsequent call here overrides
it with richer, human-authored metadata.

Raises
------
ValueError
Expand Down Expand Up @@ -520,6 +529,19 @@ def register_openapi_metadata(

reg = registry if registry is not None else _registry
with reg.lock:
# Same method+path is a single OpenAPI operation by definition, so
# registering it again is an intentional last-writer-wins replace, not a
# collision between two logical operations. This is what powers the
# scan-then-enrich pattern: the bridge seeds a minimal entry via
# ``scan_endpoint_metadata`` and the caller then overrides it here with
# richer, human-authored metadata. Log the replace at debug level so it
# stays traceable without being noisy.
if reg.get(registry_key) is not None:
logger.debug(
"Replacing existing OpenAPI metadata for '%s %s' (last-writer-wins)",
validated_method.upper(),
path,
)
reg.set(registry_key, {
"summary": summary,
"description": description,
Expand Down
22 changes: 22 additions & 0 deletions tests/test_spec_warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,28 @@ def test_resolved_collision_not_carried_to_next_generation(self) -> None:
second = collect_spec_warnings(generate_openapi_spec(registry=reg), registry=reg)
assert not any(w.code == WarningCode.DUPLICATE_OPERATION for w in second)

def test_programmatic_reregistration_is_last_writer_wins(self) -> None:
# #397 (by design): a method+path pair is a single OpenAPI operation, so
# re-registering it replaces the prior entry rather than raising. This
# powers the scan-then-enrich pattern where the bridge seeds a minimal
# entry and the caller overrides it with richer metadata. Exactly one
# entry survives, and it is the last registration.
reg = OpenAPIRegistry()
register_openapi_metadata(
path="/api/dup", method="POST", summary="first", registry=reg
)
register_openapi_metadata(
path="/api/dup", method="POST", summary="second", registry=reg
)
snapshot = reg.snapshot()
assert len(snapshot) == 1
assert snapshot["post::/api/dup"]["summary"] == "second"
# A single surviving entry means no duplicate-operation warning: there is
# no second operation for the shared path to collide with.
spec = generate_openapi_spec(registry=reg)
codes = {w.code for w in collect_spec_warnings(spec, registry=reg)}
assert WarningCode.DUPLICATE_OPERATION not in codes


# ---------------------------------------------------------------------------
# #381: app-scoped (isolated) registry
Expand Down
Loading