feat(server): project and schema endpoints — the first protected routes, and the conventions (#27) - #99
Merged
Conversation
…es, and the conventions (#27) Nine operations over ProjectService and SchemaService, and the first change that moves openapi.json: FastAPI collects security definitions per route, so components.securitySchemes enters the contract with the first route that depends on the bearer scheme. #25 predicted this and pinned the exact shape against a probe app; the test asserting the scheme was absent is inverted here. Conventions, decided once and documented in docs/api.md: - Collections answer {"items": [...], "total": n}, never a bare array — an array cannot grow a field without breaking every client. No paging parameters: the kernel has no windowed read, and a limit implemented by slicing a full read is a window that lies about its cost. `total` already means "matching the query", so #29 adds limit/offset without a breaking change. - Gates are query parameters (?confirm=true, ?allow_destructive=true), so recovering from a 409 is the identical request plus one parameter. No route pre-checks one; the kernel's refusal carries the code. - Ids are UUIDs, with a schema version the deliberate integer exception. - Request bodies forbid unknown fields; PATCH /projects/{id} renames only, because the SDK has no way to update a description. Wire models live in server/models.py and are not domain models. A route returning a kernel class would ship its RST docstring verbatim into the contract, publish domain-internal aliases as named components, and make always-present response fields optional through their defaults. Page[T] is generic with concrete subclasses, because a parametrised generic emits `Page_ProjectOut_` as a component name. LabelClassBody/AttributeBody build their domain object inside a model_validator rather than in the route body. A pydantic ValidationError raised in a handler is neither a domain error nor a request-validation failure, so it reaches the catch-all and answers 500 to a plainly malformed payload; converting during parsing makes it a 422 carrying the domain's own message and the offending field's loc. operationIds are now the handler's own name rather than FastAPI's path-derived default, since an operationId becomes a method name in the generated client and moving a path should not rename it. No migration (FORMAT_VERSION stays 11), no VERSION change, no kernel change, no new dependency. 1116 tests, up from 1056. Closes #27
This was referenced Jul 28, 2026
JArmandoAnaya
added a commit
that referenced
this pull request
Aug 21, 2026
…es, and the conventions (#27) (#99) Nine operations over ProjectService and SchemaService, and the first change that moves openapi.json: FastAPI collects security definitions per route, so components.securitySchemes enters the contract with the first route that depends on the bearer scheme. #25 predicted this and pinned the exact shape against a probe app; the test asserting the scheme was absent is inverted here. Conventions, decided once and documented in docs/api.md: - Collections answer {"items": [...], "total": n}, never a bare array — an array cannot grow a field without breaking every client. No paging parameters: the kernel has no windowed read, and a limit implemented by slicing a full read is a window that lies about its cost. `total` already means "matching the query", so #29 adds limit/offset without a breaking change. - Gates are query parameters (?confirm=true, ?allow_destructive=true), so recovering from a 409 is the identical request plus one parameter. No route pre-checks one; the kernel's refusal carries the code. - Ids are UUIDs, with a schema version the deliberate integer exception. - Request bodies forbid unknown fields; PATCH /projects/{id} renames only, because the SDK has no way to update a description. Wire models live in server/models.py and are not domain models. A route returning a kernel class would ship its RST docstring verbatim into the contract, publish domain-internal aliases as named components, and make always-present response fields optional through their defaults. Page[T] is generic with concrete subclasses, because a parametrised generic emits `Page_ProjectOut_` as a component name. LabelClassBody/AttributeBody build their domain object inside a model_validator rather than in the route body. A pydantic ValidationError raised in a handler is neither a domain error nor a request-validation failure, so it reaches the catch-all and answers 500 to a plainly malformed payload; converting during parsing makes it a 422 carrying the domain's own message and the offending field's loc. operationIds are now the handler's own name rather than FastAPI's path-derived default, since an operationId becomes a method name in the generated client and moving a path should not rename it. No migration (FORMAT_VERSION stays 11), no VERSION change, no kernel change, no new dependency. 1116 tests, up from 1056. Closes #27
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Closes #27. The first task that registers a route, and the first that moves
openapi.json.The nine operations
POST /projectsPROJECT_NAME_TAKENGET /projectsGET /projects/{project_id}PATCH /projects/{project_id}DELETE /projects/{project_id}?confirm=truePOST /projects/{project_id}/schema/versions?allow_destructive=trueGET /projects/{project_id}/schema/versionsGET /projects/{project_id}/schema/versions/{version}GET /projects/{project_id}/schema401 comes from
protected_router(); 422/500/503 from the app-levelresponses=. No route lists them.Why
openapi.jsonmoves now and not in #25FastAPI collects security definitions per route, from each route's dependency tree, so a module-level
HTTPBeareremitted nothing while zero routes depended on it. #25 found this, lefttest_the_committed_contract_has_no_security_scheme_yetrecording it, and pinned the exact scheme shape against a probe app. That test is inverted here and the pinned shape is now asserted against the shipped contract./health'soperationIdis the only pre-existing line the export touched.Conventions this decides (documented in
docs/api.md){"items": [...], "total": n}. A bare array cannot grow a field without breaking every client; and the kernel has no windowed read, so alimitimplemented by slicing a full read would be a window that lies about its cost.totalalready means matching the query, so server: batch/job endpoints — approve, partition, "next N pending assets", annotation submission, progress (the third-party-app contract) #29 addslimit/offsetbeside it without a breaking change.?confirm=true,?allow_destructive=true— so recovering from a 409 is the identical request plus one parameter. No route pre-checks one; the kernel's refusal is what carriesCONFIRMATION_REQUIRED/DESTRUCTIVE_SCHEMA_CHANGE.PATCHrenames only, becauseProjectServicehas no description update — the API does not grow a field the SDK cannot honour.Two things worth a reviewer's eye
Wire models are not domain models, for three verified reasons: a model docstring ships verbatim into the contract; a PEP 695
typealias emits a named component (this bit insideserver/models.pyitself —AttributeKindhad to be inlined); and a field with a default is emitted as optional even when a response always carries it.LabelClassBody/AttributeBodybuild their domain object inside amodel_validator, not in the route body. Apydantic.ValidationErrorraised in a handler is neither aVisionSetErrornor aRequestValidationError, so it reaches the catch-all and answers 500 to a plainly malformed payload. Converting during parsing makes it a 422 carrying the domain's own message — and pydantic merges the nested error's path, solocreaches["body","classes",0,"name"].test_a_blank_class_name_is_422_validation_error_not_500pins it.Ledger
No migration (
FORMAT_VERSIONstays 11), noVERSIONchange, no kernel change, no new dependency, no import-linter change.openapi.json2.8 KB → 34 KB; the 200 KB tracked-file cap is worth watching around #29/#30.MCP parity (for #35): these capabilities imply
create_project,list_projects,get_project,rename_project,delete_project,create_schema_version,get_schema,list_schema_versions.Checks
Also exercised against a real uvicorn server over a real workspace: 401 without a token, 201/200 on create and list, 409 on the narrowing gate, 201 on the identical body plus
?allow_destructive=true, 409 then 204 on delete.