From 0ebdf6e52a3d6ed79eaa0a9e331a23f27459debf Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 14:37:54 -0300 Subject: [PATCH 01/14] fix: derive OpenAPI operationId from route config.operation 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. --- src/admin-app.ts | 2 + src/app.ts | 2 + src/http/routes/health/healthcheck.ts | 1 + src/http/routes/openapi-transform.ts | 60 +++++++++++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 src/http/routes/openapi-transform.ts diff --git a/src/admin-app.ts b/src/admin-app.ts index b3a481037..0a1b8638b 100644 --- a/src/admin-app.ts +++ b/src/admin-app.ts @@ -5,6 +5,7 @@ import { getGlobal } from '@platformatic/globals' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, setErrorHandler } from './http' +import { createOpenApiTransform } from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -19,6 +20,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { if (opts.exposeDocs) { app.register(fastifySwagger, { exposeHeadRoutes: true, + transform: createOpenApiTransform(), openapi: { info: { title: 'Supabase Storage Admin API', diff --git a/src/app.ts b/src/app.ts index 42e295193..ee3c5a053 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ import fastifySwaggerUi from '@fastify/swagger-ui' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, schemas, setErrorHandler } from './http' +import { createOpenApiTransform } from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -26,6 +27,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { if (opts.exposeDocs) { app.register(fastifySwagger, { exposeHeadRoutes: true, + transform: createOpenApiTransform(), openapi: { info: { title: 'Supabase Storage API', diff --git a/src/http/routes/health/healthcheck.ts b/src/http/routes/health/healthcheck.ts index a8ecd55ad..deca8dc45 100644 --- a/src/http/routes/health/healthcheck.ts +++ b/src/http/routes/health/healthcheck.ts @@ -7,6 +7,7 @@ export default async function routes(fastify: FastifyInstance) { '/', { schema: { + operationId: 'healthcheck', summary, tags: ['health'], }, diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts new file mode 100644 index 000000000..61c25bb32 --- /dev/null +++ b/src/http/routes/openapi-transform.ts @@ -0,0 +1,60 @@ +import { FastifySchema, RouteOptions } from 'fastify' + +/** + * Derives a stable, unique operationId from the route's `config.operation` + * (see ROUTE_OPERATIONS in ./operations.ts), e.g. `storage.object.get_public` -> `objectGetPublic`. + * Routes without a `config.operation` (protocol-level catch-alls like /s3 and /upload/resumable) + * are left without an operationId. + */ +function operationToId(operation: string): string { + const parts = operation.split('.').filter((part) => part !== 'storage') + return parts + .map((part) => + part + .split('_') + .filter(Boolean) + .map((word, i) => (i === 0 ? word : word[0].toUpperCase() + word.slice(1))) + .join('') + ) + .map((part, i) => (i === 0 ? part : part[0].toUpperCase() + part.slice(1))) + .join('') +} + +/** + * OpenAPI requires operationId to be unique across the whole document. `exposeHeadRoutes` + * auto-derives a HEAD operation from every GET route re-using the same `config.operation`, + * so the id needs a per-method suffix to stay unique when that happens. + * Returns a fresh transform bound to its own dedup state, so main/admin specs don't + * leak collisions into each other when generated in the same process (see export-docs.ts). + */ +export function createOpenApiTransform() { + const seenIds = new Set() + + return function transformOpenApiSchema({ + schema, + url, + route, + }: { + schema: FastifySchema + url: string + route: RouteOptions + }): { schema: FastifySchema; url: string } { + const operation = (route.config as { operation?: string } | undefined)?.operation + + if (!operation || (schema as { operationId?: string }).operationId) { + return { schema, url } + } + + let operationId = operationToId(operation) + if (seenIds.has(operationId)) { + const method = Array.isArray(route.method) ? route.method[0] : route.method + operationId += method[0].toUpperCase() + method.slice(1).toLowerCase() + } + seenIds.add(operationId) + + return { + schema: { ...schema, operationId }, + url, + } + } +} From 0832ef9d2c837dea34438d8f40b20a2194e279ba Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 14:41:42 -0300 Subject: [PATCH 02/14] fix: rename OpenAPI wildcard path param from `*` to `wildcard` 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. --- src/http/routes/openapi-transform.ts | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts index 61c25bb32..e64e3b542 100644 --- a/src/http/routes/openapi-transform.ts +++ b/src/http/routes/openapi-transform.ts @@ -1,5 +1,46 @@ import { FastifySchema, RouteOptions } from 'fastify' +const WILDCARD_PARAM = '*' +const WILDCARD_DOC_NAME = 'wildcard' + +/** + * Fastify's catch-all route segment is `*`, and its request params are keyed by the + * literal `*` character (`request.params['*']`). @fastify/swagger mirrors that straight + * into the OpenAPI doc as a path template `{*}` with a parameter named `*`, which isn't a + * legal parameter/identifier name for any code generator. Rewrite it to a readable name for + * docs only - the raw url string returned here still goes through Fastify's own `:name` + * path-param formatting, and `route.schema` (the live validation schema) is never mutated. + */ +function renameWildcardParam( + schema: FastifySchema, + url: string +): { schema: FastifySchema; url: string } { + if (!url.split('/').includes(WILDCARD_PARAM)) { + return { schema, url } + } + + const renamedUrl = url + .split('/') + .map((segment) => (segment === WILDCARD_PARAM ? `:${WILDCARD_DOC_NAME}` : segment)) + .join('/') + + const params = schema.params as + | { properties?: Record; required?: string[] } + | undefined + if (!params?.properties?.[WILDCARD_PARAM]) { + return { schema, url: renamedUrl } + } + + const { [WILDCARD_PARAM]: wildcardProperty, ...otherProperties } = params.properties + const renamedParams = { + ...params, + properties: { ...otherProperties, [WILDCARD_DOC_NAME]: wildcardProperty }, + required: params.required?.map((name) => (name === WILDCARD_PARAM ? WILDCARD_DOC_NAME : name)), + } + + return { schema: { ...schema, params: renamedParams }, url: renamedUrl } +} + /** * Derives a stable, unique operationId from the route's `config.operation` * (see ROUTE_OPERATIONS in ./operations.ts), e.g. `storage.object.get_public` -> `objectGetPublic`. @@ -39,6 +80,8 @@ export function createOpenApiTransform() { url: string route: RouteOptions }): { schema: FastifySchema; url: string } { + ;({ schema, url } = renameWildcardParam(schema, url)) + const operation = (route.config as { operation?: string } | undefined)?.operation if (!operation || (schema as { operationId?: string }).operationId) { From 49c2746838a3cafaa53a21e0c2a3d4834477e79a Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 14:50:00 -0300 Subject: [PATCH 03/14] fix: dedupe trailing-slash duplicate OpenAPI paths /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. --- src/admin-app.ts | 3 +- src/app.ts | 3 +- src/http/routes/openapi-transform.ts | 48 ++++++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/admin-app.ts b/src/admin-app.ts index 0a1b8638b..a34da6049 100644 --- a/src/admin-app.ts +++ b/src/admin-app.ts @@ -5,7 +5,7 @@ import { getGlobal } from '@platformatic/globals' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, setErrorHandler } from './http' -import { createOpenApiTransform } from './http/routes/openapi-transform' +import { createOpenApiTransform, dedupeTrailingSlashPaths } from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -21,6 +21,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { app.register(fastifySwagger, { exposeHeadRoutes: true, transform: createOpenApiTransform(), + transformObject: dedupeTrailingSlashPaths, openapi: { info: { title: 'Supabase Storage Admin API', diff --git a/src/app.ts b/src/app.ts index ee3c5a053..cb75ebae7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,7 +3,7 @@ import fastifySwaggerUi from '@fastify/swagger-ui' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, schemas, setErrorHandler } from './http' -import { createOpenApiTransform } from './http/routes/openapi-transform' +import { createOpenApiTransform, dedupeTrailingSlashPaths } from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -28,6 +28,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { app.register(fastifySwagger, { exposeHeadRoutes: true, transform: createOpenApiTransform(), + transformObject: dedupeTrailingSlashPaths, openapi: { info: { title: 'Supabase Storage API', diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts index e64e3b542..2e34ea25b 100644 --- a/src/http/routes/openapi-transform.ts +++ b/src/http/routes/openapi-transform.ts @@ -1,3 +1,4 @@ +import { SwaggerTransformObject } from '@fastify/swagger' import { FastifySchema, RouteOptions } from 'fastify' const WILDCARD_PARAM = '*' @@ -88,10 +89,14 @@ export function createOpenApiTransform() { return { schema, url } } - let operationId = operationToId(operation) + const baseId = operationToId(operation) + let operationId = baseId if (seenIds.has(operationId)) { const method = Array.isArray(route.method) ? route.method[0] : route.method - operationId += method[0].toUpperCase() + method.slice(1).toLowerCase() + operationId = baseId + method[0].toUpperCase() + method.slice(1).toLowerCase() + } + for (let suffix = 2; seenIds.has(operationId); suffix++) { + operationId = baseId + suffix } seenIds.add(operationId) @@ -101,3 +106,42 @@ export function createOpenApiTransform() { } } } + +/** + * A handful of paths are reachable both with and without a trailing slash - either because + * Fastify's own `exposeHeadRoutes` derives a HEAD route from the un-prefixed path (e.g. + * /health vs /health/) or because a route is explicitly registered both ways (the S3 + * protocol surface). Both forms genuinely work at runtime, but documenting them as two + * unrelated paths just doubles the number of operations a generated SDK has to deal with + * for the same endpoint. Keep the slash-less form and fold the other one's methods into it. + */ +export const dedupeTrailingSlashPaths: SwaggerTransformObject = (documentObject) => { + if (!('openapiObject' in documentObject)) { + return documentObject.swaggerObject + } + + const { openapiObject } = documentObject + const paths = openapiObject.paths as Record> | undefined + if (!paths) { + return openapiObject + } + + for (const url of Object.keys(paths)) { + if (url === '/' || !url.endsWith('/')) { + continue + } + + const canonicalUrl = url.slice(0, -1) + const canonicalPathItem = paths[canonicalUrl] + if (!canonicalPathItem) { + continue + } + + for (const [method, operation] of Object.entries(paths[url])) { + canonicalPathItem[method] ??= operation + } + delete paths[url] + } + + return openapiObject +} From 007009586eb05aef676b93450fe01f95ece316fe Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 14:59:16 -0300 Subject: [PATCH 04/14] fix: register bucket/object schemas as named OpenAPI components 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. --- src/admin-app.ts | 7 ++++++- src/app.ts | 10 +++++++++- src/http/routes/bucket/getAllBuckets.ts | 3 +-- src/http/routes/bucket/getBucket.ts | 3 +-- src/http/routes/object/deleteObjects.ts | 3 +-- src/http/routes/object/listObjects.ts | 3 +-- src/http/routes/openapi-transform.ts | 15 +++++++++++++++ src/storage/schemas/object.ts | 3 +-- 8 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/admin-app.ts b/src/admin-app.ts index a34da6049..8d8eeb7cc 100644 --- a/src/admin-app.ts +++ b/src/admin-app.ts @@ -5,7 +5,11 @@ import { getGlobal } from '@platformatic/globals' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, setErrorHandler } from './http' -import { createOpenApiTransform, dedupeTrailingSlashPaths } from './http/routes/openapi-transform' +import { + createOpenApiTransform, + dedupeTrailingSlashPaths, + nameSchemaByDollarId, +} from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -22,6 +26,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { exposeHeadRoutes: true, transform: createOpenApiTransform(), transformObject: dedupeTrailingSlashPaths, + refResolver: { buildLocalReference: nameSchemaByDollarId }, openapi: { info: { title: 'Supabase Storage Admin API', diff --git a/src/app.ts b/src/app.ts index cb75ebae7..d3caf1975 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,9 +1,14 @@ import fastifySwagger from '@fastify/swagger' import fastifySwaggerUi from '@fastify/swagger-ui' +import { bucketSchema, objectSchema } from '@storage/schemas' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' import { plugins, routes, schemas, setErrorHandler } from './http' -import { createOpenApiTransform, dedupeTrailingSlashPaths } from './http/routes/openapi-transform' +import { + createOpenApiTransform, + dedupeTrailingSlashPaths, + nameSchemaByDollarId, +} from './http/routes/openapi-transform' interface buildOpts extends FastifyServerOptions { exposeDocs?: boolean @@ -29,6 +34,7 @@ const build = (opts: buildOpts = {}): FastifyInstance => { exposeHeadRoutes: true, transform: createOpenApiTransform(), transformObject: dedupeTrailingSlashPaths, + refResolver: { buildLocalReference: nameSchemaByDollarId }, openapi: { info: { title: 'Supabase Storage API', @@ -75,6 +81,8 @@ const build = (opts: buildOpts = {}): FastifyInstance => { // add in common schemas app.addSchema(schemas.authSchema) app.addSchema(schemas.errorSchema) + app.addSchema(bucketSchema) + app.addSchema(objectSchema) app.register(plugins.requestContext) app.register(plugins.signals) diff --git a/src/http/routes/bucket/getAllBuckets.ts b/src/http/routes/bucket/getAllBuckets.ts index a814e533b..f812ff126 100644 --- a/src/http/routes/bucket/getAllBuckets.ts +++ b/src/http/routes/bucket/getAllBuckets.ts @@ -1,5 +1,4 @@ import { isClientVersionBefore } from '@storage/limits' -import { bucketSchema } from '@storage/schemas' import { FastifyInstance } from 'fastify' import { FromSchema } from 'json-schema-to-ts' import { createDefaultSchema } from '../../routes-helper' @@ -8,7 +7,7 @@ import { ROUTE_OPERATIONS } from '../operations' const successResponseSchema = { type: 'array', - items: bucketSchema, + items: { $ref: 'bucketSchema#' }, examples: [ [ { diff --git a/src/http/routes/bucket/getBucket.ts b/src/http/routes/bucket/getBucket.ts index b872972d5..64c164d80 100644 --- a/src/http/routes/bucket/getBucket.ts +++ b/src/http/routes/bucket/getBucket.ts @@ -1,4 +1,3 @@ -import { bucketSchema } from '@storage/schemas' import { FastifyInstance } from 'fastify' import { FromSchema } from 'json-schema-to-ts' import { createDefaultSchema } from '../../routes-helper' @@ -13,7 +12,7 @@ const getBucketParamsSchema = { required: ['bucketId'], } as const -const successResponseSchema = bucketSchema +const successResponseSchema = { $ref: 'bucketSchema#' } interface getBucketRequestInterface extends AuthenticatedRequest { Params: FromSchema } diff --git a/src/http/routes/object/deleteObjects.ts b/src/http/routes/object/deleteObjects.ts index c773d4d3a..074aacaf8 100644 --- a/src/http/routes/object/deleteObjects.ts +++ b/src/http/routes/object/deleteObjects.ts @@ -1,5 +1,4 @@ import { DELETE_OBJECTS_LIMIT_DESCRIPTION, enforceDeleteObjectsLimit } from '@storage/limits' -import { objectSchema } from '@storage/schemas/object' import { FastifyInstance, FastifyRequest } from 'fastify' import { FromSchema } from 'json-schema-to-ts' import { createDefaultSchema } from '../../routes-helper' @@ -28,7 +27,7 @@ const deleteObjectsBodySchema = { } as const const successResponseSchema = { type: 'array', - items: objectSchema, + items: { $ref: 'objectSchema#' }, } interface deleteObjectsInterface extends AuthenticatedRequest { Params: FromSchema diff --git a/src/http/routes/object/listObjects.ts b/src/http/routes/object/listObjects.ts index 9efe578da..96688ebb5 100644 --- a/src/http/routes/object/listObjects.ts +++ b/src/http/routes/object/listObjects.ts @@ -1,4 +1,3 @@ -import { objectSchema } from '@storage/schemas' import { FastifyInstance } from 'fastify' import { FastifyRequest } from 'fastify/types/request' import { FromSchema } from 'json-schema-to-ts' @@ -35,7 +34,7 @@ const searchRequestBodySchema = { } as const const successResponseSchema = { type: 'array', - items: objectSchema, + items: { $ref: 'objectSchema#' }, } interface searchRequestInterface extends AuthenticatedRequest { Body: FromSchema diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts index 2e34ea25b..bdcd11591 100644 --- a/src/http/routes/openapi-transform.ts +++ b/src/http/routes/openapi-transform.ts @@ -1,6 +1,21 @@ import { SwaggerTransformObject } from '@fastify/swagger' import { FastifySchema, RouteOptions } from 'fastify' +/** + * @fastify/swagger names every de-duplicated component schema `def-0`, `def-1`, ... by + * default, even for schemas registered with a meaningful `$id` (bucketSchema, errorSchema). + * Use the `$id` as the component name instead, falling back to the default `def-N` for + * anonymous schemas so unrelated inline schemas don't collide. + */ +export function nameSchemaByDollarId( + json: { $id?: string }, + _baseUri: unknown, + _fragment: unknown, + i: number +) { + return json.$id || `def-${i}` +} + const WILDCARD_PARAM = '*' const WILDCARD_DOC_NAME = 'wildcard' diff --git a/src/storage/schemas/object.ts b/src/storage/schemas/object.ts index 0dbb6a0cc..edd877036 100644 --- a/src/storage/schemas/object.ts +++ b/src/storage/schemas/object.ts @@ -1,5 +1,4 @@ import { FromSchema } from 'json-schema-to-ts' -import { bucketSchema } from './bucket' export const objectSchema = { $id: 'objectSchema', @@ -20,7 +19,7 @@ export const objectSchema = { user_metadata: { anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], }, - buckets: bucketSchema, + buckets: { $ref: 'bucketSchema#' }, }, required: ['name'], additionalProperties: false, From bc35496fee490ad261a4a190c20728b3587879d6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 15:05:11 -0300 Subject: [PATCH 05/14] fix: document real 403/401 auth responses instead of only blanket 4XX 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. --- src/http/plugins/apikey.ts | 12 ++++++++++++ src/http/plugins/jwt.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/http/plugins/apikey.ts b/src/http/plugins/apikey.ts index e5986f6b5..7bcb7c266 100644 --- a/src/http/plugins/apikey.ts +++ b/src/http/plugins/apikey.ts @@ -19,6 +19,18 @@ export function registerApiKeyAuth(fastify: FastifyInstance) { fastify.addHook('onRoute', (routeOptions) => { routeOptions.schema = routeOptions.schema || {} routeOptions.schema.security = [{ apiKeyAuth: [] }] + + // Every route behind this plugin can reject with an empty-bodied 401 when the + // `apikey` header is missing or doesn't match one of the configured admin API keys. + // Routes that don't declare their own response schema fall back to @fastify/swagger's + // own "200: Default Response" placeholder - preserve that instead of losing it, since + // setting `schema.response` at all opts a route out of that fallback. + const hadResponseSchema = Boolean(routeOptions.schema.response) + routeOptions.schema.response = { + ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), + 401: { description: 'Missing or invalid API key' }, + ...(routeOptions.schema.response as object | undefined), + } }) fastify.register(apiKeyPlugin) } diff --git a/src/http/plugins/jwt.ts b/src/http/plugins/jwt.ts index 697a90011..1acba16e2 100644 --- a/src/http/plugins/jwt.ts +++ b/src/http/plugins/jwt.ts @@ -102,6 +102,18 @@ export function registerJwtAuth(fastify: FastifyInstance, opts: JWTPluginOptions fastify.addHook('onRoute', (routeOptions) => { routeOptions.schema = routeOptions.schema || {} routeOptions.schema.security = [{ bearerAuth: [] }] + + // Every route behind this plugin can reject with 403 (invalid/missing JWT, or - + // when enforceJwtRoles is set - an authenticated role without the required role). + // Routes that don't declare their own response schema fall back to @fastify/swagger's + // own "200: Default Response" placeholder - preserve that instead of losing it, since + // setting `schema.response` at all opts a route out of that fallback. + const hadResponseSchema = Boolean(routeOptions.schema.response) + routeOptions.schema.response = { + ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), + 403: { description: 'Access denied', $ref: 'errorSchema#' }, + ...(routeOptions.schema.response as object | undefined), + } }) fastify.register(jwtPlugin, opts) } From 8464e061e7200de7d3ab407f7cdd3c3af041750f Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 15:10:57 -0300 Subject: [PATCH 06/14] fix: hide the schemaless S3 protocol catch-all from the OpenAPI spec 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/*. --- src/app.ts | 9 ++++++++- src/http/routes/openapi-transform.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/app.ts b/src/app.ts index d3caf1975..9a0988716 100644 --- a/src/app.ts +++ b/src/app.ts @@ -53,7 +53,14 @@ const build = (opts: buildOpts = {}): FastifyInstance => { tags: [ { name: 'object', description: 'Object end-points' }, { name: 'bucket', description: 'Bucket end-points' }, - { name: 's3', description: 'S3 end-points' }, + { + name: 's3', + description: + 'S3-compatible protocol. Not enumerated here: each operation is dispatched ' + + 'by query string/header on a handful of shared routes, which OpenAPI cannot ' + + 'express as distinct operations. See src/http/routes/s3/commands for the ' + + 'per-command request/response contract, or use any S3 SDK against this endpoint.', + }, { name: 'transformation', description: 'Image transformation' }, { name: 'resumable', description: 'Resumable Upload end-points' }, { name: 'cdn', description: 'CDN cache management' }, diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts index bdcd11591..8e8c18582 100644 --- a/src/http/routes/openapi-transform.ts +++ b/src/http/routes/openapi-transform.ts @@ -57,6 +57,22 @@ function renameWildcardParam( return { schema: { ...schema, params: renamedParams }, url: renamedUrl } } +/** + * The S3-compatible surface dispatches ~18 real commands (PutObject, ListObjects, + * CreateMultipartUpload, ...) from a handful of generic Fastify routes based on query + * string/header matching done entirely inside the internal `s3/router.ts` Router - see + * `s3/index.ts`. 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 distinct operations, same path and method, picked by a query parameter". Documenting + * that catch-all as-is would give SDK generators a single method with an untyped body and + * an untyped response for a request that's actually the whole S3 API - worse than nothing. + * Hide it instead; the real per-command schemas remain the source of truth in s3/commands/*. + */ +function isS3ProtocolCatchAll(schema: FastifySchema | undefined, route: RouteOptions): boolean { + const operation = (route.config as { operation?: string } | undefined)?.operation + return (schema?.tags?.includes('s3') ?? false) && !operation +} + /** * Derives a stable, unique operationId from the route's `config.operation` * (see ROUTE_OPERATIONS in ./operations.ts), e.g. `storage.object.get_public` -> `objectGetPublic`. @@ -96,6 +112,10 @@ export function createOpenApiTransform() { url: string route: RouteOptions }): { schema: FastifySchema; url: string } { + if (isS3ProtocolCatchAll(schema, route)) { + return { schema: { ...schema, hide: true }, url } + } + ;({ schema, url } = renameWildcardParam(schema, url)) const operation = (route.config as { operation?: string } | undefined)?.operation From e9606c3d37e2cb09882ef50447340842e2f3bef1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Jul 2026 15:23:27 -0300 Subject: [PATCH 07/14] fix: add missing OpenAPI descriptions across all routes 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. --- src/http/routes/bucket/createBucket.ts | 2 ++ src/http/routes/bucket/deleteBucket.ts | 1 + src/http/routes/bucket/emptyBucket.ts | 2 ++ src/http/routes/bucket/getAllBuckets.ts | 2 ++ src/http/routes/bucket/getBucket.ts | 2 ++ src/http/routes/bucket/updateBucket.ts | 2 ++ src/http/routes/cdn/purgeCache.ts | 6 ++++ src/http/routes/health/healthcheck.ts | 2 ++ src/http/routes/object/copyObject.ts | 2 ++ src/http/routes/object/createObject.ts | 2 ++ src/http/routes/object/deleteObject.ts | 2 ++ src/http/routes/object/deleteObjects.ts | 2 ++ src/http/routes/object/getObject.ts | 2 ++ src/http/routes/object/getObjectInfo.ts | 4 +++ src/http/routes/object/getPublicObject.ts | 2 ++ src/http/routes/object/getSignedObject.ts | 2 ++ src/http/routes/object/getSignedURL.ts | 2 ++ src/http/routes/object/getSignedURLs.ts | 2 ++ src/http/routes/object/getSignedUploadURL.ts | 2 ++ src/http/routes/object/listObjects.ts | 2 ++ src/http/routes/object/listObjectsV2.ts | 2 ++ src/http/routes/object/moveObject.ts | 2 ++ src/http/routes/object/updateObject.ts | 2 ++ src/http/routes/object/uploadSignedObject.ts | 2 ++ .../routes/render/renderAuthenticatedImage.ts | 2 ++ src/http/routes/render/renderPublicImage.ts | 2 ++ src/http/routes/render/renderSignedImage.ts | 2 ++ src/http/routes/tus/index.ts | 32 ++++++++++++++++--- 28 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/http/routes/bucket/createBucket.ts b/src/http/routes/bucket/createBucket.ts index 261636caf..a2510fe70 100644 --- a/src/http/routes/bucket/createBucket.ts +++ b/src/http/routes/bucket/createBucket.ts @@ -44,6 +44,8 @@ export default async function routes(fastify: FastifyInstance) { allowUnionTypes: true, body: createBucketBodySchema, summary, + description: + 'The bucket id defaults to the given name when not provided, and the owner is set from the authenticated caller', tags: ['bucket'], }) fastify.post( diff --git a/src/http/routes/bucket/deleteBucket.ts b/src/http/routes/bucket/deleteBucket.ts index 033d9fc8f..457c3c11f 100644 --- a/src/http/routes/bucket/deleteBucket.ts +++ b/src/http/routes/bucket/deleteBucket.ts @@ -28,6 +28,7 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: deleteBucketParamsSchema, summary, + description: 'Fails if the bucket still contains objects, so it must be emptied first', tags: ['bucket'], }) diff --git a/src/http/routes/bucket/emptyBucket.ts b/src/http/routes/bucket/emptyBucket.ts index de86da22a..35e0269c2 100644 --- a/src/http/routes/bucket/emptyBucket.ts +++ b/src/http/routes/bucket/emptyBucket.ts @@ -30,6 +30,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: emptyBucketParamsSchema, summary, + description: + 'Queues asynchronous deletion of all objects inside the bucket without deleting the bucket itself, which may take up to an hour to complete', tags: ['bucket'], }) diff --git a/src/http/routes/bucket/getAllBuckets.ts b/src/http/routes/bucket/getAllBuckets.ts index f812ff126..0a6f02f13 100644 --- a/src/http/routes/bucket/getAllBuckets.ts +++ b/src/http/routes/bucket/getAllBuckets.ts @@ -45,6 +45,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { querystring: requestQuerySchema, summary, + description: + 'Returns buckets owned by the authenticated user, supporting pagination via limit/offset plus sorting and search filters', tags: ['bucket'], }) diff --git a/src/http/routes/bucket/getBucket.ts b/src/http/routes/bucket/getBucket.ts index 64c164d80..836c53406 100644 --- a/src/http/routes/bucket/getBucket.ts +++ b/src/http/routes/bucket/getBucket.ts @@ -22,6 +22,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: getBucketParamsSchema, summary, + description: + 'Requires the caller to have access to the bucket via RLS policies, unlike the public bucket listing', tags: ['bucket'], }) fastify.get( diff --git a/src/http/routes/bucket/updateBucket.ts b/src/http/routes/bucket/updateBucket.ts index 3738a3556..0a292b35f 100644 --- a/src/http/routes/bucket/updateBucket.ts +++ b/src/http/routes/bucket/updateBucket.ts @@ -52,6 +52,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { body: updateBucketBodySchema, summary, + description: + 'Requires at least one of public, file_size_limit or allowed_mime_types in the body, and only the fields provided are changed', tags: ['bucket'], }) fastify.put( diff --git a/src/http/routes/cdn/purgeCache.ts b/src/http/routes/cdn/purgeCache.ts index ceda04263..4340c4f38 100644 --- a/src/http/routes/cdn/purgeCache.ts +++ b/src/http/routes/cdn/purgeCache.ts @@ -57,6 +57,8 @@ export default async function routes(fastify: FastifyInstance) { schema: createDefaultSchema(successResponseSchema, { querystring: purgeQuerySchema, summary: 'Purge cache for entire tenant or tenant transformations', + description: + 'Pass transformations=true in the query string to purge only cached image transformation renditions instead of the whole tenant cache', tags: ['cdn'], }), config: { @@ -83,6 +85,8 @@ export default async function routes(fastify: FastifyInstance) { params: purgeBucketParamsSchema, querystring: purgeQuerySchema, summary: 'Purge cache for an entire bucket or bucket transformations', + description: + 'Pass transformations=true in the query string to purge only cached image transformation renditions for the bucket instead of all of its cached responses', tags: ['cdn'], }), config: { @@ -111,6 +115,8 @@ export default async function routes(fastify: FastifyInstance) { params: purgeObjectParamsSchema, querystring: purgeQuerySchema, summary: 'Purge cache for an object or object transformations', + description: + 'Pass transformations=true in the query string to purge only cached image transformation renditions of the object instead of its plain cached response', tags: ['cdn'], }), config: { diff --git a/src/http/routes/health/healthcheck.ts b/src/http/routes/health/healthcheck.ts index deca8dc45..fda956c64 100644 --- a/src/http/routes/health/healthcheck.ts +++ b/src/http/routes/health/healthcheck.ts @@ -9,6 +9,8 @@ export default async function routes(fastify: FastifyInstance) { schema: { operationId: 'healthcheck', summary, + description: + 'Checks database connectivity and always responds with HTTP 200, reporting healthy: false in the body rather than an error status when the check fails', tags: ['health'], }, }, diff --git a/src/http/routes/object/copyObject.ts b/src/http/routes/object/copyObject.ts index a7e7bc066..528df27f3 100644 --- a/src/http/routes/object/copyObject.ts +++ b/src/http/routes/object/copyObject.ts @@ -43,6 +43,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { body: copyRequestBodySchema, summary, + description: + 'Copies metadata along with the object content by default (override via copyMetadata), can target a different destinationBucket, and requires x-upsert to overwrite an existing destination key', tags: ['object'], }) diff --git a/src/http/routes/object/createObject.ts b/src/http/routes/object/createObject.ts index 133ef4481..b3890b2ce 100644 --- a/src/http/routes/object/createObject.ts +++ b/src/http/routes/object/createObject.ts @@ -42,6 +42,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: createObjectParamsSchema, summary, + description: + 'Fails if an object already exists at the given key unless the x-upsert header is set to true', tags: ['object'], }) diff --git a/src/http/routes/object/deleteObject.ts b/src/http/routes/object/deleteObject.ts index c56423a6c..acb0e335e 100644 --- a/src/http/routes/object/deleteObject.ts +++ b/src/http/routes/object/deleteObject.ts @@ -28,6 +28,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: deleteObjectParamsSchema, summary, + description: + 'Deletes a single object by its exact key, unlike the bulk-delete endpoint which accepts multiple prefixes', tags: ['object'], }) diff --git a/src/http/routes/object/deleteObjects.ts b/src/http/routes/object/deleteObjects.ts index 074aacaf8..1810323b8 100644 --- a/src/http/routes/object/deleteObjects.ts +++ b/src/http/routes/object/deleteObjects.ts @@ -41,6 +41,8 @@ export default async function routes(fastify: FastifyInstance) { body: deleteObjectsBodySchema, params: deleteObjectsParamsSchema, summary, + description: + 'Accepts a list of object prefixes to delete in one request, capped by a configurable per-request limit', tags: ['object'], }) diff --git a/src/http/routes/object/getObject.ts b/src/http/routes/object/getObject.ts index 555c467b1..2a4602090 100644 --- a/src/http/routes/object/getObject.ts +++ b/src/http/routes/object/getObject.ts @@ -94,6 +94,8 @@ export default async function routes(fastify: FastifyInstance) { querystring: getObjectQuerySchema, headers: { $ref: 'authSchema#' }, summary, + description: + 'Requires a valid auth token and checks bucket/object access via RLS, regardless of whether the bucket is public', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['object'], }, diff --git a/src/http/routes/object/getObjectInfo.ts b/src/http/routes/object/getObjectInfo.ts index e5b798c59..7092695b8 100644 --- a/src/http/routes/object/getObjectInfo.ts +++ b/src/http/routes/object/getObjectInfo.ts @@ -141,6 +141,8 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { querystring: getObjectInfoQuerySchema, headers: { $ref: 'authSchema#' }, summary, + description: + 'Returns object metadata in headers only, with no body, and requires a valid auth token even for public buckets', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['object'], }, @@ -161,6 +163,8 @@ export async function authenticatedRoutes(fastify: FastifyInstance) { querystring: getObjectInfoQuerySchema, headers: { $ref: 'authSchema#' }, summary, + description: + 'Returns object metadata as a JSON body rather than headers only, and requires a valid auth token even for public buckets', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['object'], }, diff --git a/src/http/routes/object/getPublicObject.ts b/src/http/routes/object/getPublicObject.ts index 10b6805b8..80e28772f 100644 --- a/src/http/routes/object/getPublicObject.ts +++ b/src/http/routes/object/getPublicObject.ts @@ -40,6 +40,8 @@ export default async function routes(fastify: FastifyInstance) { params: getPublicObjectParamsSchema, querystring: getObjectQuerySchema, summary, + description: + 'Requires no authorization header but errors if the bucket is not marked public, unlike the authenticated retrieval endpoint', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['object'], }, diff --git a/src/http/routes/object/getSignedObject.ts b/src/http/routes/object/getSignedObject.ts index 867467448..2e1d24077 100644 --- a/src/http/routes/object/getSignedObject.ts +++ b/src/http/routes/object/getSignedObject.ts @@ -47,6 +47,8 @@ export default async function routes(fastify: FastifyInstance) { params: getSignedObjectParamsSchema, querystring: getSignedObjectQSSchema, summary, + description: + 'Requires no authorization header, relying instead on the signed token query parameter, and streams the object bytes rather than metadata', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['object'], }, diff --git a/src/http/routes/object/getSignedURL.ts b/src/http/routes/object/getSignedURL.ts index 6f2cfc1c1..80156b369 100644 --- a/src/http/routes/object/getSignedURL.ts +++ b/src/http/routes/object/getSignedURL.ts @@ -56,6 +56,8 @@ export default async function routes(fastify: FastifyInstance) { body: getSignedURLBodySchema, params: getSignedURLParamsSchema, summary, + description: + 'Returns a URL valid for the given expiresIn seconds; optional transform options are baked into the signed token so the download URL applies image transformations without further authorization', tags: ['object'], }) diff --git a/src/http/routes/object/getSignedURLs.ts b/src/http/routes/object/getSignedURLs.ts index 170b8f3cf..e7b45d7e8 100644 --- a/src/http/routes/object/getSignedURLs.ts +++ b/src/http/routes/object/getSignedURLs.ts @@ -66,6 +66,8 @@ export default async function routes(fastify: FastifyInstance) { body: getSignedURLsBodySchema, params: getSignedURLsParamsSchema, summary, + description: + 'Batches signed URL generation for multiple paths up to the configured max objects per request, returning a per-path error instead of failing the whole request when a path is inaccessible', tags: ['object'], }) diff --git a/src/http/routes/object/getSignedUploadURL.ts b/src/http/routes/object/getSignedUploadURL.ts index 4a8df863d..bbe908634 100644 --- a/src/http/routes/object/getSignedUploadURL.ts +++ b/src/http/routes/object/getSignedUploadURL.ts @@ -55,6 +55,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: getSignedUploadURLParamsSchema, summary, + description: + 'The returned token is valid for the configured upload signed URL expiration time and must be submitted to the presigned upload endpoint; set x-upsert to allow overwriting an existing object', tags: ['object'], }) diff --git a/src/http/routes/object/listObjects.ts b/src/http/routes/object/listObjects.ts index 96688ebb5..d198d1540 100644 --- a/src/http/routes/object/listObjects.ts +++ b/src/http/routes/object/listObjects.ts @@ -47,6 +47,8 @@ export default async function routes(fastify: FastifyInstance) { body: searchRequestBodySchema, params: searchRequestParamsSchema, summary, + description: + 'Legacy listing endpoint using limit/offset pagination and a required prefix, without the cursor-based pagination or delimiter grouping supported by list-v2', tags: ['object'], }) diff --git a/src/http/routes/object/listObjectsV2.ts b/src/http/routes/object/listObjectsV2.ts index 6ecd55e50..df2439670 100644 --- a/src/http/routes/object/listObjectsV2.ts +++ b/src/http/routes/object/listObjectsV2.ts @@ -48,6 +48,8 @@ export default async function routes(fastify: FastifyInstance) { body: searchRequestBodySchema, params: searchRequestParamsSchema, summary, + description: + 'Uses cursor-based pagination instead of limit/offset, and supports an optional delimiter to group results like folders, unlike the legacy list endpoint', tags: ['object'], }, config: { diff --git a/src/http/routes/object/moveObject.ts b/src/http/routes/object/moveObject.ts index 29c910235..11e0aa20a 100644 --- a/src/http/routes/object/moveObject.ts +++ b/src/http/routes/object/moveObject.ts @@ -31,6 +31,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { body: moveObjectsBodySchema, summary, + description: + 'Renames the object within the same bucket, or relocates it to destinationBucket when provided, without re-uploading the file content', tags: ['object'], }) diff --git a/src/http/routes/object/updateObject.ts b/src/http/routes/object/updateObject.ts index 42cd069fc..1061d0415 100644 --- a/src/http/routes/object/updateObject.ts +++ b/src/http/routes/object/updateObject.ts @@ -39,6 +39,8 @@ export default async function routes(fastify: FastifyInstance) { const schema = createDefaultSchema(successResponseSchema, { params: updateObjectParamsSchema, summary, + description: + 'Always overwrites the object at the given key, in contrast to the create-object endpoint which requires x-upsert to overwrite', tags: ['object'], }) diff --git a/src/http/routes/object/uploadSignedObject.ts b/src/http/routes/object/uploadSignedObject.ts index f923765d0..13c0d9b96 100644 --- a/src/http/routes/object/uploadSignedObject.ts +++ b/src/http/routes/object/uploadSignedObject.ts @@ -68,6 +68,8 @@ export default async function routes(fastify: FastifyInstance) { params: uploadSignedObjectParamsSchema, querystring: uploadSignedObjectQSSchema, summary, + description: + 'Verifies the token query parameter instead of requiring an authorization header, and does not need the caller to have direct bucket access', response: { 200: { description: 'Successful response', ...successResponseSchema }, '4xx': { $ref: 'errorSchema#', description: 'Error response' }, diff --git a/src/http/routes/render/renderAuthenticatedImage.ts b/src/http/routes/render/renderAuthenticatedImage.ts index 17b11724f..8f27dd72f 100644 --- a/src/http/routes/render/renderAuthenticatedImage.ts +++ b/src/http/routes/render/renderAuthenticatedImage.ts @@ -39,6 +39,8 @@ export default async function routes(fastify: FastifyInstance) { params: renderAuthenticatedImageParamsSchema, querystring: renderImageQuerySchema, summary, + description: + 'Requires a valid auth token and checks RLS access to the object, with transformation options passed directly as query parameters', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['transformation'], }, diff --git a/src/http/routes/render/renderPublicImage.ts b/src/http/routes/render/renderPublicImage.ts index e6de899c9..d46d97f85 100644 --- a/src/http/routes/render/renderPublicImage.ts +++ b/src/http/routes/render/renderPublicImage.ts @@ -39,6 +39,8 @@ export default async function routes(fastify: FastifyInstance) { params: renderPublicImageParamsSchema, querystring: renderImageQuerySchema, summary, + description: + 'Requires no authorization header but errors if the bucket is not marked public, with transformations passed as query parameters', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['transformation'], }, diff --git a/src/http/routes/render/renderSignedImage.ts b/src/http/routes/render/renderSignedImage.ts index 6f2066400..27588c1bd 100644 --- a/src/http/routes/render/renderSignedImage.ts +++ b/src/http/routes/render/renderSignedImage.ts @@ -45,6 +45,8 @@ export default async function routes(fastify: FastifyInstance) { params: renderAuthenticatedImageParamsSchema, querystring: renderImageQuerySchema, summary, + description: + 'Requires no authorization header, verifying the token query parameter instead, with the transformations already encoded in the signed token rather than passed as separate query parameters', response: { '4xx': { $ref: 'errorSchema#', description: 'Error response' } }, tags: ['transformation'], }, diff --git a/src/http/routes/tus/index.ts b/src/http/routes/tus/index.ts index b3dbcf4ce..711566e3a 100644 --- a/src/http/routes/tus/index.ts +++ b/src/http/routes/tus/index.ts @@ -263,7 +263,12 @@ const authenticatedRoutes = fastifyPlugin( fastify.post( '/', { - schema: { summary: 'Handle POST request for TUS Resumable uploads', tags: ['resumable'] }, + schema: { + summary: 'Handle POST request for TUS Resumable uploads', + description: + 'Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT', + tags: ['resumable'], + }, config: { operation: `${ROUTE_OPERATIONS.TUS_CREATE_UPLOAD}${options.operation || ''}`, }, @@ -276,7 +281,12 @@ const authenticatedRoutes = fastifyPlugin( fastify.post( '/*', { - schema: { summary: 'Handle POST request for TUS Resumable uploads', tags: ['resumable'] }, + schema: { + summary: 'Handle POST request for TUS Resumable uploads', + description: + 'Creates a new resumable upload and returns its Location URL; the sign/ variant authorizes via the x-signature header from a presigned token instead of a JWT', + tags: ['resumable'], + }, config: { operation: `${ROUTE_OPERATIONS.TUS_CREATE_UPLOAD}${options.operation || ''}`, }, @@ -289,7 +299,12 @@ const authenticatedRoutes = fastifyPlugin( fastify.put( '/*', { - schema: { summary: 'Handle PUT request for TUS Resumable uploads', tags: ['resumable'] }, + schema: { + summary: 'Handle PUT request for TUS Resumable uploads', + description: + 'Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload created by the POST step', + tags: ['resumable'], + }, config: { operation: `${ROUTE_OPERATIONS.TUS_UPLOAD_PART}${options.operation || ''}`, }, @@ -303,6 +318,8 @@ const authenticatedRoutes = fastifyPlugin( { schema: { summary: 'Handle PATCH request for TUS Resumable uploads', + description: + 'Appends a chunk of upload data at the given Upload-Offset to an in-progress resumable upload, per the standard TUS PATCH semantics', tags: ['resumable'], }, config: { @@ -316,7 +333,12 @@ const authenticatedRoutes = fastifyPlugin( fastify.head( '/*', { - schema: { summary: 'Handle HEAD request for TUS Resumable uploads', tags: ['resumable'] }, + schema: { + summary: 'Handle HEAD request for TUS Resumable uploads', + description: + 'Returns the current Upload-Offset and Upload-Length of an in-progress upload without transferring any data, and does not require authorization', + tags: ['resumable'], + }, config: { operation: `${ROUTE_OPERATIONS.TUS_GET_UPLOAD}${options.operation || ''}`, }, @@ -330,6 +352,8 @@ const authenticatedRoutes = fastifyPlugin( { schema: { summary: 'Handle DELETE request for TUS Resumable uploads', + description: + 'Cancels an in-progress resumable upload and discards any data received so far', tags: ['resumable'], }, config: { From edf65c635d29297843b7461db29e1941696bf155 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 09:35:48 -0300 Subject: [PATCH 08/14] fix: replace OpenAPI 3.1-only nullable type-array syntax with nullable: 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. --- src/http/routes/object/getSignedURLs.ts | 6 ++++-- src/storage/schemas/bucket.ts | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/http/routes/object/getSignedURLs.ts b/src/http/routes/object/getSignedURLs.ts index e7b45d7e8..651222229 100644 --- a/src/http/routes/object/getSignedURLs.ts +++ b/src/http/routes/object/getSignedURLs.ts @@ -37,7 +37,8 @@ const successResponseSchema = { type: 'object', properties: { error: { - type: ['string', 'null'], + type: 'string', + nullable: true, examples: ['Either the object does not exist or you do not have access to it'], }, path: { @@ -45,7 +46,8 @@ const successResponseSchema = { examples: ['folder/cat.png'], }, signedURL: { - type: ['string', 'null'], + type: 'string', + nullable: true, examples: [ '/object/sign/avatars/folder/cat.png?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1cmwiOiJhdmF0YXJzL2ZvbGRlci9jYXQucG5nIiwiaWF0IjoxNjE3NzI2MjczLCJleHAiOjE2MTc3MjcyNzN9.s7Gt8ME80iREVxPhH01ZNv8oUn4XtaWsmiQ5csiUHn4', ], diff --git a/src/storage/schemas/bucket.ts b/src/storage/schemas/bucket.ts index 7f73ab5e6..46207d7fa 100644 --- a/src/storage/schemas/bucket.ts +++ b/src/storage/schemas/bucket.ts @@ -10,8 +10,8 @@ export const bucketSchema = { owner_id: { type: 'string' }, public: { type: 'boolean' }, type: { type: 'string', enum: ['STANDARD', 'ANALYTICS'] }, - file_size_limit: { type: ['integer', 'null'] }, - allowed_mime_types: { type: ['array', 'null'], items: { type: 'string' } }, + file_size_limit: { type: 'integer', nullable: true }, + allowed_mime_types: { type: 'array', nullable: true, items: { type: 'string' } }, created_at: { type: 'string' }, updated_at: { type: 'string' }, }, From faeeac21f4804aa38b4fe31bdb9d67b9a5471ddd Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 09:37:32 -0300 Subject: [PATCH 09/14] fix: replace OpenAPI 3.1-only anyOf-null nullable syntax with nullable: true Same problem as the previous commit's type-array fix, different spelling: anyOf: [, {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. --- src/storage/schemas/object.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/storage/schemas/object.ts b/src/storage/schemas/object.ts index edd877036..2ec877abf 100644 --- a/src/storage/schemas/object.ts +++ b/src/storage/schemas/object.ts @@ -9,15 +9,19 @@ export const objectSchema = { owner: { type: 'string' }, owner_id: { type: 'string' }, version: { type: 'string' }, - id: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - updated_at: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - created_at: { anyOf: [{ type: 'string' }, { type: 'null' }] }, - last_accessed_at: { anyOf: [{ type: 'string' }, { type: 'null' }] }, + id: { type: 'string', nullable: true }, + updated_at: { type: 'string', nullable: true }, + created_at: { type: 'string', nullable: true }, + last_accessed_at: { type: 'string', nullable: true }, metadata: { - anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], + type: 'object', + additionalProperties: true, + nullable: true, }, user_metadata: { - anyOf: [{ type: 'object', additionalProperties: true }, { type: 'null' }], + type: 'object', + additionalProperties: true, + nullable: true, }, buckets: { $ref: 'bucketSchema#' }, }, From 379655fafafaf00b0ecea7dc5a798cd6130b46c5 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Wed, 8 Jul 2026 09:41:57 -0300 Subject: [PATCH 10/14] fix: drop redundant top-level anyOf from bucketUpdate request body 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. --- src/http/routes/bucket/updateBucket.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/http/routes/bucket/updateBucket.ts b/src/http/routes/bucket/updateBucket.ts index 0a292b35f..eee8aca6b 100644 --- a/src/http/routes/bucket/updateBucket.ts +++ b/src/http/routes/bucket/updateBucket.ts @@ -21,11 +21,6 @@ const updateBucketBodySchema = { items: { type: 'string', examples: [['image/png', 'image/jpg']] }, }, }, - anyOf: [ - { required: ['public'] }, - { required: ['file_size_limit'] }, - { required: ['allowed_mime_types'] }, - ], } as const const updateBucketParamsSchema = { type: 'object', From db9994aa0a2ad85efbed9f36628f3d5e2f469306 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 07:30:34 -0300 Subject: [PATCH 11/14] fix: default every operation's 4xx response to errorSchema 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. --- src/admin-app.ts | 4 +++- src/http/error-handler.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/admin-app.ts b/src/admin-app.ts index 8d8eeb7cc..3990ab427 100644 --- a/src/admin-app.ts +++ b/src/admin-app.ts @@ -4,7 +4,7 @@ import { handleMetricsRequest } from '@internal/monitoring/otel-metrics' import { getGlobal } from '@platformatic/globals' import fastify, { FastifyInstance, FastifyServerOptions } from 'fastify' import { getConfig } from './config' -import { plugins, routes, setErrorHandler } from './http' +import { plugins, routes, schemas, setErrorHandler } from './http' import { createOpenApiTransform, dedupeTrailingSlashPaths, @@ -61,6 +61,8 @@ const build = (opts: buildOpts = {}): FastifyInstance => { }) } + app.addSchema(schemas.errorSchema) + app.register(plugins.requestContext) app.register(plugins.signals) app.register(plugins.adminTenantId) diff --git a/src/http/error-handler.ts b/src/http/error-handler.ts index 2bea093d6..215fd8da9 100644 --- a/src/http/error-handler.ts +++ b/src/http/error-handler.ts @@ -23,6 +23,20 @@ export const setErrorHandler = ( formatter?: (error: StorageError) => Record } ) => { + // Every route can hit this handler and get back the {statusCode, error, message} shape + // sent below, regardless of app (main or admin) or which helper built its schema - default + // every route's OpenAPI doc to that shape for any 4xx, without overriding a status code a + // route already documents more specifically (e.g. jwt.ts's 403, apikey.ts's 401). + app.addHook('onRoute', (routeOptions) => { + routeOptions.schema = routeOptions.schema || {} + const hadResponseSchema = Boolean(routeOptions.schema.response) + routeOptions.schema.response = { + ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), + '4xx': { description: 'Error response', $ref: 'errorSchema#' }, + ...(routeOptions.schema.response as object | undefined), + } + }) + app.setErrorHandler(function (error, request, reply) { const formatter = options?.formatter || ((e) => e) // We assign the error received. From 90f07e8a60c61bf6968d55f08f274c8fd32f6ae2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 08:02:37 -0300 Subject: [PATCH 12/14] fix: CI failures from the global 4xx-default onRoute hook 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). --- src/http/error-handler.ts | 65 ++++++++++++++++++++++++++------ src/http/routes/iceberg/index.ts | 15 ++++++++ src/http/schemas/error.ts | 3 +- 3 files changed, 70 insertions(+), 13 deletions(-) diff --git a/src/http/error-handler.ts b/src/http/error-handler.ts index 215fd8da9..c2e868304 100644 --- a/src/http/error-handler.ts +++ b/src/http/error-handler.ts @@ -8,6 +8,7 @@ import { } from '@internal/errors' import { isDatabaseSlowDownError } from '@internal/errors/database-error' import { FastifyInstance } from 'fastify' +import { errorSchema } from './schemas/error' /** * The global error handler for all the uncaught exceptions within a request. @@ -21,21 +22,61 @@ export const setErrorHandler = ( options?: { respectStatusCode?: boolean formatter?: (error: StorageError) => Record + // JSON schema fragment matching what `formatter` actually produces - required alongside + // `formatter` to keep the doc default (below) truthful for a subtree with a reshaped + // error body, e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}. + errorResponseSchema?: object } ) => { - // Every route can hit this handler and get back the {statusCode, error, message} shape - // sent below, regardless of app (main or admin) or which helper built its schema - default - // every route's OpenAPI doc to that shape for any 4xx, without overriding a status code a - // route already documents more specifically (e.g. jwt.ts's 403, apikey.ts's 401). - app.addHook('onRoute', (routeOptions) => { - routeOptions.schema = routeOptions.schema || {} - const hadResponseSchema = Boolean(routeOptions.schema.response) - routeOptions.schema.response = { - ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), - '4xx': { description: 'Error response', $ref: 'errorSchema#' }, - ...(routeOptions.schema.response as object | undefined), + // Every route can hit this handler and get back the shape sent below, regardless of app + // (main or admin) or which helper built its schema - default every route's OpenAPI doc to + // that shape for any 4xx: a plain flat {statusCode, error, message, code} normally, or + // `errorResponseSchema` when this call also passes a custom `formatter` that reshapes the + // wire response (e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}). + // The formatter branch below overrides rather than only filling a gap, since this hook runs + // after any ancestor's (registerJwtAuth's 403, or a shallower setErrorHandler's own flat + // default - both added earlier in every caller of this file) and a subtree with its own + // formatter must not stay stuck documenting its ancestor's flat shape. That's not just a doc + // bug: fast-json-stringify chokes on the type mismatch (error typed as a string in + // errorSchema, sent as an object by the REST-catalog formatter) and 500s. + if (!options?.formatter) { + // The onRoute hook below needs errorSchema registered on this instance to resolve its + // $ref - register it here instead of requiring every caller (including tests that build + // a bare Fastify() and only call setErrorHandler) to remember to do it themselves. Guarded + // since app.ts/admin-app.ts also register it directly for their own schemas' sake. + if (!app.getSchema('errorSchema')) { + app.addSchema(errorSchema) } - }) + + app.addHook('onRoute', (routeOptions) => { + routeOptions.schema = routeOptions.schema || {} + const hadResponseSchema = Boolean(routeOptions.schema.response) + routeOptions.schema.response = { + ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), + '4xx': { description: 'Error response', $ref: 'errorSchema#' }, + ...(routeOptions.schema.response as object | undefined), + } + }) + } else if (options.errorResponseSchema) { + app.addHook('onRoute', (routeOptions) => { + routeOptions.schema = routeOptions.schema || {} + const hadResponseSchema = Boolean(routeOptions.schema.response) + const existingResponse = { ...(routeOptions.schema.response as object | undefined) } + // Drop any status-specific entry an ancestor already added (registerJwtAuth's 403, + // a shallower setErrorHandler's 4xx) - all of them assumed the flat errorSchema shape, + // which this subtree's formatter doesn't produce, so none of them are still accurate. + for (const status of Object.keys(existingResponse)) { + if (status !== '200') { + delete (existingResponse as Record)[status] + } + } + routeOptions.schema.response = { + ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), + ...existingResponse, + '4xx': { description: 'Error response', ...options.errorResponseSchema }, + } + }) + } app.setErrorHandler(function (error, request, reply) { const formatter = options?.formatter || ((e) => e) diff --git a/src/http/routes/iceberg/index.ts b/src/http/routes/iceberg/index.ts index 0d864e2ed..cea710acb 100644 --- a/src/http/routes/iceberg/index.ts +++ b/src/http/routes/iceberg/index.ts @@ -49,6 +49,21 @@ export default async function routes(fastify: FastifyInstance) { }, } }, + errorResponseSchema: { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + message: { type: 'string' }, + type: { type: 'string' }, + code: { type: 'number' }, + }, + required: ['message', 'type', 'code'], + }, + }, + required: ['error'], + }, }) }) } diff --git a/src/http/schemas/error.ts b/src/http/schemas/error.ts index 6357f2f00..a66ea3dd9 100644 --- a/src/http/schemas/error.ts +++ b/src/http/schemas/error.ts @@ -5,6 +5,7 @@ export const errorSchema = { statusCode: { type: 'string' }, error: { type: 'string' }, message: { type: 'string' }, + code: { type: 'string' }, }, - required: ['statusCode', 'error', 'message'], + required: ['statusCode', 'error', 'message', 'code'], } as const From 085135166f802b2e8032d82db366c2ba5397b377 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 08:15:46 -0300 Subject: [PATCH 13/14] fix: move the generic 4xx default from a real onRoute hook to docs-only 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). --- src/http/error-handler.ts | 50 +++++++++++----------------- src/http/routes/openapi-transform.ts | 28 ++++++++++++++++ 2 files changed, 47 insertions(+), 31 deletions(-) diff --git a/src/http/error-handler.ts b/src/http/error-handler.ts index c2e868304..5e4f622be 100644 --- a/src/http/error-handler.ts +++ b/src/http/error-handler.ts @@ -8,7 +8,6 @@ import { } from '@internal/errors' import { isDatabaseSlowDownError } from '@internal/errors/database-error' import { FastifyInstance } from 'fastify' -import { errorSchema } from './schemas/error' /** * The global error handler for all the uncaught exceptions within a request. @@ -28,36 +27,25 @@ export const setErrorHandler = ( errorResponseSchema?: object } ) => { - // Every route can hit this handler and get back the shape sent below, regardless of app - // (main or admin) or which helper built its schema - default every route's OpenAPI doc to - // that shape for any 4xx: a plain flat {statusCode, error, message, code} normally, or - // `errorResponseSchema` when this call also passes a custom `formatter` that reshapes the - // wire response (e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}). - // The formatter branch below overrides rather than only filling a gap, since this hook runs - // after any ancestor's (registerJwtAuth's 403, or a shallower setErrorHandler's own flat - // default - both added earlier in every caller of this file) and a subtree with its own - // formatter must not stay stuck documenting its ancestor's flat shape. That's not just a doc - // bug: fast-json-stringify chokes on the type mismatch (error typed as a string in - // errorSchema, sent as an object by the REST-catalog formatter) and 500s. - if (!options?.formatter) { - // The onRoute hook below needs errorSchema registered on this instance to resolve its - // $ref - register it here instead of requiring every caller (including tests that build - // a bare Fastify() and only call setErrorHandler) to remember to do it themselves. Guarded - // since app.ts/admin-app.ts also register it directly for their own schemas' sake. - if (!app.getSchema('errorSchema')) { - app.addSchema(errorSchema) - } - - app.addHook('onRoute', (routeOptions) => { - routeOptions.schema = routeOptions.schema || {} - const hadResponseSchema = Boolean(routeOptions.schema.response) - routeOptions.schema.response = { - ...(hadResponseSchema ? undefined : { 200: { description: 'Default Response' } }), - '4xx': { description: 'Error response', $ref: 'errorSchema#' }, - ...(routeOptions.schema.response as object | undefined), - } - }) - } else if (options.errorResponseSchema) { + // registerJwtAuth's onRoute hook documents a flat {statusCode, error, message, code} 403 on + // every route it guards, since that's what a real AccessDenied error always renders as - true + // for every subtree except one with a custom `formatter` here that reshapes the wire response + // entirely (e.g. iceberg/index.ts's REST-catalog-spec {error: {message, type, code}}). Without + // this override, that subtree's routes would keep documenting (and, worse, get response- + // schema-serialized against) the wrong flat shape: fast-json-stringify chokes on the type + // mismatch (error typed as a string in errorSchema, sent as an object) and 500s. This hook + // runs after registerJwtAuth's (added earlier in every caller of this file) so it overrides + // rather than only filling a gap. + // + // There's deliberately no equivalent generic default for the plain (no formatter) case: this + // codebase has many handlers that reply with an ad-hoc, partial error body directly + // (`reply.status(400).send({message: '...'})`, bypassing this file's formatter entirely), and + // forcing errorSchema's `required` fields onto those routes' response serialization makes + // fast-json-stringify throw on the ones actually missing a field - a real, previously-hit + // regression (see git history). The generic "errorSchema is probably the shape of a 4xx" + // default lives in openapi-transform.ts's doc-only transform instead, which can't affect + // real request handling either way. + if (options?.formatter && options.errorResponseSchema) { app.addHook('onRoute', (routeOptions) => { routeOptions.schema = routeOptions.schema || {} const hadResponseSchema = Boolean(routeOptions.schema.response) diff --git a/src/http/routes/openapi-transform.ts b/src/http/routes/openapi-transform.ts index 8e8c18582..46aa68fce 100644 --- a/src/http/routes/openapi-transform.ts +++ b/src/http/routes/openapi-transform.ts @@ -93,6 +93,33 @@ function operationToId(operation: string): string { .join('') } +/** + * Every route can end up hitting setErrorHandler and getting back a {statusCode, error, + * message, code} body (or, for a subtree with its own formatter, whatever that subtree + * documents instead) - default the doc to that shape for any otherwise-undocumented 4xx. + * Doc-only on purpose: several handlers reply with an ad-hoc, partial error body directly + * (`reply.status(400).send({message: '...'})`, bypassing the formatter entirely), and an + * earlier version of this defaulted via a real onRoute hook that made Fastify enforce + * errorSchema's `required` fields during response *serialization* - which threw on exactly + * those ad-hoc replies (fast-json-stringify errors on a missing required property rather + * than dropping it). A transform can't affect request handling, so it can't cause that. + */ +function defaultErrorResponse(schema: FastifySchema | undefined): FastifySchema { + const response = schema?.response as Record | undefined + if (schema && response && Object.keys(response).some((status) => /^4xx$/i.test(status))) { + return schema + } + + return { + ...schema, + response: { + ...(response ? undefined : { 200: { description: 'Default Response' } }), + '4xx': { description: 'Error response', $ref: 'errorSchema#' }, + ...response, + }, + } +} + /** * OpenAPI requires operationId to be unique across the whole document. `exposeHeadRoutes` * auto-derives a HEAD operation from every GET route re-using the same `config.operation`, @@ -117,6 +144,7 @@ export function createOpenApiTransform() { } ;({ schema, url } = renameWildcardParam(schema, url)) + schema = defaultErrorResponse(schema) const operation = (route.config as { operation?: string } | undefined)?.operation From 876e94e278bd73f75ad07349d24f19b55fa9637c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 Jul 2026 09:23:52 -0300 Subject: [PATCH 14/14] fix: revert errorSchema's code field - it unmasked a long-standing strip 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. --- src/http/schemas/error.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/http/schemas/error.ts b/src/http/schemas/error.ts index a66ea3dd9..6357f2f00 100644 --- a/src/http/schemas/error.ts +++ b/src/http/schemas/error.ts @@ -5,7 +5,6 @@ export const errorSchema = { statusCode: { type: 'string' }, error: { type: 'string' }, message: { type: 'string' }, - code: { type: 'string' }, }, - required: ['statusCode', 'error', 'message', 'code'], + required: ['statusCode', 'error', 'message'], } as const