Salta ai contenuti

0016. Build the HTTP API as a factory of interfaces, with one table of problems and one versioned contract

Questi contenuti non sono ancora disponibili nella tua lingua.

  • Status: accepted
  • Date: 2026-10-26

Chapter 9 of the analysis (090-api.html) fixes the conventions of the public API: REST resources with explicit actions, JSON in snake_case, TypeID identifiers, cursor pagination, RFC 9457 errors with a stable code, the major version in the URL, a Request-Id on every answer. Chapter 7 chooses Hono with @hono/zod-openapi and Scalar for the reference. Chapter 10 makes the OpenAPI document the source of the SDK. The decisions already taken that this layer must follow:

  • Errors (ADR 0008). Use cases return expected problems with a stable code; infrastructure failures are thrown with retryable and outcomeUnknown; bugs throw. The API maps each code to a problem type.
  • Texts (ADR 0013). title and detail follow the negotiated language, with English as the fallback. Content-Language names the language produced, and negotiated answers send Vary: Accept-Language. type, code, the status and every extension member are the same in every language. No technical message reaches detail.
  • Wire values (ADR 0007, ADR 0012). Identifiers are TypeIDs; dates of stays are ISO dates; amounts are decimal strings with their currency; nothing formatted for people, except problem texts.
  • The package (repository.md, sections 3 and 7). api is a pure route and schema factory that receives the use cases and the auth handler as interfaces. The document is exported offline to openapi/v1.json and is MIT.
  • Runtimes. The same app runs in a Vercel function, which accepts bodies of up to 4.5 MB, and in Node.js in Docker. Both give the app standard Request and Response objects.

What the libraries do by default, checked on the pinned versions:

  • @hono/zod-openapi 1.6 generates OpenAPI 3.1 from Zod 4 schemas. It skips validation of a JSON body sent with another media type, unless the body is required.
  • @scalar/hono-api-reference 0.12 renders a page that loads @scalar/api-reference from jsDelivr. Without a pinned URL it loads the latest version. Telemetry is on, and the page loads fonts from Scalar’s servers. It has no option for Subresource Integrity.
  • Better Auth receives form posts on some routes: a SAML response, an OAuth form_post. They are not JSON.

