Skip to content

fix: improve generated OpenAPI spec quality for SDK generation#1215

Draft
grdsdev wants to merge 14 commits into
masterfrom
claude/reverent-ramanujan-32755f
Draft

fix: improve generated OpenAPI spec quality for SDK generation#1215
grdsdev wants to merge 14 commits into
masterfrom
claude/reverent-ramanujan-32755f

Conversation

@grdsdev

@grdsdev grdsdev commented Jul 7, 2026

Copy link
Copy Markdown

Summary

Evaluated the OpenAPI spec generated from this service's route schemas (npm run docs:export) against what SDK generators actually need, and fixed the issues found. Each fix is its own commit so the change and its rationale are easy to review independently.

  • operationId on every operation (0ebdf6e) - was missing on all 80 operations, which is what generators use to name SDK methods. Derived from the existing ROUTE_OPERATIONS constants via a shared @fastify/swagger transform instead of touching every route file.
  • Rename the wildcard path param from * to wildcard (0832ef9) - Fastify's catch-all route segment leaked straight into the spec as a parameter literally named *, which isn't a legal identifier in any target language. Doc-only rename; the live Fastify route/validation schema is untouched.
  • Register bucketSchema/objectSchema as named components (0070095) - these were already shared TS constants but got inlined by value into every route's response, so components.schemas only ever had the two globally addSchema'd entries (def-0/def-1). Switched the 5 call sites to $ref and named components after their $id instead of the default opaque def-N.
  • Document real 403/401 auth responses (bc35496) - every operation only ever documented a generic 4XX (or nothing, on the admin API). The JWT and admin API-key plugins already apply their security scheme via a single shared onRoute hook, so extended that same hook to also document the specific status each one throws.
  • Dedupe trailing-slash duplicate paths (49c2746) - /bucket, /health, and /s3/{Bucket} were each documented twice because they genuinely respond on both forms (Fastify's exposeHeadRoutes and some explicit dual registrations). Folded into one path per real endpoint via transformObject.
  • Hide the schemaless S3 protocol catch-all (8464e06) - the S3-compatible surface dispatches ~18 real commands via query-string/header matching inside an internal router, which OpenAPI can't express as distinct operations. The generic catch-all Fastify saw had no request/response schema at all - worse for SDK generation than not documenting it. Hidden, with the s3 tag description pointing at the real per-command schemas in s3/commands/*.
  • Fill in missing descriptions (e9606c3) - 50 of 62 operations had a summary but no description. Added one-liners focused on what wasn't already obvious from the summary (auth flow variant, pagination style, side effects, TUS protocol semantics).

Skipped: a servers block. This service doesn't know its own deployment prefix (Kong adds /storage/v1 in front in production; self-hosted setups vary), so a guessed base URL would be more misleading than useful.

Impact (main api.json)

before after
paths 35 29
operations 80 62
with operationId 0 60
with description 10 60
named component schemas def-0, def-1 authSchema, errorSchema, bucketSchema, objectSchema
params literally named * 43 0
/s3/** paths 4 0
responses with 403 0 40

Admin api-admin.json: paths 19→18, 401 responses 0→40.

Follow-up: which fixes are docs-only vs. real route changes

Two different mechanisms are used above, worth flagging for review:

  • Real route changes (affect the live server, not just the generated doc): the 403/401 responses (bc35496) are added via an onRoute hook that mutates the actual routeOptions.schema before Fastify compiles the route - confirmed in fastify/lib/route.js, onRoute fires on the live options object pre-registration. The bucketSchema/objectSchema refs and the added descriptions are plain edits to each route file.
  • Docs-only (@fastify/swagger transform/transformObject, never touch the live server): operationId derivation, the wildcard *wildcard rename, def-N$id ref naming, and the trailing-slash dedupe.

Looked into whether any of the docs-only ones should move to being direct route changes instead:

Fix Movable? Why / tradeoff
operationId Partially Could hardcode a string per route, but loses the single source of truth (ROUTE_OPERATIONS) and can't help Fastify's auto-synthesized HEAD routes anyway - there's no route file for those to edit; the dedup logic has to run after Fastify creates them. Not worth it.
wildcard *wildcard Yes, but risky Fastify supports named regex params (:wildcard(.*)) as an alternative to *. A real fix would mean changing the actual route pattern and every handler that reads request.params['*']. Bigger diff, touches routing semantics, needs real regression testing - not a docs change anymore.
def-N$id ref naming No Document-serialization setting, not a per-route thing - no route-level equivalent exists.
trailing-slash dedupe Yes, but risky fastify({ignoreTrailingSlash: true}) would collapse them for real, but S3's dual registrations are explicit and would likely collide with that flag. Real behavior change, needs testing.
S3 catch-all hide Yes, cleanly s3/index.ts already builds schema: { tags: ['s3'] } inline at the one place these routes are registered. Adding hide: true there directly would be simpler than the tag-sniffing check currently in the shared transform (isS3ProtocolCatchAll), and would let that check be deleted entirely.

Not applied yet - flagging for a follow-up commit if wanted.

Follow-up: 3 more bugs found downstream by swift-openapi-generator

A consumer repo adopting swift-openapi-generator against this spec hit three more real bugs,
independent of the fixes above. Fixed here too, each as its own commit:

  • OpenAPI 3.1-only nullable type-array syntax in a declared-3.0.3 document. 4 properties used
    "type": ["integer", "null"] (valid only under OpenAPI 3.1/JSON-Schema-2020-12), which
    swift-openapi-generator hard-fails parsing on - OpenAPI 3.0.x requires a plain type plus
    nullable: true, which this codebase already uses elsewhere (e.g. createBucket's
    file_size_limit). Fixed bucketSchema.file_size_limit/allowed_mime_types and the
    GET /object/sign/{bucketName} response's error/signedURL fields.
  • Same problem, anyOf-with-null spelling. anyOf: [<schema>, {type: 'null'}] is the other
    3.1-only way of expressing nullable, equally invalid under 3.0.3. swift-openapi-generator failed
    with Cannot initialize JSONType from invalid String value null on objectSchema.id. Fixed
    objectSchema's id/updated_at/created_at/last_accessed_at/metadata/user_metadata -
    copyObject.ts spreads objectSchema.properties by reference into its own response, so this
    single fix also resolved the identical occurrences at the /object/copy response. A full
    structural scan of the generated document confirmed zero remaining type-array or
    anyOf-with-null shapes anywhere. (uploadSchema/multipartUploadSchema/uploadPartSchema
    have the same pattern but aren't referenced by any HTTP route, so they never reach the generated
    spec - left alone, flagged separately as dead code.)
  • Redundant top-level anyOf on bucketUpdate's request body dropped its properties
    entirely in swift-openapi-generator.
    The body declared minProperties: 1 and normal
    properties, but also a top-level anyOf requiring at least one of
    public/file_size_limit/allowed_mime_types - which swift-openapi-generator treats as
    anyOf-driven, generating a body type with every field permanently nil. Before removing it,
    verified minProperties: 1 is not actually equivalent on its own: since
    additionalProperties isn't restricted, a body containing only unrecognized fields would
    satisfy minProperties without providing any of the three real fields (confirmed against a
    standalone ajv compile). The real backstop turned out to be one layer down: storage.ts's
    updateBucket already independently throws ERRORS.NoContentProvided() (400) when
    public/fileSizeLimit/allowedMimeTypes are all undefined, regardless of what the request
    body contained - so dropping the schema-level anyOf doesn't weaken validation, it just moves
    the guarantee to where it was already enforced.

Follow-up: errorSchema as the default for any 4xx

setErrorHandler already sends the same {statusCode, error, message} shape for any uncaught
error, on any route, in both apps - but that was only actually documented where a route happened
to go through createDefaultSchema (main app) or the jwt/apikey onRoute hooks added earlier in
this PR. 21 main-app operations (raw file/image streaming routes with no response schema at all)
and all 40 admin operations (errorSchema was never even registered there) had no 4xx
documented at all.

Added the default directly where the error shape is actually defined: setErrorHandler's
onRoute hook now fills in a generic 4xx -> errorSchema for every route in both apps, without
overriding a status code a route already documents more specifically (403, 401, or an explicit
4xx/4XX a route sets itself). Also registered errorSchema as a component on the admin app, which
never called addSchema for it. Main API: 62/62 operations now have 4xx documented (was 41/62).
Admin API: 40/40 (was 0/40).

Test plan

  • npx tsc -noEmit -p . clean after every commit
  • npm run docs:export regenerated both specs successfully after every commit (this is also how the FST_ERR_SCH_SERIALIZATION_BUILD crash from the schema-registration commit and the dropped-default-200 regression from the auth-response commit were caught before landing)
  • biome check / prettier --check clean on all touched files
  • Integration/e2e suite (npm run test:integration) not run in this session - needs Docker infra not available in the sandbox

grdsdev added 7 commits July 7, 2026 14:37
Every generated operation was missing operationId, which breaks SDK
method naming in every OpenAPI code generator. Route configs already
carry a stable, unique operation name (ROUTE_OPERATIONS) used for
observability - derive operationId from it via a swagger transform
instead of duplicating names per route.
Fastify's catch-all route segment is `*`, and @fastify/swagger mirrored
it verbatim into the spec as a path param literally named `*` - not a
legal identifier for any SDK generator. Rewrite it to `wildcard` for
docs only; the raw route/schema Fastify validates against is untouched.
/bucket, /health, and /s3/{Bucket} were each documented twice (with
and without a trailing slash) because Fastify's exposeHeadRoutes and
some routes' explicit dual registration both genuinely respond on
either form. Fold the trailing-slash variant's methods into the
slash-less path via transformObject so SDKs only see one operation
per real endpoint.

Also hardens the operationId dedup from the previous commit: it only
tried one method-based suffix, which wasn't enough once two GET routes
(and their auto-derived HEAD counterparts) legitimately share the same
config.operation - loop with a numeric suffix until unique instead.
bucketSchema and objectSchema were already shared TS constants with
their own $id, but every route embedded them by value instead of by
$ref, so the spec had no reusable models at all outside the two
globally addSchema'd ones (authSchema/errorSchema) - every response
body was duplicated inline per operation.

Register them via app.addSchema and switch the 5 call sites that
embedded them by value to $ref instead (including objectSchema's own
nested `buckets` field). Also make @fastify/swagger name components
after their $id (bucketSchema, objectSchema) instead of the default
opaque def-0/def-1 numbering.
Every operation only ever documented a generic 4XX (main API) or
nothing at all (admin API) for errors, so a generated SDK gets exactly
one exception type regardless of what actually happened. The JWT and
admin API-key plugins already have a single onRoute hook that applies
the security scheme to every route they guard - extend that same hook
to also document the specific, code-verified status each one throws
(403 for invalid JWT/role in jwt.ts, 401 for a bad admin apikey), since
that's true for every route behind it without needing to touch each
route file individually.

Guards against dropping @fastify/swagger's default "200: Default
Response" placeholder on routes that don't declare their own response
schema, which setting schema.response unconditionally would otherwise
suppress.
The S3-compatible surface dispatches ~18 real commands (PutObject,
ListObjects, CreateMultipartUpload, ...) by matching query
string/headers inside the internal s3/router.ts Router - Fastify (and
therefore @fastify/swagger) only ever sees the outer catch-all route,
with no request/response schema, since OpenAPI has no way to express
"N operations, same path and method, picked by a query parameter".

Documenting that catch-all as-is produced one method per path/verb
with an untyped body and an untyped response standing in for the
entire S3 API - worse for SDK generation than not documenting it.
Hide it instead and point the s3 tag description at the real
per-command schemas in s3/commands/*.
50 of 62 operations had a summary but no description, so generated
SDK doc comments were empty for most methods. Add one-line
descriptions across 27 route files, focused on what isn't already
obvious from the summary: auth flow differences (public/authenticated/
signed-URL variants), pagination style (cursor vs limit/offset),
side effects (CDN purge scope, x-upsert), and TUS protocol semantics
per step.
@coveralls

coveralls commented Jul 7, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29018018528

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.002%) to 78.997%

Details

  • Coverage increased (+0.002%) from the base build.
  • Patch coverage: 58 uncovered changes across 1 file (20 of 78 lines covered, 25.64%).
  • 102 coverage regressions across 10 files.

Uncovered Changes

File Changed Covered %
src/http/routes/openapi-transform.ts 61 3 4.92%
Total (7 files) 78 20 25.64%

Coverage Regressions

102 previously-covered lines in 10 files lost coverage.

File Lines Losing Coverage Coverage
src/scripts/pprof-client.ts 25 64.63%
src/internal/database/pg-connection.ts 23 90.03%
src/internal/monitoring/pprof/download.ts 23 87.26%
src/http/routes/admin/pprof.ts 8 91.77%
src/http/plugins/metrics.ts 6 76.81%
src/http/plugins/log-request.ts 5 90.48%
src/admin-app.ts 3 85.37%
src/http/plugins/header-validator.ts 3 86.49%
src/internal/monitoring/pprof/client-http.ts 3 87.5%
src/internal/monitoring/pprof/runtime.ts 3 95.1%

Coverage Stats

Coverage Status
Relevant Lines: 12673
Covered Lines: 10470
Line Coverage: 82.62%
Relevant Branches: 7357
Covered Branches: 5353
Branch Coverage: 72.76%
Branches in Coverage %: Yes
Coverage Strength: 428.43 hits per line

💛 - Coveralls

grdsdev added 3 commits July 8, 2026 09:35
…e: true

The document declares openapi: 3.0.3 but 4 properties used the
array-form nullable type (type: ["integer", "null"]), which is only
valid under OpenAPI 3.1/JSON-Schema-2020-12 - 3.0.x requires a plain
type plus nullable: true, which this codebase already uses elsewhere
(e.g. createBucket's file_size_limit). swift-openapi-generator hard-
fails parsing on the array form.

Fixes bucketSchema.file_size_limit/allowed_mime_types and the
GET /object/sign/{bucketName} response's error/signedURL fields.
…e: true

Same problem as the previous commit's type-array fix, different
spelling: anyOf: [<schema>, {type: 'null'}] is the other
JSON-Schema-2020-12/OpenAPI-3.1 way of expressing nullable, and it's
equally invalid under this document's declared openapi: 3.0.3.
swift-openapi-generator failed with "Cannot initialize JSONType from
invalid String value null" on objectSchema.id.

Fixes objectSchema's id/updated_at/created_at/last_accessed_at/
metadata/user_metadata. copyObject.ts spreads objectSchema.properties
by reference into its own response schema, so this single fix also
resolves the identical anyOf-null occurrences at the /object/copy
response - confirmed via a full structural scan of the generated
document for both the type-array and anyOf-null shapes (zero
remaining either way).

uploadSchema (upload.ts) and multipartUploadSchema/uploadPartSchema
(multipart.ts) have the same anyOf-null pattern but aren't referenced
by any HTTP route, so they never reach the generated spec - left
alone as out of scope for this fix.
The body already declared minProperties: 1 and a normal properties
object (public/file_size_limit/allowed_mime_types), but also a
top-level anyOf requiring at least one of those three - which
swift-openapi-generator treats as anyOf-driven, dropping the
co-located properties entirely and generating a body type with every
field permanently nil.

Verified before removing: minProperties: 1 alone is NOT actually
equivalent, since additionalProperties isn't restricted here - a
request body containing only unrecognized fields would satisfy
minProperties without providing public/file_size_limit/
allowed_mime_types (confirmed empirically against a standalone ajv
compile). The real backstop is storage.ts's updateBucket, which
independently throws ERRORS.NoContentProvided() (400) when
public/fileSizeLimit/allowedMimeTypes are all undefined regardless of
what the request body contained - so dropping the schema-level anyOf
doesn't weaken validation, it just moves the "at least one field"
guarantee to where it was already independently enforced.
setErrorHandler already sends the same {statusCode, error, message}
shape for any uncaught error on any route, in both the main and admin
apps - but that default was only actually documented where a route
happened to go through createDefaultSchema (main app) or the jwt/
apikey onRoute hooks. 21 main-app operations (raw file/image streaming
routes with no response schema at all) and all 40 admin operations
(errorSchema was never even registered there) had no 4xx documented.

Add the default directly to the one place that already defines this
error shape for the whole server: setErrorHandler's onRoute hook now
fills in a generic 4xx -> errorSchema for every route in both apps,
without overriding a status code a route already documents more
specifically (jwt.ts's 403, apikey.ts's 401, or a route's own explicit
4xx/4XX). Also registers errorSchema as a component on the admin app,
which never called addSchema for it.
@blacksmith-sh

This comment has been minimized.

grdsdev added 3 commits July 9, 2026 08:02
Three distinct bugs from the previous commit's setErrorHandler change,
found by actually running the suite instead of trusting the earlier
docs-only verification:

1. Unit tests building a bare Fastify() and calling setErrorHandler
   directly (error-handler.test.ts, header-validator.test.ts) crashed
   at boot with "Cannot resolve ref errorSchema#" - the onRoute hook
   needs errorSchema registered on that instance, which only app.ts/
   admin-app.ts did. setErrorHandler now registers it itself
   (idempotently, since both apps also register it directly for
   their own schemas).

2. errorSchema was missing `code`, which every real error response
   actually sends (confirmed in error-handler.ts and storage-error.ts's
   render()). Forcing a response schema onto routes that previously had
   none made fast-json-stringify start enforcing it, silently dropping
   `code` from real payloads - caught by error-handler.test.ts's
   existing assertions on the response body.

3. The big one: iceberg/index.ts's setErrorHandler call passes a custom
   `formatter` that reshapes every error into the REST catalog spec's
   nested {error: {message, type, code}}, completely different from
   errorSchema's flat shape. The global default (and registerJwtAuth's
   403, added earlier in the same subtree) still forced the flat
   schema onto iceberg routes, and fast-json-stringify serializing a
   real object against a schema declaring that field as a string
   doesn't just drop data, it throws - manifesting as a 500 on every
   iceberg error response, including plain 404s (confirmed by
   reproducing acceptance/specs/iceberg.test.ts's exact failure
   locally: GET .../namespaces/missing returned 500 instead of 404).

   setErrorHandler now takes an optional errorResponseSchema alongside
   formatter, and when present, its onRoute hook strips any status-
   specific entry an ancestor already added (all assuming the flat
   shape) and documents errorResponseSchema instead - overriding
   rather than only filling a gap, since this hook runs after the
   ones it needs to correct. iceberg/index.ts now passes the schema
   matching its actual formatter output.

Verified: full unit suite (110 files, 1130 tests) passes, and the
specific iceberg 500 reproduces on master's version of this commit
and is fixed after it (tested locally via inject with ICEBERG_ENABLED
=true, both the JWT-rejection 403 path and confirmed via a full docs
export that the documented response now matches the formatter's real
output).
Multitenant acceptance CI still failed after the last fix: GET
/migrations/failed?cursor=not-a-number returned 500 instead of 400.
Root cause is the same class of bug as the iceberg one, wider in
scope: this codebase has many handlers that reply with an ad-hoc,
partial error body directly (reply.status(400).send({message: '...'}),
bypassing setErrorHandler's formatter entirely - migrations.ts,
tenants.ts, queue.ts, jwks.ts, objects.ts all do this). Forcing
errorSchema's required fields onto those routes' response
*serialization* makes fast-json-stringify throw on the ones missing a
field ("statusCode" is required!), not silently drop it as assumed.

There's no way to safely enforce this at the real-route level without
auditing and fixing every ad-hoc reply site, which is out of scope and
risks more of the same whack-a-mole. Since the actual goal is
documentation, not enforcement, move the generic default into
openapi-transform.ts's doc-only transform instead - it edits a copy
used only for the generated spec and can never affect request
handling, so it can't cause this class of bug by construction.

registerJwtAuth's 403 and the iceberg-formatter override in
error-handler.ts stay as real onRoute hooks, since both are safe: every
route they cover either always goes through the central formatter
(iceberg) or always renders the full flat shape for a real 403
(AccessDenied is only ever thrown by the central auth code, never
replied ad-hoc).
Test/Postgres, OrioleDB, and Multigres all failed the same way: dozens
of integration tests (app.test.ts, bucket.test.ts, object.test.ts)
asserting exact error response bodies started receiving an unexpected
`code` field they don't expect, e.g. object.test.ts:446 expecting
{statusCode, error, message} and receiving {..., code: "AccessDenied"}
too.

routes-helper.ts's createDefaultSchema has forced every route's generic
4xx response through `$ref: errorSchema#` since long before this PR -
completely unrelated to anything added here. errorSchema never declared
`code`, so fast-json-stringify has been silently stripping it from
every one of those routes' real 4xx/413/403/etc responses for as long
as that's existed, even though error-handler.ts's actual formatter
always includes it. That stripping is the actual, long-shipped,
test-covered contract.

Adding `code` to errorSchema (to fix the unit-test crash two commits
ago) incidentally un-stripped it everywhere createDefaultSchema's
generic 4xx applies - nothing to do with the crash itself, which is
already fixed by the previous commit removing the generic onRoute
hook entirely. Revert just the schema change; the doc now goes back to
accurately describing what actually gets serialized.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants