-
Notifications
You must be signed in to change notification settings - Fork 709
UN-4009 [MISC] Generate and commit the API deployment OpenAPI spec in-repo #2237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
chandrasekharan-zipstack
wants to merge
9
commits into
main
Choose a base branch
from
feat/docstudio-openapi-spec
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
c49234b
feat(api): generate and commit the API deployment OpenAPI spec
chandrasekharan-zipstack 482ca1b
fix(api): publish the deployment contract the server actually implements
chandrasekharan-zipstack 86761cc
Name the spec after the API, not one product
chandrasekharan-zipstack 05413c1
test: assert over every documented operation, not exactly one
chandrasekharan-zipstack 3ebfafb
chore: drop drf-yasg now that spectacular generates the spec
chandrasekharan-zipstack 3198b52
refactor: move the deployment schema annotations out of the view
chandrasekharan-zipstack 51c7cd4
test: name the downstream repos in the drift failure, and pin the res…
chandrasekharan-zipstack eddd4b7
fix(api): publish the error bodies and file fields the server really …
chandrasekharan-zipstack 879ec5a
Merge branch 'main' into feat/docstudio-openapi-spec
chandrasekharan-zipstack File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| """URLconf the published OpenAPI spec is generated against. | ||
|
|
||
| Each entry is an included sub-urlconf: generating against one directly yields | ||
| paths without the prefix it is mounted at, i.e. a spec describing URLs the | ||
| server does not serve. The mounts are selected out of the served urlconf | ||
| rather than restated, so moving one moves the generated paths with it. | ||
|
|
||
| Widening the spec to another endpoint means annotating its view with | ||
| ``@extend_schema`` and adding its urlconf here. | ||
| """ | ||
|
|
||
| from django.core.exceptions import ImproperlyConfigured | ||
|
|
||
| from backend import base_urls | ||
|
|
||
| SPEC_URLCONFS = ("api_v2.execution_urls",) | ||
|
|
||
| urlpatterns = [ | ||
| entry | ||
| for entry in base_urls.urlpatterns | ||
| if getattr(getattr(entry, "urlconf_name", None), "__name__", None) in SPEC_URLCONFS | ||
| ] | ||
|
|
||
| missing = set(SPEC_URLCONFS) - {entry.urlconf_name.__name__ for entry in urlpatterns} | ||
| if missing: | ||
| raise ImproperlyConfigured( | ||
| f"{', '.join(sorted(missing))} is not mounted in backend.base_urls; the " | ||
| "spec would be generated for routes the server does not serve." | ||
| ) |
132 changes: 132 additions & 0 deletions
132
backend/api_v2/management/commands/generate_docstudio_spec.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| """Regenerate the committed API deployment OpenAPI spec. | ||
|
|
||
| The spec is the contract the published clients and their generated SDKs are | ||
| built from, so it is committed and CI fails on drift: change a route, a | ||
| serializer or the schema annotation, and regenerate in the same PR. | ||
|
|
||
| uv run python manage.py generate_docstudio_spec # from backend/ | ||
| uv run python manage.py generate_docstudio_spec --check # no write, drift is an error | ||
|
|
||
| The generated paths carry ``API_DEPLOYMENT_PATH_PREFIX``, so generation refuses | ||
| to produce a spec mounted anywhere but the public default: the committed | ||
| artifact describes the deployment as it is served publicly, not as one | ||
| installation chooses to mount it. | ||
| """ | ||
|
|
||
| import json | ||
| from pathlib import Path | ||
| from typing import Any | ||
|
|
||
| from django.core.management.base import BaseCommand, CommandError | ||
| from drf_spectacular.drainage import GENERATOR_STATS | ||
| from drf_spectacular.generators import SchemaGenerator | ||
| from drf_spectacular.validation import validate_schema | ||
|
|
||
| DEFAULT_OUT = Path(__file__).resolve().parents[4] / "specs" / "docstudio-oss.json" | ||
| URLCONF = "api_v2.deployment_spec_urls" | ||
| REGENERATE = "uv run python manage.py generate_docstudio_spec" | ||
| # The mount the deployment is served at publicly. `API_DEPLOYMENT_PATH_PREFIX` | ||
| # can move it per installation, and a spec carrying a private prefix would send | ||
| # every generated client to a URL only that installation answers. | ||
| PUBLISHED_PATH_PREFIX = "deployment" | ||
| # Named in every failure message: the repos that regenerate from this file are | ||
| # the ones a spec change actually breaks, and nothing there watches this repo. | ||
| DOWNSTREAM = ( | ||
| "The published client (Zipstack/unstract-python-client) and the CLI " | ||
| "(Zipstack/unstract-cli) are generated from this file — raise the matching " | ||
| "PRs there for anything that changes an operation id, a tag or a schema." | ||
| ) | ||
|
|
||
|
|
||
| class SpecGenerationFailed(CommandError): | ||
| """Raised when the generator had to guess.""" | ||
|
|
||
|
|
||
| def render_spec() -> str: | ||
| """The committed artifact, byte for byte. | ||
|
|
||
| Shared with the drift test: two copies of this could disagree, and then | ||
| the gate rejects exactly the file the command it names produces. | ||
| """ | ||
| GENERATOR_STATS.reset() | ||
| schema = SchemaGenerator(urlconf=URLCONF).get_schema(request=None, public=True) | ||
| if GENERATOR_STATS: | ||
| # An operation spectacular could not resolve is published with no | ||
| # request body and an empty response rather than dropped, which reads | ||
| # downstream as an annotation that is simply thin. Both caches are | ||
| # drained because the severity a given diagnostic carries is | ||
| # spectacular's choice, not something to rely on. | ||
| diagnostics = "\n".join( | ||
| f" {severity}: {message}" | ||
| for severity, cache in ( | ||
| ("error", GENERATOR_STATS._error_cache), | ||
| ("warning", GENERATOR_STATS._warn_cache), | ||
| ) | ||
| for message in cache | ||
| ) | ||
| raise SpecGenerationFailed( | ||
| f"The generator reported problems, so the spec would describe an " | ||
| f"API nobody implements:\n{diagnostics}" | ||
| ) | ||
|
|
||
| off_prefix = [ | ||
| path | ||
| for path in schema["paths"] | ||
| if not path.startswith(f"/{PUBLISHED_PATH_PREFIX}/") | ||
| ] | ||
| if off_prefix: | ||
| raise SpecGenerationFailed( | ||
| f"Generated paths are not under /{PUBLISHED_PATH_PREFIX}/: " | ||
| f"{', '.join(sorted(off_prefix))}. Unset API_DEPLOYMENT_PATH_PREFIX " | ||
| f"and regenerate." | ||
| ) | ||
|
|
||
| # Hand-written fragments (path parameter schemas, security schemes) reach | ||
| # the output verbatim, so nothing above would notice a typo in one. | ||
| try: | ||
| validate_schema(schema) | ||
| except Exception as error: | ||
| raise SpecGenerationFailed(f"The generated spec is not valid OpenAPI: {error}") | ||
|
|
||
| # Sorted keys are what make the committed artifact a usable drift signal. | ||
| return json.dumps(schema, indent=2, sort_keys=True) + "\n" | ||
|
|
||
|
|
||
| class Command(BaseCommand): | ||
| help = "Generate the API deployment OpenAPI spec." | ||
|
|
||
| def add_arguments(self, parser: Any) -> None: | ||
| parser.add_argument("--out", type=Path, default=DEFAULT_OUT) | ||
| parser.add_argument( | ||
| "--check", | ||
| action="store_true", | ||
| help="Fail if the file on disk differs, instead of writing it.", | ||
| ) | ||
|
|
||
| def handle(self, *args: Any, **options: Any) -> None: | ||
| rendered = render_spec() | ||
|
|
||
| out: Path = options["out"] | ||
| if options["check"]: | ||
| current = out.read_text() if out.exists() else "" | ||
| if current != rendered: | ||
| raise CommandError( | ||
| f"{out} is out of date. Run `{REGENERATE}` from `backend/` " | ||
| f"and commit the result.\n\n{DOWNSTREAM}" | ||
| ) | ||
| self.stdout.write(f"{out} is up to date") | ||
| return | ||
|
|
||
| out.parent.mkdir(parents=True, exist_ok=True) | ||
| out.write_text(rendered) | ||
| schema = json.loads(rendered) | ||
| operations = sum( | ||
| 1 | ||
| for methods in schema["paths"].values() | ||
| for method in methods | ||
| if method in {"get", "post", "put", "patch", "delete"} | ||
| ) | ||
| self.stdout.write( | ||
| f"{out}: {len(schema['paths'])} paths, {operations} operations, " | ||
| f"{len(schema.get('components', {}).get('schemas', {}))} schemas" | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.