UN-4009 [MISC] Generate and commit the API deployment OpenAPI spec in-repo - #2237
Conversation
The published Python clients and their generated SDKs are built from a spec of the deployment execute/status endpoints, which until now was produced by a script living outside this repo — so a route or serializer change here could silently invalidate it. The schema annotation for DeploymentExecution now lives next to the view, and `manage.py generate_docstudio_spec` writes specs/docstudio-oss.json. A unit test regenerates and compares, so drift fails in this repo's existing CI tier rather than in a client repo, with no database or extra CI job needed. The generated spec is unchanged from what the external script produced, apart from a root `tags` array — clients had nowhere to read group descriptions from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The committed spec is what published clients are generated from, so the places where it disagreed with the server are places every SDK inherits. - Declare the bearer scheme the endpoints enforce. DRF's unset authentication default was being introspected as a decision and published session and basic auth, which these endpoints do not accept. - Declare the failures a caller has to handle (400/401/403/404/409/429) and describe the 406, so a generated client can branch on them. - Derive the response model from the serializer that builds the response, which drops `workflow_id` -- a field no code path produces. - Stop shadowing `files`: the real field carries the binary annotation, so a change to it now moves the spec. - Drop the MCP operations. MCP speaks JSON-RPC over one POST, so it had no REST shape to describe and was published with guessed responses, no security, and an internal docstring as its description. - Say in the shipped text that a status read is one-shot, and that documents may be supplied as files or presigned URLs. The gate had the same blind spots. It now resolves the real mount instead of comparing against a hand-written copy of it, fails when the generator reports a diagnostic instead of certifying its guess, and asserts the auth scheme and error statuses. Verified by mutation: moving the mount, adding a response field, changing the `files` constraint and dropping the auth annotation each redden the suite, and none of them did before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The deployment endpoints are the public API surface, and the generated clients carry this title into their own documentation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
The spec describes one endpoint today, and five of these tests read it by unpacking a single item or by indexing get and post directly. The first endpoint added turns all five red for no reason, and a GET-only one raises KeyError. They now walk whatever the spec documents. The drift and diagnostics gates are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
|
| Filename | Overview |
|---|---|
| backend/api_v2/openapi_schema.py | Defines the client-facing deployment request, response, authentication, status, and error schema annotations. |
| backend/api_v2/management/commands/generate_docstudio_spec.py | Generates deterministic OpenAPI output and rejects diagnostics, invalid schemas, unsupported prefixes, and drift. |
| backend/api_v2/tests/test_docstudio_spec.py | Checks committed-spec drift and anchors important generated-client contracts to implementation behavior. |
| backend/api_v2/deployment_spec_urls.py | Selects the actual served deployment route mount for schema generation. |
| backend/backend/settings/base.py | Configures drf-spectacular with standardized-error schema generation and deployment-key metadata. |
| backend/pyproject.toml | Replaces drf-yasg with a pinned drf-spectacular production dependency. |
| specs/docstudio-oss.json | Commits the generated execute and status API contract consumed by downstream clients. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
Views[Deployment views and serializers] --> Annotations[OpenAPI annotations]
Routes[Served deployment URL configuration] --> Generator[Schema generation command]
Annotations --> Generator
Generator --> Validation[OpenAPI and diagnostic validation]
Validation --> Spec[specs/docstudio-oss.json]
Spec --> Clients[Published client and generated SDKs]
Generator --> DriftTest[Unit-test drift check]
Spec --> DriftTest
Reviews (4): Last reviewed commit: "Merge branch 'main' into feat/docstudio-..." | Re-trigger Greptile
The `docs` app served a redoc UI over a schema drf-yasg introspected with `public=False`, so an anonymous caller saw nothing and no endpoint carried a `@swagger_auto_schema` annotation. Nothing generates or consumes it. Removes the dependency, the `docs` app and its two mounts. The `/doc/` route goes with it; drf-spectacular serves no UI, only the committed spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx
The annotation serializers exist only to shape the published spec, so they sit in `api_v2/openapi_schema.py` rather than in `serializers.py`, where a request-time import of one would look ordinary. `api_deployment_views.py` keeps a single decorator. `deployment_spec_urls.py` now selects mounts from a tuple, so widening the spec to another endpoint is one entry plus its `@extend_schema`. The generated spec is unchanged: component names are all that reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx
…ponse fields The drift test is the only gate, so its message has to reach the person or agent who then has to regenerate the client and the CLI; it now names both repos, as does the management command's `--check`. Adds one binding the annotation could not express by inheritance: the view returns the execution DTO as a dict rather than through `APIExecutionResponseSerializer`, so a renamed DTO field would reach clients as a field the server never sends. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFSunNN6RKRA1xo6kWkztx
ritwik-g
left a comment
There was a problem hiding this comment.
Standardized review — verdict: REQUEST CHANGES
Critical: 0 · High: 2 · Medium: 8 · Low: 7 · Lenses run: 17/17
Reviewed against a fixed 17-lens rubric (unstract:standard-review, plugin v0.30.1) at 51c7cd45, diffed from merge-base 6b916eac — 15 files, matching GitHub's count.
The engineering here is careful: the guard against spectacular's guessing, the urlconf that selects the real mount rather than restating it, the failure message naming the downstream repos. Two things to say up front:
- The claim that CI fails on drift is real — I suspected it wasn't and checked.
tests/groups.yaml:85-94collectsbackend/**by glob, and this PR's own CI run showsunit-backendat 1009 passed. - A dedicated security pass (the plugin's vendored prompt, separate from the lenses) found no findings at confidence ≥ 8 — an empty result within its scope, not by exclusion.
/doc/was never inWHITELISTED_PATHS, the committed spec has noserversblock and no secrets, and removing the redoc UI net-reduces attack surface.
Both High findings were confirmed by running the real serializers against the real DTOs in a scratch venv, not by reading. Both reach a generated SDK, and this spec is the artifact Zipstack/unstract-python-client#27 is generated from — so fixing them here will move that PR's generated code.
Unanchored findings
These have no line in this diff to attach to.
- [Medium] [Lens 17]
backend/README.md:178-179now points at a route this PR deletes. "access the API documentation that's auto generated at the backend endpoint/api/v1/doc/" — that route goes withdrf-yasgand thedocsapp (public_urls.py:31,public_urls_v2.py:28). The section now describes an endpoint that 404s, on the page a new backend developer reads first. The only survivingdoc/route isadmin/doc/(Django admindocs), a different mount behindADMIN_ENABLED. Suggest replacing the section with the committed spec and the regeneration command. (Verified directly — one automated sweep reported no surviving references and was wrong.) - PR title — this repo states no title convention in writing (no
CLAUDE.md, noCONTRIBUTINGtitle section, template links out only), so there is nothing to judge against.MISCis the right type regardless: nothing here changes what the product does for a user. - PR body drops template sections, including Dependencies Versions — on the one PR that swaps
drf-yasgfor a pinneddrf-spectacular==0.30.0, that's the section this change most needed. - One description claim is unfalsifiable from this repo: "byte-identical to what the external script produced, apart from the root
tagsarray." The external script isn't here;unstract-python-client#27is where that gets checked.
Open questions
- Is a cloud spec generated from this same command? That decides whether the
hitl_*finding wants a flatexclude_fieldsor a conditional. - Was the
{status, message}ErrorResponseshape a deliberate simplification, or written beforedrf-standardized-errorsbecame the handler?
Assumptions
drf-standardized-errorsis the active handler for these endpoints (verified atbackend/middleware/exception.py:48). If a deployment overridesEXCEPTION_HANDLER, the first finding's severity changes.specs/docstudio-oss.jsonis the OSS artifact only, per its filename andDEFAULT_OUT. If it also feeds cloud, thehitl_*finding becomes High.
Lens checklist (17/17)
1 see findings · 2 see findings · 3 see findings · 4 Clean — dedicated security pass, plus: all published paths match served routes, spec has no servers/secrets, /doc/ was never whitelisted · 5 N/A — no migrations or persisted state · 6 N/A — no concurrency primitives · 7 see findings; consumer half checked separately — endpoint, status codes and wire format are untouched, so unstract-cloud/load_test and unstract-docs are unaffected; the spec's consumers are the unmerged #27 and the CLI · 8 N/A — generation is offline, no external calls added · 9 Clean · 10 Clean · 11 Clean — backend/docs/ fully removed, zero residual drf_yasg references, no docker/chart references, no request-time behaviour change · 12 Clean — the only agent-surface change is @extend_schema(exclude=True) on MCPServerView, documentation-only · 13 see findings · 14 Clean — pinned with a stated reason, canonical package, lockfile consistent, transitive set shrinks · 15 see findings · 16 see findings · 17 see Unanchored
Posted as COMMENT, not REQUEST_CHANGES — the merge decision is yours, not the review's.
…sends
Address review on the API deployment OpenAPI spec.
- Adopt drf-standardized-errors' AutoSchema and restate ErrorResponse as the
{type, errors[]} body the project's exception handler actually emits; keep
the hand-built {status, message} bodies for the 406/422/500 branches the
views build themselves.
- Mark `error`, `status_api` and `file_execution_id` nullable, drop the
never-emitted FileResult.metrics and document `extracted_text`.
- Declare 413/502/504 on execute, drop the unreachable 409, and scope 429 to
execute; reword the 403/404 descriptions so they do not assert one cause.
- Say in the status description that a pending poll answers 422.
- Validate the rendered spec as OpenAPI and refuse a non-default deployment
path prefix, so a typo in a hand-written fragment fails generation.
- Anchor the spec's file result, status body, upload encoding and error shape
to the code that produces them, and exercise the command's own branches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjRpEocCvnxGUSkFivgnhk
|
Thanks — this was a genuinely useful review; the two High findings were real and both would have shipped into the generated client. Everything is addressed in
PR body — restored to the template, including Dependencies Versions: Open question 1 — is a cloud spec generated from this command? No. This is the single artifact, and the PyPI client generated from it serves cloud users, so the Open question 2 — was Assumption you flagged — Also added while in here, both prompted by your findings rather than requested: generation now rejects a spec whose paths carry a non-default One thing deliberately not done: Backend unit tier: |
Relock backend with uv 0.6.14 (the version the lock automation pins) so the merge keeps main's lockfile revision instead of rewriting every entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ
|
Unstract test resultsPer-group results
Critical paths
|
ritwik-g
left a comment
There was a problem hiding this comment.
FOLLOWUP review — verdict: APPROVE
Prior findings: 17 · RESOLVED 16 · WAIVED 1 (with a documented rationale) · NOT RESOLVED 0
New findings: none. Scope change: NO.
Verified against 879ec5ab from the current merge-base e452f778. The 12 commits since my review are 11 from a main merge plus one fix commit, eddd4b74. Status here is what the code shows, not what the replies claim.
The two blockers, verified against live behaviour rather than against the tests
ErrorResponse is now {type: ErrorType, errors: [ErrorDetail]}, both required, bound to 400/401/403/404 on both operations and 413/429/502/504 on execute. Rather than trust the new test, I replayed the real drf_logging_exc_handler over six real exceptions — APIException, ValidationError, NotAuthenticated, PermissionDenied, NotFound, Throttled — and the emitted keys match the published component exactly, with all three emitted type values inside the published enum. Leaving code free-form was the right call: it varies (error, invalid, not_found, not_authenticated, permission_denied, throttled), so an enum would have been violated.
I also checked the risk this fix could have introduced — that the hand-built bodies got the new shape by mistake. They didn't: 406 → AcknowledgedResponse{status, message}, status 422/500 → StatusResponse{status, message}, execute 422/500 → ExecuteResponse{message} with no status key, each matching its Response(...) call site.
error / status_api are nullable: true in the committed artifact and both removed from required (which is now just execution_id, execution_status). Confirmed in the regenerated spec, not only in the annotation source.
The new invariants are real guards
Five were each broken and observed to fail: removing @extend_schema_field(OpenApiTypes.BINARY), dropping allow_null from result, re-adding the phantom metrics, and moving 429 back onto status all now fail after regeneration. test_the_documented_error_body_is_the_one_the_handler_sends genuinely replays the live handler rather than restating the implementation. Your claim that "dropping either decorator this PR was written for now fails regardless of regeneration" holds as tested.
The patern typo repro from my finding now produces SpecGenerationFailed: … is not valid under any of the given schemas, and API_DEPLOYMENT_PATH_PREFIX=private is rejected by output assertion — a better lever than the override_settings I suggested, since base_urls bakes the prefix in at import.
The one waiver, and two notes
The HITL fields stay in the OSS artifact because one spec serves both OSS and cloud — excluding them would make the enterprise feature unreachable from the official client. That answers the open question I raised and is a sound trade; the 400 is now a documented precondition via help_text published as the client-facing description, rather than a surprise.
Two things carried forward, neither blocking and neither introduced here: deployment_helper.py:654-658 still passes an upstream document-store status verbatim, so a store answering e.g. 410 still surfaces undeclared (body shape is fine — it's an APIException); and middleware/exception.py:38-47 has an SdkError branch emitting a third shape, which I could not demonstrate is reachable on these two operations.
Suite green: 18 in the spec tests, 260 across api_v2 middleware mcp_server, both drift tests passing so the artifact regenerates byte-identically. CI green including e2e. Nice work on backend/README.md — that closes the stale /api/v1/doc/ pointer cleanly.



What
The OpenAPI description of the API deployment
execute/statusendpoints is now generated and committed in this repository, and a test fails when it drifts from the code.drf-yasg, which generated nothing anyone read, is removed.Why
The published Python client and the SDK generated for it are built from that description. It was produced by a script living outside this repository, so a route, serializer or response change here could silently invalidate it — the breakage would surface later, in a client repo, against a spec nobody in the PR could see.
How
drf-spectacularadded as a backend dependency;DEFAULT_SCHEMA_CLASSandSPECTACULAR_SETTINGSset insettings/base.py. Both are read only during schema generation — no request-time behaviour changes. The schema class isdrf_standardized_errors.openapi.AutoSchema, matching the exception handler these views actually run behind.api_v2/openapi_schema.pyholds the schema annotation. The introspected schema is wrong in ways that matter to a generated client: a bareFileFieldmaps toformat: uri(right for output, wrong for a multipart upload),result: nullwhile an execution is pending crashes a generated deserialiser withoutallow_null, andoperation_id/tagsdecide the command names and module paths clients expose. It is a module of its own rather than part ofserializers.py, because none of these serializers parses a request or builds a response.api_v2/deployment_spec_urls.py— a urlconf mirroring the real mount. Generating against the included sub-urlconf drops the prefix and produces paths the server does not serve.SPEC_URLCONFSis a tuple: widening the spec to another endpoint is one entry plus its@extend_schema.manage.py generate_docstudio_specwritesspecs/docstudio-oss.jsonwith sorted keys, so the committed file is a usable drift signal.--checkfails instead of writing. Generation refuses to publish on any generator diagnostic, on a spec that is not valid OpenAPI, or on paths carrying a non-defaultAPI_DEPLOYMENT_PATH_PREFIX.api_v2/tests/test_docstudio_spec.pyregenerates and compares, and — because drift alone passes on a spec that is uniformly wrong — anchors the upload encoding, the nullable result, the documented file-result fields and the error body to the code that produces them. It runs in the existingunit-backendgroup — no database, no new CI job. Its failure message names the repos that regenerate from the spec, since propagating the change downstream is the part CI cannot do.drf-yasgand thedocsapp removed. The redoc UI they served was built withpublic=Falseand no endpoint carried a@swagger_auto_schema, so an anonymous caller saw an empty schema. Nothing generated or consumed it.backend/README.mdnow points at the committed spec and the regeneration command instead of the deleted/api/v1/doc/route.Regenerating
from
backend/, then commit. The test says so in its failure message, along with the client and CLI repos that regenerate from the result.Can this PR break any existing features. If yes, please list possible items. If no, please explain why.
The
/doc/route is gone withdrf-yasg, and drf-spectacular serves no UI in its place — the spec is a committed file, not an endpoint. Nothing else changes at request time: the settings added are read only during schema generation, and no route or response is modified. The only serializer change ishelp_texton the twohitl_*fields, which is published documentation and does not affect validation. The annotation is metadata on the view, not behaviour.Database Migrations
None.
Env Config
None.
API_DEPLOYMENT_PATH_PREFIXis read as before; generation now refuses to write a spec when it is overridden away fromdeployment, so a developer's local override cannot reach the committed artifact.Relevant Docs
backend/README.md— API Docs section, replaced with the committed spec's location and the regeneration command.Related Issues or PRs
Dependencies Versions
drf-yasg1.21.15drf-spectacular0.30.0(pinned exactly: the generated artifact is committed, so a minor bump would show as spec drift rather than as a dependency change)drf-standardized-errorsis unchanged at>=0.12.6; it was already a dependency and is now also the source of the schema class. Transitively,drf-yasgrequiredinflection,packaging,pytz,pyyamlanduritemplate;drf-spectacularrequiresinflection,jsonschema,pyyamlanduritemplate. All of them exceptjsonschemawere already in the lock through other packages.Notes on Testing
676 passed, 1 skipped(plus 340 subtests) in the backend unit tier this lands in, including the 18 spec tests. Verified by hand that the two new generation gates fire: a typo in a hand-written schema fragment fails OpenAPI validation, andAPI_DEPLOYMENT_PATH_PREFIX=privatefails the prefix check.The generated spec is byte-identical to what the external script produced, apart from the root
tagsarray — clients had nowhere to read group descriptions from, which is the one deliberate addition — and the review fixes in the latest commit (error body shape, nullability, the statuses each operation can actually return).Screenshots
n/a — no user-facing surface.
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KjRpEocCvnxGUSkFivgnhk