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
Context
Section titled “Context”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
retryableandoutcomeUnknown; bugs throw. The API maps each code to a problem type. - Texts (ADR 0013).
titleanddetailfollow the negotiated language, with English as the fallback.Content-Languagenames the language produced, and negotiated answers sendVary: Accept-Language.type,code, the status and every extension member are the same in every language. No technical message reachesdetail. - 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).apiis a pure route and schema factory that receives the use cases and the auth handler as interfaces. The document is exported offline toopenapi/v1.jsonand 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
RequestandResponseobjects.
What the libraries do by default, checked on the pinned versions:
@hono/zod-openapi1.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-reference0.12 renders a page that loads@scalar/api-referencefrom 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.
Decision
Section titled “Decision”The factory.
createApi(dependencies, options)returns a Hono app. The dependencies are interfaces:- the use cases, as the core’s types (
GetProperty); PrincipalResolver, whichauthimplements: a request gives aPrincipal(an id and aTenantScope),auth.unauthenticatedorauth.forbidden;AuthHandler, mounted at/auth/*;- the core’s
ClockandRandomSource, and aLogger.
- the use cases, as the core’s types (
- The options are the body limit and the reference page. The API reads no environment, and importing it does nothing (ADR 0010).
server/httpwires it;apps/webmounts 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 isproperty.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/v1for at least 12 months. packages/api/src/contract-compatibility.unit.test.tsholds the document to the contract of an earlier revision (packages/api/test/contract/v<revision>.json, written bypnpm --filter @guestcraft/api contract:baseline): the same operations, parameters, members and types, with additions only.- Deprecation uses the
DeprecationandSunsetheaders. - The document of v1 has
servers: [{ url: "/api/v1" }]and paths without the version.info.versionis 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.reverseandfolios.rerate. Their 200 answer becomesFolioWriteResult: aFoliofor a caller that holdsfolios.readfor the folio’s property, or aFolioAcknowledgementwith onlyid,workspace_id,property_idandreservation_idfor 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
movementson the result no longer compiles, so the revision is 4.0.0, not a minor. - Criteria. A change of this kind is allowed under
/v1only 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 integrationfolios.readfor 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:
- Only statuses proved impossible by the code path are removed, pinned by the global
412/
If-Matchtest or a per-operation exact-problem test. - A comment-stripped comparison proves that the only generated type differences are removed
keys of the listed
*Errorsmaps; schemas, parameters and success types stay identical. - Web and mobile consumers typecheck.
- 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
operationIdis a namespace and a method in camelCase,properties.getorreservationRooms.checkIn: the SDK’sgc.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’sid.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_MEMBERSlists for the code, so a member added to a problem in the core or inauth, 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-Languagestays 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 anError, such as a rejected string ornull, is wrapped in one, with the value as itscause, 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, whichserverimplements: 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-openapithat 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.
- every code that the core or the API declares as
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:
- Request id. The client’s
Request-Idis kept when it has 1 to 128 letters, digits,.,_,:or-. Otherwise a TypeIDreq_…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. - 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: truewhen it was cut, so a line is always printable and bounded. The query string is never logged, because searches can carry guests’ names. - Headers. Every answer of the API itself has
X-Content-Type-Options: nosniffand, unless the route chose another,Cache-Control: no-store: an answer can carry a tenant’s data, andAuthorizationkeeps shared caches away from it but not a browser’s./v1/openapi.jsonispublic, max-age=300. The auth handler’s answers are its own; HSTS and the other headers of the site belong toserver. - Errors only. Anything thrown that is not an
Erroris wrapped in one, because Hono gives only errors to the error handler. - Language.
Accept-Languagechooses the catalog with@guestcraft/i18n’snegotiate. Only problems use it, so only they sendContent-LanguageandVary: Accept-Language. - 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. - 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.OPTIONSanswers 405 too, until the API has CORS. - Body limit. 1 MiB by default (
maxBodyBytes): byContent-Length, or while a body without one streams. AContent-Lengthis digits only, so00is zero; any other isrequest.invalid, with the header as its field. Bulk imports and ARI updates are asynchronous jobs (analysis, chapter 9). - JSON only, for
/v1. A body isapplication/jsonorapplication/*+json, such asapplication/merge-patch+json. A request has a body when it says so: aContent-Lengthabove zero, aTransfer-Encoding, or a stream with aContent-Type./auth/*is outside this rule, for the form posts of Better Auth. - Authentication, per route. It runs before validation, so a request without credentials learns nothing about the schema.
- Validation. Then the handler.
Lists, when they arrive.
- Parameters:
limit, from 1 to 100, 20 by default, andcursor. - The answer is
{ data, next_cursor, has_more }.next_cursoris 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=truewith tombstones kept 90 days, and the feedGET /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
defaultanswer: “Any other error, as a problem detail; seecode.” The shared 405 and 415 answers are described once undercomponents.responses. Every operation also references the body limit’s 400request.invalidanswer for a malformedContent-Length; methods that carry a body also reference 413request.too_large.request_idis a required member ofProblem, and the identifiers of a resource carry their TypeID pattern. pnpm --filter @guestcraft/api openapiwrites it toopenapi/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 ashttps://hotel-rossi.vercel.appin 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, withintegrity="sha256-…"andcrossorigin="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 setsstyleattributes, 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 hasReferrer-Policy: no-referrer, and theno-storeof every answer. In a browser, the page renders under this policy; the one report is Zod’s probe forFunction, which Scalar catches. - Telemetry, Scalar’s fonts and its request proxy are off, so the page sends nothing to Scalar.
- It names
openapi.jsonby 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.jsonas 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
customFetchhands Scalar that copy when it fetchesopenapi.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,
customFetchfetchesopenapi.jsonitself: the page is then as slow as before, but whole. - The reference thus shows the committed document, while
/v1/openapi.jsonserves the one built from the routes; CI keeps the two equal by checking thatopenapi/v1.jsonis current. Underpnpm dev, the reference shows the committed document as it was when the server started. - The copy shares under
componentseach 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 fromopenapi/v1.jsonas the build does and checks it: the same operations and, read through its references, every parameter, schema, example, description andx-guestcraft-*extension. Only Scalar’s own keys and one rewrite of Scalar’s store may differ: an emptyadditionalPropertiesschema becomesfalse, as it does when Scalar loadsopenapi.jsonin 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.jsonitself, as before.
- The web build publishes, beside the bundle, the committed
- An installation can serve its own copy at a path (
reference.scriptUrl), or from another origin with itsintegrity, or turn the page off (reference: false).createApirefuses a URL that is nothttps: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.
apiimportscoreandi18n.i18nis 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:
honoand@hono/zod-openapi. Scalar’s bundle (MIT) is a development dependency ofapi, for the test of its hash, and ofweb, whose build publishes it. Scalar’s store (@scalar/workspace-store, MIT), at the version the bundle pins, is a development dependency ofweb, 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.
Consequences
Section titled “Consequences”- 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.jsoncome from one document, which a pull request regenerates withpnpm --filter @guestcraft/api openapi. - The domain’s field names reach clients through problem members, such as
field: "timeZone"ofproperty.invalid. When a route takes such a field in its body, the route maps it to the API’s name inerrors. - 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/docssets its own Content Security Policy. Whenserversets one for the site, it must leave this page’s alone, or allow the same nonce and script URL.
Alternatives considered
Section titled “Alternatives considered”- 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 retiresX-prefixes, and the analysis namesRequest-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
scriptUrloption 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.OPTIONSbelongs 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.mdputs it inopenapi/, MIT and outside an AGPL package, where the SDK’s generator reads it.