Skip to content

feat(server): project and schema endpoints — the first protected routes, and the conventions (#27) - #99

Merged
JArmandoAnaya merged 1 commit into
mainfrom
feat/27-project-schema-endpoints
Jul 28, 2026
Merged

feat(server): project and schema endpoints — the first protected routes, and the conventions (#27)#99
JArmandoAnaya merged 1 commit into
mainfrom
feat/27-project-schema-endpoints

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #27. The first task that registers a route, and the first that moves openapi.json.

The nine operations

Method + path Status Notes
POST /projects 201 409 PROJECT_NAME_TAKEN
GET /projects 200 envelope
GET /projects/{project_id} 200
PATCH /projects/{project_id} 200 renames only
DELETE /projects/{project_id} 204 ?confirm=true
POST /projects/{project_id}/schema/versions 201 ?allow_destructive=true
GET /projects/{project_id}/schema/versions 200 envelope
GET /projects/{project_id}/schema/versions/{version} 200
GET /projects/{project_id}/schema 200 the version in force

401 comes from protected_router(); 422/500/503 from the app-level responses=. No route lists them.

Why openapi.json moves now and not in #25

FastAPI collects security definitions per route, from each route's dependency tree, so a module-level HTTPBearer emitted nothing while zero routes depended on it. #25 found this, left test_the_committed_contract_has_no_security_scheme_yet recording 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's operationId is the only pre-existing line the export touched.

Conventions this decides (documented in docs/api.md)

  • List envelope, no paging parameters. {"items": [...], "total": n}. A bare array cannot grow a field without breaking every client; and the kernel has no windowed read, so a limit implemented by slicing a full read would be a window that lies about its cost. total already means matching the query, so server: batch/job endpoints — approve, partition, "next N pending assets", annotation submission, progress (the third-party-app contract) #29 adds limit/offset beside it 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 is what carries CONFIRMATION_REQUIRED / DESTRUCTIVE_SCHEMA_CHANGE.
  • Ids are UUIDs, with a schema version the deliberate integer exception (it is the handle the domain itself uses). A malformed UUID is 422, not 404.
  • Bodies forbid unknown fields. PATCH renames only, because ProjectService has 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 type alias emits a named component (this bit inside server/models.py itself — AttributeKind had to be inlined); and a field with a default is emitted as optional even when a response always carries it.

LabelClassBody/AttributeBody build their domain object inside a model_validator, not in the route body. A pydantic.ValidationError raised in a handler is neither a VisionSetError nor a RequestValidationError, 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, so loc reaches ["body","classes",0,"name"]. test_a_blank_class_name_is_422_validation_error_not_500 pins it.

Ledger

No migration (FORMAT_VERSION stays 11), no VERSION change, no kernel change, no new dependency, no import-linter change. openapi.json 2.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

ruff check .            All checks passed!
ruff format --check .   146 files already formatted
mypy src/visionset/kernel   Success: no issues found in 54 source files
mypy src/visionset          Success: no issues found in 73 source files
lint-imports            Contracts: 2 kept, 0 broken.
pytest                  1116 passed  (up from 1056)

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.

…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
@JArmandoAnaya
JArmandoAnaya merged commit 6a1e750 into main Jul 28, 2026
3 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/27-project-schema-endpoints branch July 28, 2026 03:03
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

server: project and schema endpoints (CRUD + versioning)

1 participant