diff --git a/docs/README.md b/docs/README.md index ddfcf673..8bd5af4a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,5 +20,5 @@ contracts (kernel purity, headless annotator) are described there and enforced i | [events.md](events.md) | Domain events: subscribing by type, why emission follows the commit, at-most-once delivery, and what an isolated subscriber failure does | | [persistence.md](persistence.md) | The metadata store: repositories, unit of work, table layout, migrations and `format_version` | | [examples.md](examples.md) | The two runnable examples: the whole cycle in one pass, ingest on its own, and what each is built to demonstrate | -| [api.md](api.md) | The REST surface: the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying | +| [api.md](api.md) | The REST surface: the conventions every endpoint follows (paths, UUID ids, the list envelope, gates as query parameters), the one error body, why clients branch on `code` and not on the status, what decides 404 / 409 / 422, what a 5xx does and does not tell you, and which codes are worth retrying | | [auth.md](auth.md) | Who may call it: per-workspace API tokens, why only a digest is stored, why every refusal is one identical 401, immediate revocation, the `visionset token` commands, and how a protected route is built | diff --git a/docs/api.md b/docs/api.md index cab819ef..80fde049 100644 --- a/docs/api.md +++ b/docs/api.md @@ -22,6 +22,65 @@ workspace named by `VISIONSET_WORKSPACE`, and one pointed at something else answ `NOT_A_WORKSPACE`. See [auth.md](auth.md) for the whole picture, including how to build a protected route. +## Conventions + +Decided once, by the project and schema endpoints, and inherited by every endpoint after them. + +**Paths.** Plural collection nouns, and a sub-resource nested under whatever owns it: + +``` +POST /projects +GET /projects +GET /projects/{project_id} +PATCH /projects/{project_id} +DELETE /projects/{project_id} +GET /projects/{project_id}/schema the version in force +POST /projects/{project_id}/schema/versions +GET /projects/{project_id}/schema/versions +GET /projects/{project_id}/schema/versions/{version} +``` + +The active schema is the collection's **parent**, not a member of it, because "in force" is a +property of the schema rather than a version number a client could guess. + +**Ids are UUIDs**, canonical hyphenated form, in the path. One deliberate exception: a **schema +version is an integer 1..N**, because that is the handle the domain itself uses — an annotation +records `schema_version`, and a batch pins one at approval. A malformed UUID never reaches a +service, so it is a **422 `VALIDATION_ERROR`** and not a 404: the request could not have named +anything. + +**Collections answer with an envelope**, never a bare array: + +```json +{ "items": [ { "id": "…", "name": "road-signs", "description": null } ], "total": 1 } +``` + +An array cannot grow a field without breaking every client that parsed it. There are no paging +parameters yet — the kernel has no windowed read, and a `limit` implemented by slicing a full +read would be a window that lies about its cost. `total` already means *matching the query* +rather than *in this page*, so `limit` and `offset` can be added beside it later without a +breaking change. An empty collection is `{"items": [], "total": 0}` and a 200, never a 404. + +**Gates are query parameters; bodies carry content.** Destroying data needs `?confirm=true`, and +narrowing a schema needs `?allow_destructive=true`. Neither is a body field, so recovering from +the 409 is resending the *identical* request with one extra parameter. The route does not +pre-check either one: the flag goes to the SDK and the SDK's refusal is what carries +`CONFIRMATION_REQUIRED` or `DESTRUCTIVE_SCHEMA_CHANGE`. + +**Statuses.** 201 with the created resource in the body; 200 for a read or an update; 204 with an +empty body for a delete. + +**Request bodies forbid unknown fields.** A misspelled key is a 422 `VALIDATION_ERROR`, never a +silently ignored one — a typo that looked like it worked is worse than a refusal. + +**Only what a service can honour is on the wire.** `PATCH /projects/{id}` takes a name and nothing +else, because the SDK has no way to update a description. The API does not grow a field it would +have to fake. + +**Response shapes are wire models, not domain models.** They live in `server/models.py` and are +written out field by field, so a field reaches a client because somebody published it and never +because somebody added it to an entity. + ## The error body Every failure — a domain refusal, a missing route, a malformed payload, an unhandled bug — @@ -61,6 +120,11 @@ workspace. Cross-scope references read as *missing*, never as *forbidden*: an as project is a 404, not a 403. `NO_SPLIT_RECIPE` is here too — a release published without a recipe has no split sub-resource, and never will, because a release is immutable. +One status covers more than one situation, which is the whole reason to branch on `code`: +`GET /projects/{id}/schema` answers 404 `PROJECT_NOT_FOUND` when the project is unknown and 404 +`SCHEMA_NOT_FOUND` when the project is real and simply has no schema yet. Only the code separates +"you named nothing" from "there is nothing to name". + **409 — the request is well-formed; the resource's state refuses it.** The remedy is to change that state and resubmit the identical request: finish the outstanding jobs, approve the batch, promote something into the dataset, pass `confirm=true`. Name and tag collisions are here, as is diff --git a/docs/projects.md b/docs/projects.md index f048cc97..42f5fccc 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -21,6 +21,11 @@ with WorkspaceService.open("./road-signs") as workspace: projects.delete(project.id, confirm=True) ``` +**Over HTTP:** `POST`/`GET /projects`, `GET`/`PATCH`/`DELETE /projects/{project_id}`, where +`PATCH` renames and `DELETE` needs `?confirm=true`. The semantics below are the same ones — the +REST surface is a thin client of this service. See [api.md](api.md) for the conventions and +`openapi.json` for the exact shapes. + ## The project–dataset relation is 1:1 The dataset **is** the curated state of the project, not a thing kept beside it. Three diff --git a/docs/schemas.md b/docs/schemas.md index 0f42b4ea..887ebc9d 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -35,6 +35,12 @@ with WorkspaceService.open("./road-signs") as workspace: schemas.allowed_geometries(project.id) # {BBOX, POLYGON} ``` +**Over HTTP:** `POST`/`GET /projects/{project_id}/schema/versions`, +`GET /projects/{project_id}/schema/versions/{version}`, and `GET /projects/{project_id}/schema` +for the version in force. Narrowing needs `?allow_destructive=true`, exactly as +`allow_destructive=` does here. `preview`, `compare` and `allowed_geometries` have no route yet — +they will get one when a surface needs them. See [api.md](api.md). + ## Versions are 1..N, and none of them changes The next version is one past the highest stored, so the numbers have no gaps and no reuse. diff --git a/openapi.json b/openapi.json index d9552cd6..fee94a3a 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,68 @@ { "components": { "schemas": { + "AttributeBody": { + "additionalProperties": false, + "description": "A typed attribute on a label class.", + "properties": { + "default": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Default" + }, + "kind": { + "enum": [ + "string", + "number", + "boolean", + "select" + ], + "title": "Kind", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "options": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Options" + }, + "required": { + "default": false, + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "name", + "kind" + ], + "title": "AttributeBody", + "type": "object" + }, "ErrorBody": { "description": "The one error shape this API emits, at every status.", "properties": { @@ -34,6 +96,227 @@ ], "title": "ErrorBody", "type": "object" + }, + "GeometryType": { + "description": "Every geometry the domain can address.\n\n3D values exist today even though unimplemented: the domain never assumes\n\"image\" anywhere \u2014 that is the Physical AI roadmap encoded as a type.", + "enum": [ + "bbox", + "polygon", + "mask", + "polyline", + "keypoints", + "cuboid_3d", + "polyline_3d", + "classification_tag" + ], + "title": "GeometryType", + "type": "string" + }, + "LabelClassBody": { + "additionalProperties": false, + "description": "One labelable class, bound to a geometry.", + "properties": { + "attributes": { + "default": [], + "items": { + "$ref": "#/components/schemas/AttributeBody" + }, + "title": "Attributes", + "type": "array" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Color" + }, + "geometry": { + "$ref": "#/components/schemas/GeometryType" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "geometry" + ], + "title": "LabelClassBody", + "type": "object" + }, + "ProjectCreate": { + "additionalProperties": false, + "description": "What creating a project needs.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ProjectCreate", + "type": "object" + }, + "ProjectOut": { + "description": "A project.", + "properties": { + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "name", + "description" + ], + "title": "ProjectOut", + "type": "object" + }, + "ProjectPage": { + "description": "A page of projects.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/ProjectOut" + }, + "title": "Items", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "ProjectPage", + "type": "object" + }, + "ProjectRename": { + "additionalProperties": false, + "description": "The one field of a project that moves.", + "properties": { + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "ProjectRename", + "type": "object" + }, + "SchemaVersionCreate": { + "additionalProperties": false, + "description": "The whole proposed version. There is no partial edit of a schema.", + "properties": { + "classes": { + "default": [], + "items": { + "$ref": "#/components/schemas/LabelClassBody" + }, + "title": "Classes", + "type": "array" + } + }, + "title": "SchemaVersionCreate", + "type": "object" + }, + "SchemaVersionOut": { + "description": "One version of a project's labeling contract.", + "properties": { + "classes": { + "items": { + "$ref": "#/components/schemas/LabelClassBody" + }, + "title": "Classes", + "type": "array" + }, + "project_id": { + "format": "uuid", + "title": "Project Id", + "type": "string" + }, + "version": { + "title": "Version", + "type": "integer" + } + }, + "required": [ + "project_id", + "version", + "classes" + ], + "title": "SchemaVersionOut", + "type": "object" + }, + "SchemaVersionPage": { + "description": "A page of schema versions.", + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/SchemaVersionOut" + }, + "title": "Items", + "type": "array" + }, + "total": { + "title": "Total", + "type": "integer" + } + }, + "required": [ + "items", + "total" + ], + "title": "SchemaVersionPage", + "type": "object" + } + }, + "securitySchemes": { + "HTTPBearer": { + "description": "A workspace API token, created with `visionset token create`. Sent as `Authorization: Bearer `.", + "scheme": "bearer", + "type": "http" } } }, @@ -47,7 +330,7 @@ "/health": { "get": { "description": "Liveness probe. Public \u2014 no token required.", - "operationId": "health_health_get", + "operationId": "health", "responses": { "200": { "content": { @@ -56,7 +339,7 @@ "additionalProperties": { "type": "string" }, - "title": "Response Health Health Get", + "title": "Response Health", "type": "object" } } @@ -96,6 +379,854 @@ }, "summary": "Health" } + }, + "/projects": { + "get": { + "description": "Every project in this workspace, in the order they were created.", + "operationId": "list_projects", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Projects", + "tags": [ + "projects" + ] + }, + "post": { + "description": "Add a project and its empty dataset, both or neither.", + "operationId": "create_project", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Project", + "tags": [ + "projects" + ] + } + }, + "/projects/{project_id}": { + "delete": { + "description": "Remove a project and everything under it.\n\nMetadata only: content blobs are shared and are never deleted. Without\n`confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys nothing.", + "operationId": "delete_project", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "description": "Required to destroy data. The kernel refuses the request without it.", + "in": "query", + "name": "confirm", + "required": false, + "schema": { + "default": false, + "description": "Required to destroy data. The kernel refuses the request without it.", + "title": "Confirm", + "type": "boolean" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Delete Project", + "tags": [ + "projects" + ] + }, + "get": { + "description": "The project with that id.", + "operationId": "get_project", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Project", + "tags": [ + "projects" + ] + }, + "patch": { + "description": "Rename a project, and its dataset with it. The only field that moves.", + "operationId": "rename_project", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Rename Project", + "tags": [ + "projects" + ] + } + }, + "/projects/{project_id}/schema": { + "get": { + "description": "The version in force: the highest one.\n\nA project that has no schema yet answers 404 `SCHEMA_NOT_FOUND`, which is a\ndifferent code from the 404 `PROJECT_NOT_FOUND` an unknown project gets.\nSame status, two situations.", + "operationId": "get_active_schema", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Active Schema", + "tags": [ + "schemas" + ] + } + }, + "/projects/{project_id}/schema/versions": { + "get": { + "description": "Every version, oldest first. An empty page is the ordinary starting state.", + "operationId": "list_schema_versions", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionPage" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "List Schema Versions", + "tags": [ + "schemas" + ] + }, + "post": { + "description": "Append the next version of the project's schema.\n\nThe body is the whole proposed version; versions are never edited in place.\n\nRemoving a class or an attribute answers 409 `DESTRUCTIVE_SCHEMA_CHANGE`\nuntil `allow_destructive=true` says so deliberately. If annotations already\nexist under an affected class it answers 409 `SCHEMA_CHANGE_WOULD_ORPHAN`\ninstead, and **no flag overrides that one** \u2014 which is why a client branches\non `code` and not on the status.", + "operationId": "create_schema_version", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "description": "Required when the new version narrows the labeling contract.", + "in": "query", + "name": "allow_destructive", + "required": false, + "schema": { + "default": false, + "description": "Required when the new version narrows the labeling contract.", + "title": "Allow Destructive", + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The resource's state refuses this request" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Create Schema Version", + "tags": [ + "schemas" + ] + } + }, + "/projects/{project_id}/schema/versions/{version}": { + "get": { + "description": "One version of a project's schema.", + "operationId": "get_schema_version", + "parameters": [ + { + "in": "path", + "name": "project_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Project Id", + "type": "string" + } + }, + { + "description": "A schema version, 1..N.", + "in": "path", + "name": "version", + "required": true, + "schema": { + "description": "A schema version, 1..N.", + "minimum": 1, + "title": "Version", + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchemaVersionOut" + } + } + }, + "description": "Successful Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Missing or invalid bearer token" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "No such resource" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The request payload is not processable" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "Unhandled server error, with an incident id" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorBody" + } + } + }, + "description": "The workspace is busy; retry after the header says" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Get Schema Version", + "tags": [ + "schemas" + ] + } } } } diff --git a/src/visionset/server/errors.py b/src/visionset/server/errors.py index 7ef56b04..73ecfcfc 100644 --- a/src/visionset/server/errors.py +++ b/src/visionset/server/errors.py @@ -289,6 +289,18 @@ class ErrorRule: """ +def documented(*statuses: int) -> dict[int | str, dict[str, Any]]: + """The responses to declare on a route, for the statuses it can produce. + + The sentence in :data:`ERROR_RESPONSES`' docstring, made executable — a route + spreads exactly the statuses it can actually answer with, so a 404 in the + contract means some caller really can name a thing that is not there. 401 + arrives from ``protected_router()`` and 422/500/503 from the app, so neither + belongs in a call to this. + """ + return {status: ERROR_RESPONSES[status] for status in statuses} + + def rule_for(exc: BaseException) -> ErrorRule | None: """The rule for ``exc``, or ``None`` if nothing in the table covers it. diff --git a/src/visionset/server/main.py b/src/visionset/server/main.py index 4d4fa2b1..3a1cbdb4 100644 --- a/src/visionset/server/main.py +++ b/src/visionset/server/main.py @@ -6,16 +6,32 @@ from contextlib import asynccontextmanager from fastapi import APIRouter, FastAPI +from fastapi.routing import APIRoute from visionset import __version__ from visionset.server.dependencies import WorkspaceHandle from visionset.server.errors import UNIVERSAL_ERROR_RESPONSES, install_error_handlers +from visionset.server.routes import ROUTERS DESCRIPTION = "REST surface of the VisionSet SDK. The committed openapi.json is the contract." router = APIRouter() +def operation_id(route: APIRoute) -> str: + """The handler's own name, as the operation id. + + FastAPI's default is derived from the path + (``get_schema_version_projects__project_id__schema_versions__version__get``), + and an operation id becomes a *method name* in a generated client — so under + the default, moving a path silently renames somebody's client method. The + handler name is the stable thing, and it is what a reader of the spec would + guess. ``tests/server/test_openapi_contract.py`` asserts no two collide, + since uniqueness is no longer structural. + """ + return route.name + + @router.get("/health") async def health() -> dict[str, str]: """Liveness probe. Public — no token required.""" @@ -68,10 +84,13 @@ def create_app() -> FastAPI: description=DESCRIPTION, responses=UNIVERSAL_ERROR_RESPONSES, lifespan=_lifespan, + generate_unique_id_function=operation_id, ) app.state.workspace_handle = WorkspaceHandle() install_error_handlers(app) app.include_router(router) + for resource in ROUTERS: + app.include_router(resource) return app diff --git a/src/visionset/server/models.py b/src/visionset/server/models.py new file mode 100644 index 00000000..d50a24e7 --- /dev/null +++ b/src/visionset/server/models.py @@ -0,0 +1,258 @@ +# usage: from visionset.server.models import ProjectOut, ProjectPage +"""The shapes this API speaks: every request body, every response body. + +One module rather than one per router, because these are the contract's *nouns* +— the things that become TypeScript types in ``frontend/ui-core`` — and one file +answers "what does this API speak?" without opening ten routers. +``LabelClassBody`` is already shared by two routers today. When this outgrows one +screen per resource it becomes ``server/models/`` with a module per resource, and +the import path ``visionset.server.models`` does not change. + +**Every class docstring in here is one short, plain sentence**, for the reason +``ErrorBody``'s already says: a model's docstring is copied verbatim into +``openapi.json``, so RST markup ships as literal backticks to every consumer and +a paragraph of design reasoning ships as API documentation. The reasoning lives +in comments, which do not travel. + +**A wire model is not a domain model, and the separation is deliberate.** Three +things go wrong when a route returns a ``kernel.domain`` class directly: + +1. **Docstrings ship verbatim** — see above. ``AnnotationSchema``'s own are + written for a reader of the kernel, and rewriting them for a server reason is + the tail wagging the dog. +2. **Domain-internal aliases become public components.** A PEP 695 + ``type X = ...`` alias emits a *named* schema into ``components``; the domain's + ``AttributeValue`` would land there. Spelling a union inline emits an + anonymous one on the field instead. This bites inside this module too, which + is why ``AttributeBody.kind`` carries no alias. +3. **Defaults make response fields optional.** ``AnnotationSchema.classes`` has a + default, so a generated client would type it ``classes?: LabelClass[]`` for a + field that is always present. A response model with no defaults says what it + means. + +So a field reaches a client because somebody put it here, never because somebody +added it to an entity. That is the ``visionset token list`` rule — name the +columns one at a time — applied to the wire. +""" + +from __future__ import annotations + +from typing import Annotated, Literal, Self +from uuid import UUID + +from fastapi import Query +from pydantic import BaseModel, ConfigDict, model_validator + +from visionset.kernel.domain import ( + AnnotationSchema, + Attribute, + GeometryType, + LabelClass, + Project, +) + +# A gate is a query parameter and never a body field, so a client that gets a 409 +# resubmits the *identical* request with one extra parameter — which is what +# ``docs/api.md``'s retry table promises. The route passes it straight to the +# service: the refusal belongs to the kernel, and a pre-check here would be a +# second copy of a rule that already has an owner. +ConfirmQuery = Annotated[ + bool, + Query(description="Required to destroy data. The kernel refuses the request without it."), +] + +DestructiveQuery = Annotated[ + bool, + Query(description="Required when the new version narrows the labeling contract."), +] + + +# Every collection answers with this envelope rather than a bare JSON array, +# because an array cannot grow a field without breaking every client that parsed +# it. There are no paging parameters yet — 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" rather than "in this page", +# so #29 adds ``limit``/``offset`` beside it without a breaking change. +# +# This class is never a response model itself; a concrete subclass is. FastAPI +# names a parametrised generic ``Page_ProjectOut_`` in the spec, which is not a +# name to hand a client generator. The PEP 695 syntax is required rather than +# preferred: ruff's UP046 rejects ``Generic[T]`` as a base. +class Page[T](BaseModel): + """One page of a collection.""" + + items: list[T] + total: int + + +# --- projects ---------------------------------------------------------------- + + +# ``workspace_id`` is deliberately absent: the server serves exactly one +# workspace, so it would be the same constant on every response ever sent. +class ProjectOut(BaseModel): + """A project.""" + + id: UUID + name: str + description: str | None + + @classmethod + def of(cls, project: Project) -> Self: + # Field by field, never ``model_validate(project, from_attributes=True)``: + # a field added to ``Project`` must not widen the public contract by + # accident. Publishing one is an edit here. + return cls(id=project.id, name=project.name, description=project.description) + + +class ProjectPage(Page[ProjectOut]): + """A page of projects.""" + + +class ProjectCreate(BaseModel): + """What creating a project needs.""" + + model_config = ConfigDict(extra="forbid") + + name: str + description: str | None = None + + +# The description is not here because ``ProjectService`` has no way to update it. +# The API does not grow a field the SDK cannot honour. +class ProjectRename(BaseModel): + """The one field of a project that moves.""" + + model_config = ConfigDict(extra="forbid") + + name: str + + +# --- annotation schemas ------------------------------------------------------ + + +# Request *and* response, because an attribute is a pure value object whose wire +# form does not differ by direction. FastAPI emits one component for it, so a +# client generator produces one type rather than an -Input/-Output pair. A +# ``@computed_field`` added here would split it in two, which makes adding one a +# contract event rather than a refactor. +class AttributeBody(BaseModel): + """A typed attribute on a label class.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # Spelled inline rather than through an alias — reason 2 in the module + # docstring, biting in this very module. A module-level + # ``type AttributeKind = ...`` emits a named ``AttributeKind`` schema into + # ``components``; an inline ``Literal`` emits an anonymous enum on the field. + # ``tests/server/test_wire_models.py`` asserts these are still the domain's + # own four, since nothing structural ties the two lists together. + kind: Literal["string", "number", "boolean", "select"] + required: bool = False + options: tuple[str, ...] | None = None + default: bool | float | str | None = None + + @model_validator(mode="after") + def _the_domain_accepts_it(self) -> Self: + # Load-bearing. A ``select`` with no options, a repeated option, a default + # of the wrong kind — every one is refused by ``Attribute``'s own + # validators, and a ``pydantic.ValidationError`` raised from a route + # *body* is neither a ``VisionSetError`` nor a ``RequestValidationError``: + # it reaches the catch-all handler and answers 500 INTERNAL_ERROR to a + # plainly malformed payload. Converting during parsing makes it a 422 + # VALIDATION_ERROR carrying the domain's own message and the offending + # field's ``loc``. No rule is restated; the domain stays their only home. + self.to_domain() + return self + + def to_domain(self) -> Attribute: + return Attribute( + name=self.name, + kind=self.kind, + required=self.required, + options=self.options, + default=self.default, + ) + + @classmethod + def of(cls, attribute: Attribute) -> Self: + return cls( + name=attribute.name, + kind=attribute.kind, + required=attribute.required, + options=attribute.options, + default=attribute.default, + ) + + +# Request and response, for the reason above ``AttributeBody``. +class LabelClassBody(BaseModel): + """One labelable class, bound to a geometry.""" + + model_config = ConfigDict(extra="forbid") + + name: str + # All eight members, including the five with no implementation yet. They are + # the domain's vocabulary, and naming one produces a precise 422 + # UNSUPPORTED_GEOMETRY from ``SchemaService``. Narrowing the enum here would + # be a second list to keep in step with ``IMPLEMENTED_GEOMETRIES`` — which is + # derived off the ``Geometry`` union precisely so no second list exists. + geometry: GeometryType + color: str | None = None + attributes: tuple[AttributeBody, ...] = () + + @model_validator(mode="after") + def _the_domain_accepts_it(self) -> Self: + # Parsing-time construction, for the reason in ``AttributeBody``. + self.to_domain() + return self + + def to_domain(self) -> LabelClass: + return LabelClass( + name=self.name, + geometry=self.geometry, + color=self.color, + attributes=tuple(attribute.to_domain() for attribute in self.attributes), + ) + + @classmethod + def of(cls, label_class: LabelClass) -> Self: + return cls( + name=label_class.name, + geometry=label_class.geometry, + color=label_class.color, + attributes=tuple(AttributeBody.of(a) for a in label_class.attributes), + ) + + +# The version's ``id`` is deliberately absent. A schema version is addressed by +# (project, version) everywhere it matters — the path, ``Annotation.schema_version``, +# the pin a batch takes at approval — so a UUID no route accepts would be +# contract surface that could never be removed. +class SchemaVersionOut(BaseModel): + """One version of a project's labeling contract.""" + + project_id: UUID + version: int + classes: tuple[LabelClassBody, ...] + + @classmethod + def of(cls, schema: AnnotationSchema) -> Self: + return cls( + project_id=schema.project_id, + version=schema.version, + classes=tuple(LabelClassBody.of(c) for c in schema.classes), + ) + + +class SchemaVersionPage(Page[SchemaVersionOut]): + """A page of schema versions.""" + + +class SchemaVersionCreate(BaseModel): + """The whole proposed version. There is no partial edit of a schema.""" + + model_config = ConfigDict(extra="forbid") + + classes: tuple[LabelClassBody, ...] = () diff --git a/src/visionset/server/routes/__init__.py b/src/visionset/server/routes/__init__.py new file mode 100644 index 00000000..acec081e --- /dev/null +++ b/src/visionset/server/routes/__init__.py @@ -0,0 +1,25 @@ +# usage: from visionset.server.routes import ROUTERS +"""The resource routers. + +``main.create_app`` includes every one of them, so adding a route module is one +import here and no edit to ``main.py``. A route module imports from +``dependencies``, ``errors`` and ``models`` — never from ``main``, which is the +cycle ``dependencies.py`` exists to prevent. + +Every router in here is built with ``protected_router()``. ``/health`` is the one +public operation and it lives on ``main``'s own router; +``tests/server/_openapi.py`` keeps the list of public operations and the spec +walk that enforces it. +""" + +from __future__ import annotations + +from typing import Final + +from fastapi import APIRouter + +from visionset.server.routes import projects, schemas + +ROUTERS: Final[tuple[APIRouter, ...]] = (projects.router, schemas.router) + +__all__ = ["ROUTERS", "projects", "schemas"] diff --git a/src/visionset/server/routes/projects.py b/src/visionset/server/routes/projects.py new file mode 100644 index 00000000..d66585ef --- /dev/null +++ b/src/visionset/server/routes/projects.py @@ -0,0 +1,76 @@ +# usage: from visionset.server.routes import projects +"""Projects: the whole lifecycle, over HTTP. + +Every handler is one call to ``ProjectService`` and one shaping step. A route +never translates an error — it raises the kernel's and stops, and the handlers +``create_app()`` installed turn it into an ``ErrorBody`` with a stable code. See +``docs/api.md``. + +Handlers are ``def``, not ``async def``, and that is not a style choice: every +kernel call underneath is a blocking SQLite call, so a coroutine here would run +it on the event loop. A sync handler gets the threadpool hop FastAPI already +offers, which is what the synchronous kernel wants. +""" + +from __future__ import annotations + +from uuid import UUID + +from fastapi import status + +from visionset.kernel.services import ProjectService +from visionset.server.dependencies import WorkspaceDep, protected_router +from visionset.server.errors import documented +from visionset.server.models import ( + ConfirmQuery, + ProjectCreate, + ProjectOut, + ProjectPage, + ProjectRename, +) + +router = protected_router(prefix="/projects", tags=["projects"]) + + +@router.post("", status_code=status.HTTP_201_CREATED, responses=documented(409)) +def create_project(workspace: WorkspaceDep, body: ProjectCreate) -> ProjectOut: + """Add a project and its empty dataset, both or neither.""" + return ProjectOut.of(ProjectService(workspace).create(body.name, body.description)) + + +@router.get("") +def list_projects(workspace: WorkspaceDep) -> ProjectPage: + """Every project in this workspace, in the order they were created.""" + found = ProjectService(workspace).list() + return ProjectPage(items=[ProjectOut.of(project) for project in found], total=len(found)) + + +@router.get("/{project_id}", responses=documented(404)) +def get_project(workspace: WorkspaceDep, project_id: UUID) -> ProjectOut: + """The project with that id.""" + return ProjectOut.of(ProjectService(workspace).get(project_id)) + + +@router.patch("/{project_id}", responses=documented(404, 409)) +def rename_project(workspace: WorkspaceDep, project_id: UUID, body: ProjectRename) -> ProjectOut: + """Rename a project, and its dataset with it. The only field that moves.""" + return ProjectOut.of(ProjectService(workspace).rename(project_id, body.name)) + + +@router.delete( + "/{project_id}", + status_code=status.HTTP_204_NO_CONTENT, + responses=documented(404, 409), +) +def delete_project( + workspace: WorkspaceDep, project_id: UUID, confirm: ConfirmQuery = False +) -> None: + """Remove a project and everything under it. + + Metadata only: content blobs are shared and are never deleted. Without + `confirm=true` this answers 409 `CONFIRMATION_REQUIRED` and destroys nothing. + """ + # ``confirm`` goes straight to the service. Refusing it here would be a + # second copy of a rule the kernel already owns, and the kernel's refusal is + # what carries the code. + ProjectService(workspace).delete(project_id, confirm=confirm) diff --git a/src/visionset/server/routes/schemas.py b/src/visionset/server/routes/schemas.py new file mode 100644 index 00000000..5b2d01a1 --- /dev/null +++ b/src/visionset/server/routes/schemas.py @@ -0,0 +1,89 @@ +# usage: from visionset.server.routes import schemas +"""The annotation schema of a project, and its versions. + +"Schema" here always means ``AnnotationSchema``. The pydantic classes this +module returns are *models*, and they live in ``server/models.py`` — the same +vocabulary ``kernel/domain/`` uses. + +The active version is the collection's **parent** (``GET .../schema``) rather +than a number a client has to guess, because "in force" is a property of the +schema and not of any particular version. Versions are 1..N and none of them is +ever edited, so there is no ``PUT`` and no ``DELETE`` here — the only write is +appending the next one. +""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import Path, status + +from visionset.kernel.services import SchemaService +from visionset.server.dependencies import WorkspaceDep, protected_router +from visionset.server.errors import documented +from visionset.server.models import ( + DestructiveQuery, + SchemaVersionCreate, + SchemaVersionOut, + SchemaVersionPage, +) + +router = protected_router(prefix="/projects/{project_id}/schema", tags=["schemas"]) + +#: ``ge=1`` mirrors ``AnnotationSchema.version``'s own bound, so ``/versions/0`` +#: is a 422 about the request rather than a 404 about a version that could never +#: have existed. +VersionPath = Annotated[int, Path(ge=1, description="A schema version, 1..N.")] + + +@router.post("/versions", status_code=status.HTTP_201_CREATED, responses=documented(404, 409)) +def create_schema_version( + workspace: WorkspaceDep, + project_id: UUID, + body: SchemaVersionCreate, + allow_destructive: DestructiveQuery = False, +) -> SchemaVersionOut: + """Append the next version of the project's schema. + + The body is the whole proposed version; versions are never edited in place. + + Removing a class or an attribute answers 409 `DESTRUCTIVE_SCHEMA_CHANGE` + until `allow_destructive=true` says so deliberately. If annotations already + exist under an affected class it answers 409 `SCHEMA_CHANGE_WOULD_ORPHAN` + instead, and **no flag overrides that one** — which is why a client branches + on `code` and not on the status. + """ + classes = [label_class.to_domain() for label_class in body.classes] + created = SchemaService(workspace).create_version( + project_id, classes, allow_destructive=allow_destructive + ) + return SchemaVersionOut.of(created) + + +@router.get("/versions", responses=documented(404)) +def list_schema_versions(workspace: WorkspaceDep, project_id: UUID) -> SchemaVersionPage: + """Every version, oldest first. An empty page is the ordinary starting state.""" + found = SchemaService(workspace).list_versions(project_id) + return SchemaVersionPage( + items=[SchemaVersionOut.of(schema) for schema in found], total=len(found) + ) + + +@router.get("/versions/{version}", responses=documented(404)) +def get_schema_version( + workspace: WorkspaceDep, project_id: UUID, version: VersionPath +) -> SchemaVersionOut: + """One version of a project's schema.""" + return SchemaVersionOut.of(SchemaService(workspace).get(project_id, version)) + + +@router.get("", responses=documented(404)) +def get_active_schema(workspace: WorkspaceDep, project_id: UUID) -> SchemaVersionOut: + """The version in force: the highest one. + + A project that has no schema yet answers 404 `SCHEMA_NOT_FOUND`, which is a + different code from the 404 `PROJECT_NOT_FOUND` an unknown project gets. + Same status, two situations. + """ + return SchemaVersionOut.of(SchemaService(workspace).get_active(project_id)) diff --git a/tests/server/_api.py b/tests/server/_api.py new file mode 100644 index 00000000..3d14a4a8 --- /dev/null +++ b/tests/server/_api.py @@ -0,0 +1,56 @@ +"""A real application, over a real workspace, holding a real token. + +The opposite of `_probe.py`, and a separate module for that reason: a probe app +carries one *fake* route and must never be confused with the shipped route set, +while everything here exercises exactly what `openapi.json` describes. One +private module per concern is the `_probe.py` / `_openapi.py` precedent, and +there is still no `conftest.py` anywhere — each test module wraps these in its +own two-line fixture. + +The workspace is initialised, a token minted through `TokenService`, and the +workspace closed again before the application opens it. That is the same +sequence `test_auth.py::test_a_persisted_token_authenticates` uses, and it is +what makes these tests exercise real authentication rather than an override. +""" + +from pathlib import Path +from typing import Final + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from tests.server._probe import handle_for + +from visionset.kernel.services import TokenService, WorkspaceService +from visionset.server.main import create_app + +TOKEN_NAME: Final = "api-tests" + + +def served_app(root: Path) -> FastAPI: + """The shipped application, serving the workspace at ``root``. + + The handle is replaced rather than the environment patched, so the test says + which workspace it means instead of relying on process-wide state. + """ + app = create_app() + app.state.workspace_handle = handle_for(root) + return app + + +def api_workspace(root: Path) -> str: + """Initialise a workspace at ``root``, mint a token, close it. Returns the secret.""" + workspace = WorkspaceService.init(root) + try: + return TokenService(workspace).create(TOKEN_NAME).secret + finally: + workspace.close() + + +def api_client(root: Path) -> TestClient: + """A client for a fresh workspace at ``root``, authenticated on every request. + + Use it as a context manager: the lifespan is what closes the workspace, and + a `visionset.db-wal` left behind would outlive the test's ``tmp_path``. + """ + secret = api_workspace(root) + return TestClient(served_app(root), headers={"Authorization": f"Bearer {secret}"}) diff --git a/tests/server/test_openapi_contract.py b/tests/server/test_openapi_contract.py index a1a5dbd1..d327c36f 100644 --- a/tests/server/test_openapi_contract.py +++ b/tests/server/test_openapi_contract.py @@ -1,18 +1,23 @@ """The acceptance walk: every documented operation but `/health` needs a token. -**Vacuous today, and deliberately committed anyway.** `/health` is the only -operation in the contract, so the walk takes its public branch once and asserts -nothing about authentication. It is a tripwire, not a check: it fires the moment -a route lands that did not come from `protected_router()`. The tests that prove -the walk can *fail* are what make a vacuous assertion worth having — without -them, "it passes" would be indistinguishable from "it looks at nothing". +It was vacuous when #25 committed it — `/health` was the only operation, so the +walk took its public branch once and asserted nothing about authentication. #27 +landed nine protected operations and it now has something to say. The tests that +prove the walk can *fail* stay regardless: without them, "it passes" would be +indistinguishable from "it looks at nothing". The walk only sees *documented* operations. `/docs`, `/redoc` and `/openapi.json` are `include_in_schema=False` and stay public by decision: the spec is already a committed artifact in a public repository, and a contract you must authenticate to read is a contract nobody generates a client from. + +This module also pins the parts of the contract that no single endpoint owns: +the security scheme, the page envelope's naming, and the fact that the committed +`openapi.json` is the application's own output. """ +import json +from pathlib import Path from typing import Any import pytest @@ -23,20 +28,74 @@ from visionset.server.dependencies import require_token from visionset.server.main import app, create_app +REPO_ROOT = Path(__file__).resolve().parents[2] + def test_every_documented_operation_except_health_requires_a_token() -> None: assert_every_operation_is_protected(app.openapi()) -def test_the_committed_contract_has_no_security_scheme_yet() -> None: - """Nothing is protected yet, so the scheme is not in the spec — by design. +def test_the_committed_contract_declares_the_bearer_scheme() -> None: + """#27's first protected route put it there, exactly as #25 predicted. FastAPI collects security definitions per *route*, from its dependency tree. - Declaring ``bearer_scheme`` at module level emits nothing; the scheme enters - ``components`` with the first route that depends on it, which is why this PR - moves ``openapi.json`` not at all and the first endpoint task moves it twice. + Declaring ``bearer_scheme`` at module level emits nothing, so the scheme was + absent for as long as zero routes depended on it — which is why #25's export + was byte-identical and this was the first task to move ``openapi.json``. + The shape is the one the probe app pinned a task earlier. + """ + schemes = app.openapi()["components"]["securitySchemes"] + assert set(schemes) == {"HTTPBearer"} + assert ( + schemes["HTTPBearer"] + == probe_app().openapi()["components"]["securitySchemes"]["HTTPBearer"] + ) + + +def test_the_committed_openapi_matches_the_application() -> None: + """The drift gate, run where the mistake is actually made. + + CI has its own job for this. Duplicating it here is deliberate: this one + fails during ``uv run pytest``, in the same command a contributor was already + running, instead of ten minutes later on a push. + """ + committed = json.loads((REPO_ROOT / "openapi.json").read_text()) + + assert committed == json.loads(json.dumps(app.openapi())), ( + "openapi.json is stale — run 'uv run python scripts/export_openapi.py'" + ) + + +def test_the_page_envelope_is_named_for_its_item_type() -> None: + """Never ``Page_ProjectOut_``, which is what a parametrised generic emits. + + A component name becomes a type name in a generated client, so the concrete + subclasses in ``server/models.py`` exist for exactly this line. + """ + schemas = app.openapi()["components"]["schemas"] + + assert {"ProjectPage", "SchemaVersionPage"} <= set(schemas) + assert not [name for name in schemas if name.startswith("Page")] + + +def test_no_operation_id_is_used_twice() -> None: + """``generate_unique_id_function`` uses the handler name, so this is not free. + + FastAPI's default is path-derived and unique by construction; the handler + name is stable across a path change, which is what a generated client wants, + at the cost of needing this assertion. """ - assert "securitySchemes" not in app.openapi().get("components", {}) + ids = [operation["operationId"] for _, _, operation in operations(app.openapi())] + + assert len(ids) == len(set(ids)), sorted(ids) + + +def test_every_documented_operation_but_health_is_tagged() -> None: + """Tags group a generated client's methods, so an untagged route is homeless.""" + for path, method, operation in operations(app.openapi()): + if path == "/health": + continue + assert operation.get("tags"), f"{method.upper()} {path}" def test_a_protected_route_declares_the_bearer_scheme_and_its_401() -> None: @@ -48,7 +107,12 @@ def test_a_protected_route_declares_the_bearer_scheme_and_its_401() -> None: def test_the_bearer_scheme_enters_the_spec_with_this_exact_shape() -> None: - """Pinned here so the diff #27 commits is a decision already reviewed.""" + """The definition of the shape, still proven without touching the real app. + + Written in #25 so the diff #27 committed was a decision already reviewed. It + stays on the probe app: this is what the scheme *is*, and the assertion above + is that the shipped contract agrees. + """ schemes = probe_app().openapi()["components"]["securitySchemes"] assert set(schemes) == {"HTTPBearer"} assert schemes["HTTPBearer"]["type"] == "http" diff --git a/tests/server/test_projects.py b/tests/server/test_projects.py new file mode 100644 index 00000000..d4a2fdb3 --- /dev/null +++ b/tests/server/test_projects.py @@ -0,0 +1,271 @@ +"""The project endpoints, against a real workspace on disk. + +Every assertion here is about the *wire*: the status, the body, the code a +client branches on. What the kernel does underneath is `tests/kernel/ +test_project_service.py`'s subject, and restating it here would be two tests +that fail together and tell you nothing extra. +""" + +from collections.abc import Iterator +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from tests.server._api import api_client + + +@pytest.fixture() +def client(tmp_path: Path) -> Iterator[TestClient]: + with api_client(tmp_path / "ws") as made: + yield made + + +def created(client: TestClient, name: str, description: str | None = None) -> dict[str, object]: + """POST a project and return its body, failing loudly if it did not take.""" + body: dict[str, object] = {"name": name} + if description is not None: + body["description"] = description + response = client.post("/projects", json=body) + assert response.status_code == 201, response.text + made: dict[str, object] = response.json() + return made + + +# --- creating ---------------------------------------------------------------- + + +def test_creating_a_project_answers_201_with_its_new_id(client: TestClient) -> None: + response = client.post("/projects", json={"name": "road-signs"}) + + assert response.status_code == 201 + assert response.json()["name"] == "road-signs" + assert response.json()["id"] + + +def test_a_created_project_is_then_readable_by_id(client: TestClient) -> None: + made = created(client, "road-signs") + + response = client.get(f"/projects/{made['id']}") + + assert response.status_code == 200 + assert response.json() == made + + +def test_the_description_is_optional_and_comes_back_as_null(client: TestClient) -> None: + """Present rather than absent, because ``ProjectOut`` declares no default. + + A generated client types it ``description: string | null``, so a consumer + never has to tell "not set" from "the server did not say". + """ + assert created(client, "road-signs")["description"] is None + assert created(client, "traffic-lights", "night shots")["description"] == "night shots" + + +def test_creating_a_project_with_a_taken_name_is_409_project_name_taken( + client: TestClient, +) -> None: + created(client, "road-signs") + + response = client.post("/projects", json={"name": "road-signs"}) + + assert response.status_code == 409 + assert response.json()["code"] == "PROJECT_NAME_TAKEN" + + +def test_creating_a_project_with_a_blank_name_is_422_invalid_name(client: TestClient) -> None: + """A domain refusal, so the code is the kernel's and ``detail`` is empty. + + The other shape of 422 is below: pydantic's, which carries ``detail.errors``. + Both are 422 and only ``code`` tells them apart. + """ + response = client.post("/projects", json={"name": " "}) + + assert response.status_code == 422 + assert response.json()["code"] == "INVALID_NAME" + assert response.json()["detail"] is None + + +def test_an_unknown_field_in_the_body_is_422_validation_error(client: TestClient) -> None: + """``extra="forbid"`` — a typo is refused, never silently dropped.""" + response = client.post("/projects", json={"name": "road-signs", "descriptoin": "typo"}) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + assert response.json()["detail"]["errors"] + + +# --- listing ----------------------------------------------------------------- + + +def test_listing_projects_returns_an_envelope_with_items_and_total(client: TestClient) -> None: + """Never a bare array: an array cannot grow a field without breaking clients.""" + created(client, "road-signs") + + body = client.get("/projects").json() + + assert set(body) == {"items", "total"} + assert body["total"] == 1 + assert [item["name"] for item in body["items"]] == ["road-signs"] + + +def test_a_fresh_workspace_lists_an_empty_page_rather_than_a_404(client: TestClient) -> None: + response = client.get("/projects") + + assert response.status_code == 200 + assert response.json() == {"items": [], "total": 0} + + +def test_the_listing_keeps_creation_order(client: TestClient) -> None: + for name in ("first", "second", "third"): + created(client, name) + + body = client.get("/projects").json() + + assert [item["name"] for item in body["items"]] == ["first", "second", "third"] + assert body["total"] == 3 + + +# --- reading ----------------------------------------------------------------- + + +def test_getting_an_unknown_project_is_404_project_not_found(client: TestClient) -> None: + response = client.get(f"/projects/{uuid4()}") + + assert response.status_code == 404 + assert response.json()["code"] == "PROJECT_NOT_FOUND" + + +def test_a_malformed_uuid_in_the_path_is_422_not_404(client: TestClient) -> None: + """The request never reaches the service, so it is about the request. + + Worth pinning: a client hand-building a URL hits this, and 404 would read as + "no such project" for something that could never name one. + """ + response = client.get("/projects/not-a-uuid") + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + + +# --- renaming ---------------------------------------------------------------- + + +def test_renaming_a_project_returns_it_under_the_new_name(client: TestClient) -> None: + made = created(client, "road-signs") + + response = client.patch(f"/projects/{made['id']}", json={"name": "street-signs"}) + + assert response.status_code == 200 + assert response.json() == {**made, "name": "street-signs"} + + +def test_renaming_to_a_name_another_project_holds_is_409(client: TestClient) -> None: + created(client, "road-signs") + other = created(client, "traffic-lights") + + response = client.patch(f"/projects/{other['id']}", json={"name": "road-signs"}) + + assert response.status_code == 409 + assert response.json()["code"] == "PROJECT_NAME_TAKEN" + + +def test_renaming_a_project_to_its_own_name_is_allowed(client: TestClient) -> None: + """Fixing the case of your own name is not a collision with yourself.""" + made = created(client, "road-signs") + + response = client.patch(f"/projects/{made['id']}", json={"name": "Road-Signs"}) + + assert response.status_code == 200 + assert response.json()["name"] == "Road-Signs" + + +def test_the_patch_body_cannot_carry_a_description(client: TestClient) -> None: + """The one mutable field, because the SDK has no way to update the other. + + The API does not grow a field the kernel cannot honour; a request that tried + is refused rather than half-applied. + """ + made = created(client, "road-signs", "day shots") + + response = client.patch( + f"/projects/{made['id']}", json={"name": "road-signs", "description": "night shots"} + ) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + assert client.get(f"/projects/{made['id']}").json()["description"] == "day shots" + + +def test_renaming_an_unknown_project_is_404(client: TestClient) -> None: + response = client.patch(f"/projects/{uuid4()}", json={"name": "whatever"}) + + assert response.status_code == 404 + assert response.json()["code"] == "PROJECT_NOT_FOUND" + + +# --- deleting ---------------------------------------------------------------- + + +def test_deleting_without_confirm_is_409_confirmation_required(client: TestClient) -> None: + made = created(client, "road-signs") + + response = client.delete(f"/projects/{made['id']}") + + assert response.status_code == 409 + assert response.json()["code"] == "CONFIRMATION_REQUIRED" + assert client.get(f"/projects/{made['id']}").status_code == 200 + + +def test_deleting_with_confirm_answers_204_with_an_empty_body(client: TestClient) -> None: + made = created(client, "road-signs") + + response = client.delete(f"/projects/{made['id']}?confirm=true") + + assert response.status_code == 204 + assert response.content == b"" + + +def test_a_deleted_project_is_then_404(client: TestClient) -> None: + made = created(client, "road-signs") + + client.delete(f"/projects/{made['id']}?confirm=true") + + assert client.get(f"/projects/{made['id']}").status_code == 404 + assert client.get("/projects").json() == {"items": [], "total": 0} + + +def test_deleting_an_unknown_project_is_404_before_the_confirmation_check( + client: TestClient, +) -> None: + """Existence first, so an unknown id reads the same with the flag and without.""" + unknown = uuid4() + + assert client.delete(f"/projects/{unknown}").status_code == 404 + assert client.delete(f"/projects/{unknown}?confirm=true").status_code == 404 + + +# --- the guard --------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/projects"), + ("GET", "/projects"), + ("GET", "/projects/{id}"), + ("PATCH", "/projects/{id}"), + ("DELETE", "/projects/{id}"), + ], +) +def test_every_project_route_refuses_a_request_without_a_token( + client: TestClient, method: str, path: str +) -> None: + """`protected_router()` guards the router, so no route can be forgotten.""" + made = created(client, "road-signs") + url = path.format(id=made["id"]) + + response = client.request(method, url, json={"name": "x"}, headers={"Authorization": ""}) + + assert response.status_code == 401, f"{method} {url}" + assert response.json()["code"] == "UNAUTHORIZED" diff --git a/tests/server/test_schemas.py b/tests/server/test_schemas.py new file mode 100644 index 00000000..4da227ea --- /dev/null +++ b/tests/server/test_schemas.py @@ -0,0 +1,296 @@ +"""The annotation schema endpoints, against a real workspace on disk. + +Versions are 1..N and none is ever edited, so the only write here is appending +the next one. The rest is reading, plus the two gates on narrowing the contract. +""" + +from collections.abc import Iterator +from pathlib import Path +from typing import Any +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from tests.server._api import api_client + + +@pytest.fixture() +def client(tmp_path: Path) -> Iterator[TestClient]: + with api_client(tmp_path / "ws") as made: + yield made + + +@pytest.fixture() +def project(client: TestClient) -> str: + """A project id to hang schema versions off.""" + response = client.post("/projects", json={"name": "road-signs"}) + assert response.status_code == 201, response.text + project_id: str = response.json()["id"] + return project_id + + +def post_version(client: TestClient, project: str, *classes: dict[str, Any], **query: Any) -> Any: + return client.post( + f"/projects/{project}/schema/versions", json={"classes": list(classes)}, params=query + ) + + +def a_class(name: str = "sign", **overrides: Any) -> dict[str, Any]: + return {"name": name, "geometry": "bbox", **overrides} + + +# --- the empty start --------------------------------------------------------- + + +def test_a_project_with_no_schema_has_no_active_version(client: TestClient, project: str) -> None: + response = client.get(f"/projects/{project}/schema") + + assert response.status_code == 404 + assert response.json()["code"] == "SCHEMA_NOT_FOUND" + + +def test_listing_versions_of_a_project_with_no_schema_is_an_empty_page( + client: TestClient, project: str +) -> None: + """Schema-less is the ordinary starting state, so listing is not a refusal.""" + response = client.get(f"/projects/{project}/schema/versions") + + assert response.status_code == 200 + assert response.json() == {"items": [], "total": 0} + + +# --- appending versions ------------------------------------------------------ + + +def test_creating_the_first_version_answers_201_and_numbers_it_1( + client: TestClient, project: str +) -> None: + response = post_version(client, project, a_class()) + + assert response.status_code == 201 + assert response.json()["version"] == 1 + assert response.json()["project_id"] == project + + +def test_the_next_version_is_numbered_one_higher(client: TestClient, project: str) -> None: + post_version(client, project, a_class()) + + response = post_version(client, project, a_class(), a_class("lane", geometry="polygon")) + + assert response.status_code == 201 + assert response.json()["version"] == 2 + + +def test_the_active_version_is_the_highest_one(client: TestClient, project: str) -> None: + post_version(client, project, a_class()) + post_version(client, project, a_class(), a_class("lane")) + + body = client.get(f"/projects/{project}/schema").json() + + assert body["version"] == 2 + assert [c["name"] for c in body["classes"]] == ["sign", "lane"] + + +def test_a_version_is_readable_by_its_number(client: TestClient, project: str) -> None: + """The old version keeps its own classes; a new one never edits it.""" + post_version(client, project, a_class()) + post_version(client, project, a_class(), a_class("lane")) + + body = client.get(f"/projects/{project}/schema/versions/1").json() + + assert body["version"] == 1 + assert [c["name"] for c in body["classes"]] == ["sign"] + + +def test_an_unknown_version_number_is_404_schema_not_found( + client: TestClient, project: str +) -> None: + post_version(client, project, a_class()) + + response = client.get(f"/projects/{project}/schema/versions/7") + + assert response.status_code == 404 + assert response.json()["code"] == "SCHEMA_NOT_FOUND" + + +def test_version_zero_is_422_because_no_version_could_ever_be_zero( + client: TestClient, project: str +) -> None: + """`ge=1` mirrors the domain's own bound, so this is about the request. + + Without it, `0` would reach the service and come back as a 404 about a + version that could not have existed. + """ + response = client.get(f"/projects/{project}/schema/versions/0") + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + + +def test_versions_are_listed_oldest_first(client: TestClient, project: str) -> None: + for extra in range(3): + post_version(client, project, *(a_class(f"sign-{n}") for n in range(extra + 1))) + + body = client.get(f"/projects/{project}/schema/versions").json() + + assert [item["version"] for item in body["items"]] == [1, 2, 3] + assert body["total"] == 3 + + +# --- what a version may contain ---------------------------------------------- + + +def test_a_class_with_an_unimplemented_geometry_is_422_unsupported_geometry( + client: TestClient, project: str +) -> None: + """`mask` is in the enum and has no implementation — a precise refusal. + + The wire model keeps all eight members deliberately, so naming one gets this + rather than "not a valid enumeration member". + """ + response = post_version(client, project, a_class(geometry="mask")) + + assert response.status_code == 422 + assert response.json()["code"] == "UNSUPPORTED_GEOMETRY" + + +def test_a_geometry_outside_the_enum_is_422_validation_error( + client: TestClient, project: str +) -> None: + response = post_version(client, project, a_class(geometry="hexagon")) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + + +def test_two_classes_with_one_name_are_422_invalid_schema(client: TestClient, project: str) -> None: + """A cross-class rule, so it belongs to the service and not to `LabelClass`.""" + response = post_version(client, project, a_class("Sign"), a_class("sign")) + + assert response.status_code == 422 + assert response.json()["code"] == "INVALID_SCHEMA" + + +def test_a_blank_class_name_is_422_validation_error_not_500( + client: TestClient, project: str +) -> None: + """The trap this whole design turns on. + + `LabelClass` refuses a blank name with a `pydantic.ValidationError`. Built in + the route body that is neither a domain error nor a request-validation + failure, so it reaches the catch-all handler and answers 500 to a plainly + malformed payload. `LabelClassBody` builds the domain object during parsing + instead, which puts the domain's own message on the offending field. + + The `loc` reaches all the way to `name` rather than stopping at the class: + pydantic merges a `ValidationError` raised *inside* a validator into the + outer path, so the domain's field-level location survives the conversion. + """ + response = post_version(client, project, a_class(" ")) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + error = response.json()["detail"]["errors"][0] + assert error["loc"] == ["body", "classes", 0, "name"] + assert "at least one non-blank character" in error["msg"] + + +def test_a_select_attribute_with_no_options_is_422_validation_error( + client: TestClient, project: str +) -> None: + attribute = {"name": "weather", "kind": "select"} + response = post_version(client, project, a_class(attributes=[attribute])) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + assert "needs at least one option" in response.text + + +def test_an_attribute_default_of_the_wrong_kind_is_422_validation_error( + client: TestClient, project: str +) -> None: + attribute = {"name": "occluded", "kind": "boolean", "default": "yes"} + response = post_version(client, project, a_class(attributes=[attribute])) + + assert response.status_code == 422 + assert response.json()["code"] == "VALIDATION_ERROR" + + +def test_attributes_and_colors_survive_the_round_trip(client: TestClient, project: str) -> None: + attribute = { + "name": "weather", + "kind": "select", + "required": True, + "options": ["sun", "rain"], + "default": "sun", + } + label_class = a_class(color="#ff0000", attributes=[attribute]) + + post_version(client, project, label_class) + body = client.get(f"/projects/{project}/schema").json() + + assert body["classes"] == [ + {"name": "sign", "geometry": "bbox", "color": "#ff0000", "attributes": [attribute]} + ] + + +# --- the gate on narrowing --------------------------------------------------- + + +def test_removing_a_class_is_409_destructive_schema_change( + client: TestClient, project: str +) -> None: + post_version(client, project, a_class("sign"), a_class("lane")) + + response = post_version(client, project, a_class("sign")) + + assert response.status_code == 409 + assert response.json()["code"] == "DESTRUCTIVE_SCHEMA_CHANGE" + + +def test_the_same_change_with_allow_destructive_succeeds(client: TestClient, project: str) -> None: + """Retrying is the identical body plus one query parameter.""" + post_version(client, project, a_class("sign"), a_class("lane")) + + response = post_version(client, project, a_class("sign"), allow_destructive=True) + + assert response.status_code == 201 + assert [c["name"] for c in response.json()["classes"]] == ["sign"] + + +# --- an unknown project ------------------------------------------------------ + + +@pytest.mark.parametrize( + ("method", "suffix"), + [("POST", "/versions"), ("GET", "/versions"), ("GET", "/versions/1"), ("GET", "")], +) +def test_every_schema_route_of_an_unknown_project_is_404_project_not_found( + client: TestClient, method: str, suffix: str +) -> None: + """A different code from `SCHEMA_NOT_FOUND`, at the same status.""" + response = client.request(method, f"/projects/{uuid4()}/schema{suffix}", json={"classes": []}) + + assert response.status_code == 404, f"{method} {suffix}" + assert response.json()["code"] == "PROJECT_NOT_FOUND" + + +# --- the guard --------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("method", "suffix"), + [("POST", "/versions"), ("GET", "/versions"), ("GET", "/versions/1"), ("GET", "")], +) +def test_every_schema_route_refuses_a_request_without_a_token( + client: TestClient, project: str, method: str, suffix: str +) -> None: + response = client.request( + method, + f"/projects/{project}/schema{suffix}", + json={"classes": []}, + headers={"Authorization": ""}, + ) + + assert response.status_code == 401, f"{method} {suffix}" + assert response.json()["code"] == "UNAUTHORIZED" diff --git a/tests/server/test_wire_models.py b/tests/server/test_wire_models.py new file mode 100644 index 00000000..fd2a8cce --- /dev/null +++ b/tests/server/test_wire_models.py @@ -0,0 +1,73 @@ +"""Tripwires on the seam between a wire model and the domain it mirrors. + +No HTTP here. These are the assertions that catch a wire model drifting away +from the kernel it publishes — which the route tests would not, because they +only ever send shapes both sides already agree on. +""" + +from typing import get_args + +import pytest + +from visionset.kernel.domain import Attribute, GeometryType, LabelClass +from visionset.server.models import AttributeBody, LabelClassBody + + +def test_the_wire_attribute_kinds_are_the_domains_own_four() -> None: + """`AttributeBody.kind` restates the domain's `Literal` and nothing ties them. + + It is spelled inline rather than shared through an alias, because a PEP 695 + `type` alias emits a *named* schema into `components` — so the price of + keeping the contract clean is this test. A fifth kind added to the domain + fails here until somebody publishes it deliberately. + """ + domain = get_args(Attribute.model_fields["kind"].annotation) + wire = get_args(AttributeBody.model_fields["kind"].annotation) + + assert wire == domain + assert set(wire) == {"string", "number", "boolean", "select"} + + +def test_the_wire_geometry_is_the_domains_own_enum() -> None: + """Reused rather than restated, so the eight members cannot drift apart.""" + assert LabelClassBody.model_fields["geometry"].annotation is GeometryType + + +def test_a_label_class_round_trips_through_the_domain_and_back() -> None: + """`of` and `to_domain` are inverses, so nothing is lost on the way out.""" + original = LabelClassBody( + name="sign", + geometry=GeometryType.BBOX, + color="#ff0000", + attributes=( + AttributeBody( + name="weather", + kind="select", + required=True, + options=("sun", "rain"), + default="sun", + ), + ), + ) + + assert LabelClassBody.of(original.to_domain()) == original + + +def test_a_domain_label_class_survives_being_published() -> None: + """The other direction: a stored class comes back identical.""" + label_class = LabelClass(name="lane", geometry=GeometryType.POLYGON) + + assert LabelClassBody.of(label_class).to_domain() == label_class + + +def test_a_wire_label_class_is_refused_by_the_domains_own_rules() -> None: + """The refusal happens at *construction*, which is why it becomes a 422. + + If this ever stops raising, the conversion has moved out of the validator + and a malformed payload is answering 500 again. + """ + with pytest.raises(ValueError, match="at least one non-blank character"): + LabelClassBody(name=" ", geometry=GeometryType.BBOX) + + with pytest.raises(ValueError, match="needs at least one option"): + AttributeBody(name="weather", kind="select")