# seampact on FastAPI > Python server: the generated Python package binds the routes; you write the handlers and the hooks. Each part below names its source page; open it for the rest. Source: https://jubasse.github.io/seampact/md/features/commands-per-stack.md Commands, run in the app folder: Python: init = not available; generate = seampact generate; check = seampact check; lint = use the binary; diff = use the binary; bundle = use the binary Source: https://jubasse.github.io/seampact/md/contract/01-basics.md ## The main blocks ```yaml # contract/api.yaml api: name: notes version: 1 kind: product maturity: provisional schemas: NoteId: { type: string, format: uuid } surfaces: app: prefix: /v{version} access: { public: "Anyone may call this api: it has no accounts." } routes: {} ``` - `api` names the api. `version: 1` gives the `/v1` in every path. - `kind` is `product` (the default: an api for apps and their users) or `service` (an internal api with a caller identity). Leave it out for a product. - `maturity` is how much you promise: `experimental`, `provisional` or `stable`. See [Versioning and change](https://jubasse.github.io/seampact/md/contract/06-versioning.md). - `schemas` holds the shapes of your data. - `surfaces` holds the routes. A surface is a group of routes under one prefix and one access rule, such as `app` for browsers and `admin` for staff. - Other blocks (`errors`, `pagings`, `enums`, `identities`) are top-level too. The next pages cover them. A [route](https://jubasse.github.io/seampact/md/concepts/glossary.md) is a YAML entry that becomes one HTTP endpoint. An operation (`op`) is a standard route you get from `ops`, such as `list` or `get`. Source: https://jubasse.github.io/seampact/md/contract/05-lists.md ## Declare a paging style A style says which query parameters a client sends and how the page comes back. It lives under `pagings`. seampact imposes no shape: you write the body your clients expect. A list without a style fails to compile, and the error shows an example. A cursor, with the next page in a `Link` header. Add it as a top-level `pagings` block of `contract/api.yaml`: ```yaml pagings: cursor: request: cursor: { query: cursor } limit: { query: limit } response: body: $items links: { next: $next } ``` `$items` is where the records go. `$next` is the link to the next page. The client sends `?cursor=...&limit=20`, and the body is the array of records. ## Use it on a list ```yaml # contract/api.yaml, under surfaces.app.routes notes: kind: collection id: { noteId: NoteId } item: Note ops: list: paging: { cursor: { maxLimit: 50, defaultLimit: 20 } } sortable: [createdAt, title] ``` - `maxLimit` is required for a style with a `limit`. A larger `limit` answers `400`. With `overLimit: clamp` it is served as `maxLimit` instead. - `defaultLimit` is the page size when the client sends none. - `sortable` lists the fields a client may sort by: `?sort=-createdAt,title`. It needs a `sort` in the style: add `sort: { query: sort, style: signed }` to `request`. `signed` means a `-` before a field sorts it downward. To use one style on every list, set it once. Then a list writes no `paging`. ```yaml # contract/api.yaml, top level conventions: paging: { default: cursor, maxLimit: 50 } ``` Source: https://jubasse.github.io/seampact/md/backend/fastapi.md ## Set up You need: - Python 3.12 or later; - FastAPI 0.162 (a later minor is untested, see Versions); - Pydantic 2.9 or later. Pydantic 1 is refused. Install seampact from PyPI. Put the generator in your dev dependencies and the runtime in production: ```sh python3 -m venv .venv && . .venv/bin/activate pip install "seampact-fastapi[generate]" ``` `uv pip install` and `uv add --dev` take the same requirement. In production, install plain `seampact-fastapi`. Add `seampact-signed-webhooks` if a route is signed by a sender. A `path` source and the OpenAPI documents need the compiler. Add the extra `seampact-ir[compiler]`, which installs the `seampact-compiler` wheel for your platform. A bundle-only app with `openapi: none` does not need it. In `seampact.config.yaml`, add one entry per api. Replace the `git` url, the tag and `integrity` with your contract's: `integrity` is the hash `seampact bundle` printed when you published it (see [Share contracts](https://jubasse.github.io/seampact/md/features/sharing-contracts.md)). To try a contract in a local folder, use `path: ./contract` instead and install the compiler extra above. ```yaml outDir: generated openapi: none sources: notes: git: { url: "https://git.example.com/notes-contract.git", tag: v1.0.0, directory: bundle } integrity: sha256-... targets: - id: notes out: notes use: pypi:seampact-fastapi api: notes options: { rules: seampact-consistent } ``` The request is checked by [Pydantic](https://jubasse.github.io/seampact/md/types-and-validation/pydantic.md) models; see [Types and validation](https://jubasse.github.io/seampact/md/types-and-validation/README.md). Then generate: ```sh seampact generate ``` The code goes into `generated/notes/`. Import it as `generated.notes.fastapi`, `generated.notes.operations` and `generated.notes.schemas`. Commit the generated files ([Generated files](https://jubasse.github.io/seampact/md/configuration/config-file.md#generated-files)). `seampact check` fails CI when they drift from the contract; [Commands per stack](https://jubasse.github.io/seampact/md/features/commands-per-stack.md) shows how to run it. ## Write a handler Write one class per resource. It implements the generated `Endpoints` protocol. A factory builds it, and the factory is a FastAPI dependency, so it takes your own dependencies: ```python from fastapi import Depends from generated.notes.operations import GetNoteErrors, GetNoteRequest, GetNoteResponse200, GetNoteResult class NotesEndpoints: def __init__(self, notes: NoteRepository) -> None: self.notes = notes async def get_note(self, request: GetNoteRequest) -> GetNoteResult: note = await self.notes.get(request.input.path.note_id) if note is None: return GetNoteErrors.api_not_found() return GetNoteResponse200(note) def notes_endpoints(notes: NoteRepository = Depends(get_notes)) -> NotesEndpoints: return NotesEndpoints(notes) ``` - A method is a plain `def` or an `async def`. A `def` runs in FastAPI's thread pool (40 threads by default, shared with your other `def` dependencies). - `request.input` holds the checked path, query, headers and body, typed. The input is checked before your factory runs, so no session opens for a bad request. - You never write a status or a content type. Return one arm of the result (`GetNoteResponse200`), or an error (`GetNoteErrors.api_not_found()`). You can raise the same error instead. - A reply that breaks the contract answers 500. So does any other exception, and `on_unexpected_error` hears it. When an operation takes a version, the boundary checks its format only. Compare it with the stored version yourself, and return the operation's conflict error when they differ: ```python if request.input.body.expected_version != task.version: return UpdateTaskErrors.state_conflict() ``` A write versioned with `header.If-Match` answers 428 when the header is missing or empty, before the handler runs. A weak tag (`W/"7"`) is accepted, and the handler receives the version unquoted (`7`). A stale version is a 412: the handler returns the `preconditionFailed` error. See [Catch lost updates](https://jubasse.github.io/seampact/md/contract/07-advanced.md#catch-lost-updates). A deadline is a declared header. `request.deadline` is `None` when the caller sent none. Check `request.deadline.has_passed(datetime.now(UTC))` while you work, and answer the operation's deadline error (`GetNoteErrors.api_deadline_exceeded()`). `budgetMs` is the client's timeout; the boundary does not enforce it. ### Who is calling On a route with an identity, `request.principal` is the caller, typed as the generated `Principal`. It is `None` for an anonymous caller on an optional-auth route. A public route has no `principal`. When the route runs in scopes, `request.scopes` has one attribute per scope, each the `ResolvedScope` your resolver returned, with its `id` and `value`: ```python workspace = request.scopes.workspace.value user_id = request.principal.user_id ``` Neither is part of the input, and the client cannot set them. Both print as `[redacted]`. ## Wire it in `bind_notes()` is the one call your app makes. Make it last, after your own middleware and exception handlers. ```python from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from generated.notes.fastapi import NotesHandlerFactories, bind_notes app = FastAPI() app.add_middleware(CORSMiddleware, allow_origins=["https://app.example.com"]) bind_notes( app, handlers=NotesHandlerFactories(notes=notes_endpoints), authenticate={"user": user_hook}, deciders={"notes.update": NotesDecider}, scope_resolvers={"workspace": WorkspaceResolver}, on_unexpected_error=NotesErrors, ) ``` - It adds the contract's routes, the exception handlers and the boundary. The boundary is the outermost middleware of the app. - `handlers` has one factory per resource. `authenticate` has one hook factory per identity, `deciders` one per right or rule, `scope_resolvers` one per scope. Keys are the contract's names. - Add `CORSMiddleware` before `bind_notes()`. A preflight request passes the boundary and reaches it. - With two apis, call `bind_()` twice. - To use an `APIRouter`, pass it as the first argument and add `app=app`. Call `bind_notes()` before `app.include_router(router)`. A prefix on that router is refused: use `mount` on the entry. Each route is named `seampact..`, and its parameters take the input property names. `NotesRouteNames` holds the names and `NotesUrls` builds the paths. `artefacts: { routeHelpers: false }` leaves both out. ## Limits - `generate` refuses some forms by name, until an app needs them. The list is in [Generate errors](https://jubasse.github.io/seampact/md/troubleshooting/generate.md#python); forms refused on every server are under [every server](https://jubasse.github.io/seampact/md/troubleshooting/generate.md#every-server). - A sort key restricted by a right is refused (`paging.sortKey.right.unsupported`). A query list is read in the default `repeat` form (`tag=a&tag=b`) or as `comma`. - DRF and Ninja are separate targets: see [Django REST Framework](https://jubasse.github.io/seampact/md/backend/drf.md) and [Django Ninja](https://jubasse.github.io/seampact/md/backend/ninja.md). ## Paging A paged list reads `request.input.query.cursor` (`None` when absent) and `request.input.query.limit` for the `cursor` style. The boundary fills in `defaultLimit` and answers 400 to a limit above `maxLimit` (`overLimit: clamp` serves `maxLimit`). `query.limit` stays typed `int | None`: narrow it with `assert query.limit is not None`. Return the generated `Page`: `items`, plus one snake-case attribute per page fact (`next_cursor` for `nextCursor`). The boundary writes the body, the headers and the `Link` relations. ```python async def list_notes(self, request: ListNotesRequest) -> ListNotesResult: query = request.input.query notes, next_cursor = await self.notes.page(after=query.cursor, limit=query.limit) return ListNotesResponse200(body=ListNotesPage(items=notes, next_cursor=next_cursor)) ``` Import `ListNotesPage` from `generated.notes.operations` with the other generated names. See [Lists](https://jubasse.github.io/seampact/md/contract/05-lists.md). Source: https://jubasse.github.io/seampact/md/configuration/validators.md ## Validators: Other servers On AdonisJS, Laravel, Symfony and the Python servers, `rules` sits on the server entry. Each guide's options table says what it changes: [AdonisJS](https://jubasse.github.io/seampact/md/backend/adonisjs.md#options), [Laravel](https://jubasse.github.io/seampact/md/backend/laravel.md#options), [Symfony](https://jubasse.github.io/seampact/md/backend/symfony.md#options), [FastAPI](https://jubasse.github.io/seampact/md/backend/fastapi.md#options), [DRF](https://jubasse.github.io/seampact/md/backend/drf.md#options), [Ninja](https://jubasse.github.io/seampact/md/backend/ninja.md#options). Spring Boot has no `rules` option: [Java records](https://jubasse.github.io/seampact/md/types-and-validation/java-records.md) says what it checks. Source: https://jubasse.github.io/seampact/md/troubleshooting/generate.md When `generate` refuses a form, its code is in one of these tables (every code has a Fix). ## Python FastAPI, Django REST framework and Django Ninja. The forms refused by Spring Boot, PHP and Python are in their own table. | Code | Cause and fix | |---|---| | `options.indent.unsupported` | The Python targets take `rules`, `responses`, `bodyValidation`, `frameworkEtag` and `catchAll` (Ninja also `views`), and `indent` is 4. Remove the option. | | `artefacts.unknown` | The entry switches an artefact the target does not have. FastAPI and Ninja take `routeHelpers`, `bootCheck`, `accessHooks` and `nativeDocs`; DRF takes the same but `nativeDocs`. | | `artefacts.bootCheck.hooks.invalid`, `artefacts.bootCheck.frameworkRoutes.invalid`, `artefacts.bootCheck.invalid`, `artefacts..invalid` | The value is not one the key takes. `hooks` takes `error` or `off`, `frameworkRoutes` takes `true`, `false` or `strict`, `bootCheck` a boolean or a map, other artefacts a boolean. | | `nativeDocs.document.ambiguous` | The api declares several documents and the framework's document can follow only one. Set `document` on the entry, or drop `nativeDocs`. | | `artefacts.nativeDocs.operation-missing` | The document the entry names lacks an operation the entry generates. Set `document` to one that holds them all, or drop `nativeDocs`. | | `artefacts.nativeDocs.component-clash` | FastAPI: a contract schema is named `ValidationError` or `HTTPValidationError`, which FastAPI's own document holds. Rename the schema, or switch `nativeDocs` off for this api. | | `artefacts.nativeDocs.document-missing` | `seampact-ir` is older than the target. Install the same release of both. | | `access..unsupported`, `identity.principal.unsupported` | The access form is not generated yet, such as a signed identity on FastAPI, or a principal that is not one schema. Remove the operation from `operations`. | | `rule..unsupported` | DRF: a rule has no DRF check under `rules: native`. Use `rules: seampact-consistent`, which checks it, or remove the rule from that use. | | `format..unsupported`, `type..unsupported`, `deadline.header.type.unsupported` | A string format, a type or a deadline header type is not generated yet. Remove the operation from `operations`. | | `response.binary.unsupported`, `response.redirect.unsupported` | Downloads and redirects are not generated yet. Remove the operation from `operations`. | | `input.media.unsupported`, `body.kind.unsupported` | Form, multipart and file bodies, and a body that is not an object schema, are not generated yet. Remove the operation from `operations`. | | `schema.union.unsupported`, `schema.map.unsupported` | A `oneOf` or a map schema is not generated yet. Remove the operation from `operations`. | | `enum.open.unsupported`, `enum.narrowed.unsupported`, `unknownKeys.keep.unsupported`, `paging.handlerResult.unsupported` | An open or narrowed enum, kept unknown keys and some paging facts are not generated yet. Remove the operation from `operations`. | | `param.path.structured.unsupported`, `param.header.structured.unsupported`, `param.default.unsupported`, `member.default.unsupported` | A path or header parameter that is not a scalar or an enum, and a default value, are not generated yet. Remove the operation from `operations`. | | `path.param.type.unsupported` | A path parameter is not a string, a number or an enum. Remove the operation from `operations`. | | `path.segment.literal.unsupported`, `path.segment.mixed.unsupported` | A fixed path segment holds `{`, `}` or `?`, or a segment mixes a parameter with text. Write each segment as a whole parameter or whole text. | | `path.param.missing`, `name.segment.missing` | The IR is not one seampact wrote. Compile the contract again with the same seampact release, or update the target. | | `name.collision` | Two schemas, members or errors give the same Python name. Rename one in the contract, or give one a `codeName`. | | `principalRequires.unsupported`, `bindings.unsupported`, `upstream.unsupported` | The operation uses an access or relay form the Python targets do not generate yet. Remove the operation from `operations`. | | `features.unsupported` | A newer seampact compiler wrote this IR. Compile the contract with the seampact release the Python targets read. | ## Spring Boot, PHP and Python Spring Boot, Laravel, Symfony, FastAPI, Django REST framework and Django Ninja refuse these forms. The Spring Boot, PHP and Python pages link here. | Code | Cause and fix | |---|---| | `scope.source.unsupported` | A scope id is read from somewhere other than the path. Read it from the path, or remove the operation from `operations`. | | `principal.variants.unsupported` | The principal has several variants. Use one principal schema, or remove the operation from `operations`. | | `signature.application.unsupported`, `signature.key.source.unsupported` | A signature your application verifies, or a key read from the request, is not generated yet. Use a signature template with a key value, or remove the operation from `operations`. | | `fieldRight.nestedPointer.unsupported` | An input field right sits below the top level of the body. Move the right to a top-level field, or remove the operation from `operations`. | | `bucket.key.unsupported`, `rule.proof.placement.unsupported` | A rate-limit bucket key or a rule proof is read from a place the server does not read yet. Remove the operation from `operations`. | | `scalar.content.unsupported`, `response.media.unsupported`, `response.header.unsupported` | A JSON string content, a non-JSON response and a response header written by the route are not generated yet. Remove the operation from `operations`. | | `unknownKeys.keep.unsupported` | Kept unknown keys are not generated yet. Remove the operation from `operations`. | | `extensionKeys.unsupported` | The api declares typed extension keys, which are not generated yet. Removing operations does not clear it. Ask the user first: remove the api's `extensions` declarations and the extension values that use them. | | `options.handlers.unsupported`, `options.naming.unsupported` | `handlers` is `perResource` only, and the naming options (`suffixes`, `perOperation` and the like) are not options of these targets. Remove the option. | ## Every server Codes several servers share. The cause names the servers it applies to. | Code | Cause and fix | |---|---| | `format.success.envelope.unsupported`, `format.success.slot.unsupported`, `format.success.headers.unsupported`, `format.meta.unsupported` | Node servers, Spring, Laravel and Symfony: the format pack writes an envelope, slot, success header or meta member the server cannot write. Ask the user first: use a format whose envelope puts `$payload` under named members, with no meta member and no success header, or another server. | | `paging.response.slot.unsupported` | The page writes a slot the server does not fill: `$resource`, `$collection`, an item's id or URL, or `$page` under an offset style. Ask the user first: use a paging style whose page writes only its response roles, or remove the operation from `operations`. | | `paging.sortKey.right.unsupported` | Spring Boot, PHP and Python: a sort key guarded by a field right is not served. Leave the key out of `sortable`, or remove the operation from `operations`. | | `scheme.query.unsupported`, `scheme.basic.unsupported`, `scheme.signature.unsupported` | The servers read a credential from a bearer token, a cookie or a header only. Ask the user first: use a `bearer`, `cookie` or `header` scheme, or remove the operation from `operations`. | | `degradable.unsupported` | Node servers, Spring, Laravel and Symfony do not generate `degradable` operations yet. Ask the user first: remove `degradable`, or the operation from `operations`. | | `concurrency.etag.withoutWrite.unsupported` | A read writes an `ETag` for a resource whose `If-Match` writes sit in another api, so the server has no field to read the version from. Ask the user first: serve the read and its writes in one api, or remove the read from `operations`. | | `deprecated.successor.otherApi.unsupported` | A deprecated operation names a successor in another api, whose URL the server cannot write in its `Link` header yet. Ask the user first: name a successor in the same api, or drop the successor. | | `identity.response.header.unsupported` | The identity declares a response header (a `versionToken`, or one declared `in: response`) that no server writes yet. Ask the user first: remove the operations under that identity from `operations`. For a header other than a `versionToken`, you may instead declare it `in: request`. | | `right.extensions.unsupported`, `rule.extensions.unsupported`, `scope.extensions.unsupported` | Node servers, Spring, Laravel and Symfony: the decide and resolve requests do not carry custom metadata yet. Ask the user first: remove the `extensions` of that right, rule or scope, or the operations that use it from `operations`. | | `property.default.unsupported` | Every Node target, Spring, Laravel and Symfony: a body member's default is not filled, so the handler would receive it absent. Ask the user first: remove the `default`, or the operations that use the schema from `operations`. | | `param.query.structured.unsupported` | PHP and Python: a query parameter that is not a scalar, an enum or a list of them in the `form` style (an object, a list of lists, `deepObject`) is not read yet. Make the param a scalar, an enum or a list of them, or remove the operation from `operations`. | | `query.list.brackets.unsupported` | The fetch client and every server: a list sent as `tag[]=a&tag[]=b` is not read yet. Ask the user first: set `conventions.query.lists` to `repeat` or `comma`. | | `response.shape.unsupported` | The fetch client, Node servers, Spring, Laravel and Symfony do not generate download or redirect responses yet. Ask the user first: remove the operation from `operations`. | | `path.segment.encoded.unsupported` | A fixed path part has a space, a control or non-ASCII character, or one of `"` `<` `>` `^` `\` `{` `}` `|` `[` `]` or a backtick, which a client would send percent-encoded. Ask the user first: use plain ASCII letters, digits and `-` in that part. | | `artefacts.accessHooks.unsupported` | Laravel, Symfony, FastAPI, DRF and Ninja: `accessHooks: false` (native access) is not generated yet. Keep the hooks, and read the framework's own auth in the authenticate hook. | | `artefacts.bootCheck.hooks.warn.unsupported`, `artefact.bootCheck.hooks.warn.unsupported` | PHP, Python and Spring Boot (`artefact.` there): starting with a hook missing (`warn`) is not generated yet. Use `error` (the default) or `off`. | | `runtime.missing` | Node servers and Spring: the app does not install the runtime. Install `@seampact/server-runtime` (Node) or add `seampact-spring-runtime` (Maven; the Gradle plugin adds it) at the generator's version. | | `runtime.version-mismatch` | Node servers and Spring: the installed runtime has another `major.minor`, or an older patch, than the generator. Install it at the generator's version; a later patch passes. With Gradle, remove the BOM or forced version that overrides the plugin's. | | `runtime.generated-for-other-version` | See [Boot errors](https://jubasse.github.io/seampact/md/troubleshooting/boot.md). |