When to use it
Web and mobile both need new data or a new action, for example the notes of the
signed-in user. The steps use GET /v1/notes as the example.
Rules to keep
contracts/openapi.jsoncomes first: change the contract, then the code on both sides. Do not define the type separately in TypeScript and Dart.- Adding an operation or an optional field is compatible and stays in
/v1. Removing or renaming a field, or changing its type, needs a new/v2path next to the old one. apps/mobile/lib/api/generated/product_api.dartis never edited by hand: change the generator and runnpm run contract:generate.- The server decides who the user is and what they may read. The handler
accepts the session cookie (web) or
Authorization: Bearer <token>(mobile), and RLS limits the rows. Never trust a user id or role sent by the client.
Steps
If the data is new, add a table in a new file under
supabase/migrations/(append-only), with RLS enabled and one policy per allowed operation, as insupabase/migrations/202609280001_shared_product_contract.sql. Describe it inDATABASE.md.In
contracts/openapi.jsonadd the path with a stableoperationIdand the schema undercomponents.schemas. Wrap lists in an object, so a field can be added later without breaking clients:"/v1/notes": { "get": { "operationId": "listNotes", "responses": { "200": { "description": "Notes of the signed-in user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NoteList" } } } } } } }NoteListhas a requireditemsarray ofNote;Notehas the required fieldsid(uuid),bodyandcreatedAt(date-time). Raiseinfo.version(minor for a compatible change).tool/generate-dart-client.mjsis a small generator written for this contract: it writes the Dart classes from a fixed text and reads only the enum values from the contract. Add to that text theNoteclass with afromJsonfactory (DateTime.parseforcreatedAt) and the methodFuture<List<Note>> listNotes();inProductApi. If the schema has an enum, build the values from the contract as forBillingStatus.Run
npm run contract:generate.npm run contract:checkonly compares the Dart file with the generator output, so also extendtool/test-openapi-contract.mjs: add the path to the checked list and assert the required fields that the Dart class reads.Create
apps/web/app/api/v1/notes/route.tsfollowingapps/web/app/api/v1/me/route.ts: read the bearer token, pass it tocreateSupabaseServerClient(token)so the queries run as that user, callauth.getUser(token)and answer 401 without a user. Query the table without filtering on a user id from the request, and map the columns to the contract names (created_atbecomescreatedAt).GEThandlers are dynamic by default: do not add caching to responses that depend on the user.In
apps/mobile/lib/api/supabase_product_api.dartimplementlistNoteswith_get('/api/v1/notes')(Next.js serves the contract under/api) and readitemswithNote.fromJson.Add tests in
apps/mobile/test/supabase_product_api_test.dartwithMockClient: the request carries the bearer token, the response is parsed, and a non-200 status throwsProductApiException.Document the operation in
API.md.
Done when
- the verification commands pass, and
npm run contract:checkreports no difference afternpm run contract:generate; - the handler answers 401 without a session or token and returns only the rows of the signed-in user, from the browser and with a bearer token;
API.mddescribes the new operation.