The factory.

  • createApi(dependencies, options) returns a Hono app. The dependencies are interfaces:
    • the use cases, as the core’s types (GetProperty);
    • PrincipalResolver, which auth implements: a request gives a Principal (an id and a TenantScope), auth.unauthenticated or auth.forbidden;
    • AuthHandler, mounted at /auth/*;
    • the core’s Clock and RandomSource, and a Logger.
  • The options are the body limit and the reference page. The API reads no environment, and importing it does nothing (ADR 0010). server/http wires it; apps/web mounts it under /api.
  • The core’s ports describe use cases, so the core gains them as endpoints need them. The first is getProperty, on the unit of work. A property of another workspace is property.not_found, like one that does not exist: an answer never reveals what another tenant has.

Versions.

  • The major version is the first segment of the path: /v1. A v1 contract changes only by additions. Removing or changing a field, a parameter or an endpoint waits for /v2, which lives next to /v1 for at least 12 months.
  • packages/api/src/contract-compatibility.unit.test.ts holds the document to the contract of an earlier revision (packages/api/test/contract/v<revision>.json, written by pnpm --filter @guestcraft/api contract:baseline): the same operations, parameters, members and types, with additions only.
  • Deprecation uses the Deprecation and Sunset headers.
  • The document of v1 has servers: [{ url: "/api/v1" }] and paths without the version. info.version is the revision of the contract, 1.<minor>.<patch>: API_REVISION. Its minor grows with each addition.

The 28 October 2026 owner-directed removal of an unwanted module is an exception to the addition-only rule. Its contract revision is 2.0.0 because operations and a guest field disappear. The remaining routes retain their existing /api/v1 URLs for installed clients; the withdrawn operations have no coexistence period. Future additive revisions resume from this revision. This exception does not establish a general removal policy.

The 30 October 2026 security redaction of folio write answers is a second exception. A caller with a folio write grant but without folios.read received the whole folio in the write’s answer, and could read it at will through an exact retry or a re-rate that changes nothing (PR #221, finding 5). The fix is recorded in ADR 0036’s amendment of 30 October 2026.

  • Affected operations. folios.postExtra, folios.pay, folios.refund, folios.reverse and folios.rerate. Their 200 answer becomes FolioWriteResult: a Folio for a caller that holds folios.read for the folio’s property, or a FolioAcknowledgement with only id, workspace_id, property_id and reservation_id for any other caller.
  • Why it is an exception. For write-only callers the answer loses its movements, totals, taxes and balance, and the generated SDK’s return type becomes a union. Code that reads movements on the result no longer compiles, so the revision is 4.0.0, not a minor.
  • Criteria. A change of this kind is allowed under /v1 only when all of these hold:
    • it closes a security defect, and has no other purpose;
    • a caller with the read permission sees no change;
    • the status and the headers are unchanged;
    • the OpenAPI document states the answer as a union of the previous schema and the reduced one.
  • Migration. Narrow the result before reading money from it, for example with "movements" in result; or give the integration folios.read for the property, and it receives the folio as before.

This exception does not establish a general removal policy. Any other removal or change still waits for /v2.

Performance definition exception (31 October 2026). Revision 5.0.0 is a third explicit exception to /v1’s addition-only rule. Performance definition 2 replaces the current-inventory denominator with historical room/type/OOO capacity. The closed definition_version value changes from 1 to 2, and capacity_basis from current_active_units to historical_active_units; the older SDK’s response validator rejects these values and TypeScript checks must change. No release exists and the SDK is unpublished, so the server and its workspace clients change together without a coexistence period. Upgrade client SDKs and definition checks together with the server. Existing performance CSV columns keep their order; new metadata and pre_history_nights are appended. Older performance ranges remain supported using labelled baseline capacity; only the new pickup report requires captured observation history. See ADR 0031 for the definitions and migration. This exception does not establish a general breaking-change policy; other changes still wait for /v2.

Maintenance ticket exception (31 October 2026). Revision 6.0.0 is a fourth explicit exception to /v1’s addition-only rule, declared as a major revision. It removes three operations of the kebab-case ticket collection:

  • GET /properties/{property_id}/maintenance-tickets (maintenanceTickets.list);
  • POST /properties/{property_id}/maintenance-tickets (maintenanceTickets.create);
  • PATCH /properties/{property_id}/maintenance-tickets/{id} (maintenanceTickets.update), which also set the ticket’s status.

They return under the snake_case collection /properties/{property_id}/maintenance_tickets that this ADR’s names require, with the same operation IDs; the status is changed only through the new PUT …/maintenance_tickets/{id}/status, where closing names its housekeeping follow-up, and the status enum gains in_progress. No alias keeps the old paths: no release exists and the SDK is unpublished, so the server and its workspace clients change together. Clients regenerate the SDK; callers of the old PATCH with status move to the status operation. See the breaking-change paragraph of ADR 0046 and the 6.0.0 entry of the HTTP API guide. This exception does not establish a general breaking-change policy; other changes still wait for /v2.

Correcting documented responses that cannot occur (2026-10-30). Revision 4.4.0 removes documented responses only when the operation’s code path proves that the status cannot occur. This is a documentation correction rather than a removal: no client can receive an impossible response, and the SDK is not yet published (ADR 0022). The generated SDK error maps that narrow are NightAuditsPrecheckErrors, NightAuditsListErrors, NightAuditsCreateErrors, NightAuditsGetErrors, TeamListErrors, TeamInviteErrors, TeamResendErrors, TeamRevokeErrors, TeamCustomRolesErrors, TeamCreateRoleErrors, TeamAuditErrors, TeamAcceptErrors, BlocksListErrors, BlocksCreateErrors, MaintenanceTicketsListErrors and MaintenanceTicketsCreateErrors. Every other generated type, including schemas, parameters, successful responses and their types, remains identical. A client that indexed a removed key, such as NightAuditsPrecheckErrors[412], must drop that access and handle only statuses the operation can return. The global contract tests in packages/api/src/openapi.unit.test.ts pin the 412/If-Match rule.

The coordinator authorized this correction only when all four criteria hold:

  1. Only statuses proved impossible by the code path are removed, pinned by the global 412/If-Match test or a per-operation exact-problem test.
  2. A comment-stripped comparison proves that the only generated type differences are removed keys of the listed *Errors maps; schemas, parameters and success types stay identical.
  3. Web and mobile consumers typecheck.
  4. The correction uses minor revision 4.4.0, with this dated note, its map list and migration.

This exception does not establish a general removal policy. Any other removal or change still waits for /v2.

Current revision note (2026-11-02). The current API_REVISION is 6.15.0, including the additive night-audit automation, cash-shift and policy undo-availability contracts (5.2.0), the integrations contracts (5.3.0), the guest merge, consent and review contracts (5.4.0), the split-folio, transfer, per-room settlement and re-rating contracts (5.5.0), the maintenance ticket exception above (6.0.0, with migration 0039 as its contract migration), the planning resize, Do not move and Undo contracts (6.1.0), the API platform’s organization tokens, client sessions and operation effects (6.2.0), the Channex ARI and booking-import completion (6.3.0) organization and workspace administration (6.4.0), the Messages integration with the API platform (6.5.0), housekeeping planning, automatic assignment and checklists (6.6.0), occupancy pricing, child age bands and packages (6.7.0), the Channex environment per workspace (6.8.0), which replaces request.invalid by channex.key_refused for a key Channex refuses, as 4.2.0 and 4.5.0 replaced codes with precise refusals, receivables, cancellation trends and consolidated reports (6.9.0), guest data rights (6.10.0), Activity keyset paging and exports and account security (6.11.0), OAuth applications, organization permissions, narrower client sessions and event versions (6.12.0), online payments (6.13.0), enterprise identity (6.14.0), and rate-plan sales caps, the retirement of live Channex targets, reports of OTA bookings and Messages history paging (6.15.0). The value in packages/api/src/openapi.ts and the checked-in OpenAPI document are the source of truth for the current revision after this exception and its subsequent additive revisions. Later revisions correct further documented responses that cannot occur under the same four criteria; their exact-list tests and compiler-checked problem lists in packages/api are the authoritative list of what they remove.

Names.

  • JSON members and query parameters are snake_case.
  • Paths are plural resources in snake_case (/properties/{id}), with domain actions as sub-paths (/reservation_rooms/{id}/check_in).
  • An operationId is a namespace and a method in camelCase, properties.get or reservationRooms.checkIn: the SDK’s gc.properties.get(). A test checks the form and that each is unique.
  • A path parameter that is an identifier is parsed by the core’s parseId. A wrong one is the core’s id.invalid, with its reason, and the schema gives the TypeID pattern to clients.
  • Codes are dotted and lower case: <context>.<what>.

Money on the wire. An amount is a decimal string with the decimals of its ISO 4217 currency, next to that currency: { "amount": "120.00", "currency": "EUR" } (ADR 0012). This supersedes the integer example of the analysis, chapter 9 ({"amount": 12000, "currency": "EUR"}):

  • no floats: a string carries the exact amount, which JavaScript’s numbers would not above 2^53, and no client is tempted to do arithmetic in binary floating point;
  • readable: "120.00" is what a person, a log or a support ticket shows;
  • independent of the client: an integer of minor units means nothing to a client that does not know how many decimals each currency has (none for JPY, three for KWD), and that table changes;
  • the same as Channex, whose amounts are decimal strings, so values pass between the two without a conversion.

The core keeps integer minor units (ADR 0007); the API converts at its edge.

Problems. Every error, of /v1 and of paths outside it, is application/problem+json (RFC 9457) with these members:

Member Value
type urn:guestcraft:problem:<code>: a URN, stable everywhere GuestCraft runs and owned by no domain, so that no one can take it over; a client compares it as a string
title, detail problems.<code>.title and .detail of @guestcraft/i18n, in the negotiated language
status from the table below
code the stable code
request_id as in the Request-Id header
the problem’s members in snake_case, such as reason or max_bytes; they are also the arguments of the texts. Only those that PROBLEM_MEMBERS lists for the code, only strings, numbers and booleans, and never one of the members above, instance, in or pointer
errors for request.invalid only: one entry per field, with in (path, query, header, cookie, body), pointer (RFC 6901, inside its location), code (field.required, field.invalid, or the core’s code, such as id.invalid), detail, and the members of its problem
  • A problem’s members are public. The API copies into the body only the members that PROBLEM_MEMBERS lists for the code, so a member added to a problem in the core or in auth, such as a workspace or a key hash, stays out until someone decides it may be published. Whoever adds a member that clients should see adds it to that list. A test checks that every argument of a problem’s English title and detail is listed, and that no list names a member of the format. The problems of the installation (rules.invalid, country_packs.invalid) list none: the log has them.
  • If one text is missing in the negotiated language, every text of the answer is in English, so that Content-Language stays true.
  • The status of each code is in one table, PROBLEM_STATUS. A route never chooses a status.
Status Codes When
400 request.invalid, request.malformed the request does not match its schema; the body is not valid JSON
401 auth.unauthenticated no valid credentials; with WWW-Authenticate: Bearer
403 auth.forbidden credentials that do not allow this request in this workspace
404 route.not_found, property.not_found no route has the path; no such resource in the caller’s scope
405 route.method_not_allowed the path exists, not with this method; with Allow
413 request.too_large the body is larger than the limit
415 request.unsupported_media_type a body that is not JSON
422 the core’s invalid values: date.invalid, money.invalid_amount, property.invalid and the others; cash_rounding.not_applicable the request matched its schema, and the core refused a value or an operation
500 server.internal, rules.invalid, country_packs.invalid a bug, an infrastructure failure that a retry cannot fix or whose outcome is unknown, or a problem of the installation
503 server.unavailable an InfrastructureError with retryable and without outcomeUnknown; with Retry-After in whole seconds, from 1 to 3600, when the adapter knows a finite delay of zero or more
  • A thrown error that is not an infrastructure failure gives server.internal. The answer carries nothing of the error: no message, name or stack. The log has the error with the request id, the method and the path. A thrown value that is not an Error, such as a rejected string or null, is wrapped in one, with the value as its cause, so it gets the same answer and log line.
  • Logs. The API hands the logger the raw error and its cause, which for a database error can carry row values. Redacting them is the job of the server’s logger, which server implements: chapter 18 allows only identifiers in logs. The API itself logs no query string, no body and no header but the request id.
  • The answer of a problem is typed with the status of its code. A route of @hono/zod-openapi that returns a problem must document that status, or the code does not compile.
  • Tests check the table in both directions:
    • every code that the core or the API declares as Problem<"…"> has a status;
    • every code has a title and a detail in every catalog;
    • every problem of the catalogs is a code of the table or a field code.

Paths. The app routes on the raw path of the request: percent-encoded, as it arrived, without the query (rawPath). Hono’s default decodes the path first, and its router then misses a path with a decoded line break, so no middleware ran on it. Route literals and identifiers are ASCII, and c.req.param() still decodes the parameters.

Middleware, in this order:

  1. Request id. The client’s Request-Id is kept when it has 1 to 128 letters, digits, ., _, : or -. Otherwise a TypeID req_… is generated with the clock and the random source. Since every path reaches the middleware, it is set on every answer of the app, whatever its path or method, the auth handler’s and the 500s included, and it is in every problem and every log line.
  2. Access log. One line per request: the id, the method, the raw path, the status and the duration. The path is at most 2,048 characters, with path_truncated: true when it was cut, so a line is always printable and bounded. The query string is never logged, because searches can carry guests’ names.
  3. Headers. Every answer of the API itself has X-Content-Type-Options: nosniff and, unless the route chose another, Cache-Control: no-store: an answer can carry a tenant’s data, and Authorization keeps shared caches away from it but not a browser’s. /v1/openapi.json is public, max-age=300. The auth handler’s answers are its own; HSTS and the other headers of the site belong to server.
  4. Errors only. Anything thrown that is not an Error is wrapped in one, because Hono gives only errors to the error handler.
  5. Language. Accept-Language chooses the catalog with @guestcraft/i18n’s negotiate. Only problems use it, so only they send Content-Language and Vary: Accept-Language.
  6. Control characters. A path with a C0 control character or DEL, percent-encoded, is request.invalid (400), with the path as its field. No path of the API has one, and it has no place in a log.
  7. Method not allowed. A 405 with Allow, instead of a 404. It is decided after the rest has answered, from the 404 of a path that exists with other methods, so it is given only when the method is the only thing wrong: a body that breaks the rules below is a 413 or a 415 first. OPTIONS answers 405 too, until the API has CORS.
  8. Body limit. 1 MiB by default (maxBodyBytes): by Content-Length, or while a body without one streams. A Content-Length is digits only, so 00 is zero; any other is request.invalid, with the header as its field. Bulk imports and ARI updates are asynchronous jobs (analysis, chapter 9).
  9. JSON only, for /v1. A body is application/json or application/*+json, such as application/merge-patch+json. A request has a body when it says so: a Content-Length above zero, a Transfer-Encoding, or a stream with a Content-Type. /auth/* is outside this rule, for the form posts of Better Auth.
  10. Authentication, per route. It runs before validation, so a request without credentials learns nothing about the schema.
  11. Validation. Then the handler.

Lists, when they arrive.

  • Parameters: limit, from 1 to 100, 20 by default, and cursor.
  • The answer is { data, next_cursor, has_more }. next_cursor is null on the last page.
  • The order is total: the sort key, then the identifier to break ties. Identifiers of the same millisecond have no order of their own (ADR 0007).
  • The cursor is opaque. The server encodes the position and a digest of the workspace, the filters and the order. A cursor used with other filters or in another workspace is refused with cursor.invalid (400). It needs no signature: authorization and row-level security apply to every page. Clients never build or read it.
  • Incremental sync: updated_after, include_deleted=true with tombstones kept 90 days, and the feed GET /changes, as in chapter 9.

The document.

  • It is generated from the schemas of the routes, with @hono/zod-openapi.
  • Each operation documents its own problems by status, and a default answer: “Any other error, as a problem detail; see code.” The shared 405 and 415 answers are described once under components.responses. Every operation also references the body limit’s 400 request.invalid answer for a malformed Content-Length; methods that carry a body also reference 413 request.too_large. request_id is a required member of Problem, and the identifiers of a resource carry their TypeID pattern.
  • pnpm --filter @guestcraft/api openapi writes it to openapi/v1.json. The export needs no database, credentials or worker: its dependencies refuse to be called.
  • It is committed, MIT (REUSE.toml), and left out of Biome as a generated file. A test fails when it is not exactly what the export writes.
  • The app serves the same document at /v1/openapi.json.
  • It keeps servers: [{ url: "/api/v1" }]. The SDK’s hand-written layer takes the instance’s origin, such as https://hotel-rossi.vercel.app in chapter 10, and appends /api/v1; the generated client only sees the full base URL. So the document does not change from one instance to another.

The reference is Scalar at /v1/docs. The page lives in the instance’s origin, next to the dashboard and its cookies, so the API renders it itself (reference.ts) instead of with @scalar/hono-api-reference:

  • It loads one file, Scalar’s standalone bundle 1.75.0 (SCALAR_SCRIPT), from the instance itself at /assets/scalar/1.75.0/standalone.js, with integrity="sha256-…" and crossorigin="anonymous". The web build writes it there from the installed package (apps/web/scripts/vendor-scalar.ts), behind Scalar’s MIT notice, as the documentation site does; the hash is that file’s, and a test recomputes it from the installed package. The page contacts no other host. (Until October 2026 it loaded the file from jsDelivr, which saw every reader’s address and could withhold the page.)
  • Both scripts, the bundle and the inline configuration, carry a nonce of 128 bits from the injected random source, new for each request.
  • The answer has a Content-Security-Policy: default-src 'none'; scripts only with the nonce; style-src 'unsafe-inline', because Scalar injects its styles and sets style attributes, which a nonce cannot allow; img-src 'self' data:; connect-src 'self', so the page reaches only the instance; no fonts, frames, forms or <base>. It also has Referrer-Policy: no-referrer, and the no-store of every answer. In a browser, the page renders under this policy; the one report is Zod’s probe for Function, which Scalar catches.
  • Telemetry, Scalar’s fonts and its request proxy are off, so the page sends nothing to Scalar.
  • It names openapi.json by a relative URL, wherever the app is mounted, and its “Download OpenAPI Document” links there (documentDownloadType: "direct"). Scalar’s own export as JSON or YAML is off: it would serialize the copy below rather than the published document.
  • Scalar no longer bundles, coerces or builds the navigation of the document in the browser. Before it showed anything, it bundled the references of the whole document, coerced it to its schema and built its navigation: about 2 s of the 3 s until the title showed, for 4.1 MB and 377 operations.
    • The web build publishes, beside the bundle, the committed openapi/v1.json as Scalar’s store holds it once loaded (document.json), with its navigation. It runs the store of the version the bundle pins, and a test checks that version. Given a document with its navigation, the store skips that work.
    • The page’s customFetch hands Scalar that copy when it fetches openapi.json; every other request goes where Scalar sends it.
    • When the copy cannot be fetched, answers with an error or is not JSON with Scalar’s navigation, customFetch fetches openapi.json itself: the page is then as slow as before, but whole.
    • The reference thus shows the committed document, while /v1/openapi.json serves the one built from the routes; CI keeps the two equal by checking that openapi/v1.json is current. Under pnpm dev, the reference shows the committed document as it was when the server started.
    • The copy shares under components each response, parameter and response header that operations repeat, which halves what Scalar validates and walks. Read through its references, it is the same document. A test of the web app (test/integration/reference-document.integration.test.ts) makes the copy from openapi/v1.json as the build does and checks it: the same operations and, read through its references, every parameter, schema, example, description and x-guestcraft-* extension. Only Scalar’s own keys and one rewrite of Scalar’s store may differ: an empty additionalProperties schema becomes false, as it does when Scalar loads openapi.json in the browser.
    • The build and the page give the document the same name (slug), which starts every identifier of its navigation, so that a link to a section opens it.
    • On a production build, the title and the navigation show in about 1.2 s on an idle machine and 4.5 s with the CPU four times slower, instead of 3.0 s and 10.4 s. An installation’s own copy of Scalar processes openapi.json itself, as before.
  • An installation can serve its own copy at a path (reference.scriptUrl), or from another origin with its integrity, or turn the page off (reference: false). createApi refuses a URL that is not https: or a path on the instance, one with a query, quotes, spaces or semicolons, and a hash that is not SHA-2, so a typo cannot break out of the attribute or the policy.

Dependencies.

  • api imports core and i18n. i18n is MIT, so the direction AGPL → MIT holds (ADR 0004). It gives the catalogs and the language negotiation, both pure and without I/O.
  • Texts are formatted with use-intl/core, as ADR 0013 says. pnpm installs React as a peer of use-intl; the API never imports it.
  • New libraries, both MIT: hono and @hono/zod-openapi. Scalar’s bundle (MIT) is a development dependency of api, for the test of its hash, and of web, whose build publishes it. Scalar’s store (@scalar/workspace-store, MIT), at the version the bundle pins, is a development dependency of web, whose build runs it.

Coverage. packages/api/src/** must reach 95/90/95/95, above the 90/85/90/90 that ADR 0005 sets for api. The gates test pins it.

  • An endpoint is a schema, a call to a core port and the problems it documents. The status of each problem is decided once, and the compiler and the tests keep the routes, the table and the catalogs in step.
  • A new problem code in the core fails the tests until it has a status and texts in every catalog.
  • The SDK, the reference and /v1/openapi.json come from one document, which a pull request regenerates with pnpm --filter @guestcraft/api openapi.
  • The domain’s field names reach clients through problem members, such as field: "timeZone" of property.invalid. When a route takes such a field in its body, the route maps it to the API’s name in errors.
  • Still to come, with their layers:
    • Spectral rules on the document, and oasdiff against the last release;
    • the discovery endpoint (server_version, api_revision, capabilities);
    • idempotency keys, ETags, rate limits and CORS;
    • the permissions of a principal beyond its tenant scope.
  • /v1/docs sets its own Content Security Policy. When server sets one for the site, it must leave this page’s alone, or allow the same nonce and script URL.
  • Problems with type: "about:blank" and only a code. Rejected: RFC 9457 recommends a type URI that people can read, and the code already gives machines a stable key.
  • 422 for every invalid request. Rejected: 400 says that the request does not match its documented schema, and 422 that the core refused a value of a valid request. Clients can treat the two apart.
  • X-Request-Id. Rejected: RFC 6648 retires X- prefixes, and the analysis names Request-Id.
  • Passing the catalogs in as a dependency. Rejected: they are data with no I/O, and injecting them adds wiring without isolating anything.
  • Serving Scalar’s bundle from the package. Rejected as the default: a bundle of several megabytes in every server artifact. The scriptUrl option lets an installation self-host it.
  • Keeping @scalar/hono-api-reference. Rejected: it cannot add an integrity hash, so whoever could change the CDN’s answer could act as any user who opened the docs. The page is a short template, and rendering it drops the integration and the eight packages it pulled in.
  • Answering 405 before the body rules, or 204 to OPTIONS. Not now: a 405 decided after the rest is simpler and only hides itself behind a 413 or a 415, which the client must fix anyway. OPTIONS belongs to CORS, which comes with its own layer.
  • Scalar’s options for a faster page alone, such as hidden clients and models or a closed first tag. Measured, they changed nothing: the time went into processing the document, not into rendering it.
  • The document of Scalar’s server store, whole or in chunks. Its chunks render in 0.9 s but leave each operation to a later request: search no longer finds parameters, bodies or descriptions, and the download gets the skeleton. Its whole document drops the operations’ x-guestcraft-* extensions and skips part of what the browser’s store does; the browser’s store, run by the build, gives what the browser would hold.
  • Processing the document in the API when it is first asked for. Rejected: seconds of CPU on the first request of each server instance, and the store and its dependencies in every server artifact.
  • Committing the processed document. Rejected: a second generated file of 1.4 MB that changes with every change of the contract. The build derives it from the committed document.
  • Preloading the processed document. Measured without gain, also on a slow network: it shares the connection with the bundle, which the page needs first.
  • Sharing the repeated answers in the published document. It would make the document smaller for every client, but it changes the shape of the public contract that the SDK’s generator and the compatibility baseline read. It is a separate decision.
  • Swagger UI or Redoc. Both are free. The analysis chose Scalar for its integrations with Hono and Next.js, and Redoc’s free edition has no requests from the page.
  • Offset pagination. Rejected: pages shift when rows are added, and offsets get slow on large tables.
  • The document at packages/api/openapi.json. Rejected: repository.md puts it in openapi/, MIT and outside an AGPL package, where the SDK’s generator reads it.