Skip to content
Closed
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: 18 additions & 4 deletions backend/scripts/export_openapi.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#!/usr/bin/env python3

Check warning on line 1 in backend/scripts/export_openapi.py

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

backend/scripts/export_openapi.py is 1069 lines; consider splitting files over 800 lines.
"""Export and check OpenAPI contracts.

Contract-surface decision for issue #8546:
Expand Down Expand Up @@ -985,12 +985,26 @@
path.write_text(generated)


def check_spec(path: Path, generated: str) -> None:
def surface_flag(surface: str) -> str:
"""Return the CLI flag that selects `surface`, so error hints regenerate the right contract.

Surface selection is a separate flag from `--write`/`--check`; omitting it silently defaults to
the public surface and would overwrite a non-public spec with the wrong contract (#10217).
"""
if surface == 'app-client':
return '--app-client '
if surface == 'integration-public':
return '--surface integration-public '
return ''


def check_spec(path: Path, generated: str, surface: str = 'public') -> None:
flag = surface_flag(surface)
if not path.exists():
raise OpenAPIContractError(f'{path} does not exist; run export_openapi.py --write {path}')
raise OpenAPIContractError(f'{path} does not exist; run backend/scripts/export_openapi.py {flag}--write {path}')
current = path.read_text()
if current != generated:
raise OpenAPIContractError(f'{path} is stale; run backend/scripts/export_openapi.py --write {path}')
raise OpenAPIContractError(f'{path} is stale; run backend/scripts/export_openapi.py {flag}--write {path}')


def parse_args() -> argparse.Namespace:
Expand Down Expand Up @@ -1043,7 +1057,7 @@
print(f'wrote {path}')
elif args.check is not None:
path = resolve_spec_path(args.surface, args.check)
check_spec(path, generated)
check_spec(path, generated, args.surface)
print(f'{path} is up to date')
return 0
except OpenAPIContractError as e:
Expand Down
22 changes: 22 additions & 0 deletions backend/tests/unit/test_openapi_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,28 @@ def test_check_spec_detects_stale_file(tmp_path):
export_openapi.check_spec(spec_path, json.dumps({'fresh': True}) + '\n')


def test_check_spec_hint_includes_surface_flag(tmp_path):
# Regression for #10217: following the stale/missing hint verbatim must regenerate the same
# surface, not silently overwrite a non-public spec with the default public contract.
spec_path = tmp_path / 'openapi.json'
spec_path.write_text(json.dumps({'stale': True}) + '\n')
fresh = json.dumps({'fresh': True}) + '\n'

with pytest.raises(export_openapi.OpenAPIContractError, match=r'--app-client --write'):
export_openapi.check_spec(spec_path, fresh, 'app-client')

with pytest.raises(export_openapi.OpenAPIContractError, match=r'--surface integration-public --write'):
export_openapi.check_spec(spec_path, fresh, 'integration-public')

missing = tmp_path / 'absent.json'
with pytest.raises(export_openapi.OpenAPIContractError, match=r'--app-client --write'):
export_openapi.check_spec(missing, fresh, 'app-client')

# Public surface needs no flag — the default command stays correct.
with pytest.raises(export_openapi.OpenAPIContractError, match=r'export_openapi\.py --write'):
export_openapi.check_spec(spec_path, fresh)


def test_network_recorder_fails_even_when_blocked_attempt_is_swallowed():
with export_openapi.record_and_block_outbound_network() as attempts:
try:
Expand Down
Loading