Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 80 additions & 1 deletion src/http/routes/openapi-transform.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,87 @@
import { RouteOptions } from 'fastify'
import { FastifySchema, RouteOptions } from 'fastify'
import { sharedErrorResponseSchemas } from '../schemas/error'
import { createOpenApiTransform } from './openapi-transform'
import { ROUTE_OPERATIONS } from './operations'

function routeWithOperation(operation?: string): RouteOptions {
return {
method: 'POST',
url: '/object/:bucketName/*',
config: operation ? { operation } : undefined,
} as RouteOptions
}

describe('documentMultipartUploadBody (via transformOpenApiSchema)', () => {
const multipartOperations = [
ROUTE_OPERATIONS.CREATE_OBJECT,
ROUTE_OPERATIONS.UPDATE_OBJECT,
ROUTE_OPERATIONS.UPLOAD_SIGN_OBJECT,
]

it.each(multipartOperations)('documents the multipart form body for %s', (operation) => {
const transform = createOpenApiTransform()
const { schema } = transform({
schema: {} as FastifySchema,
url: '/object/:bucketName/*',
route: routeWithOperation(operation),
})

const body = schema.body as { content: Record<string, { schema: unknown }> }
expect(body.content['multipart/form-data'].schema).toEqual({
type: 'object',
properties: {
cacheControl: { type: 'string', description: "Defaults to 'no-cache' if not set." },
metadata: {
type: 'string',
description: 'JSON-encoded custom metadata. Alias: userMetadata.',
},
userMetadata: { type: 'string', description: 'Alias for metadata.' },
contentType: { type: 'string', description: 'Overrides the auto-detected mime type.' },
file: { type: 'string', format: 'binary' },
},
required: ['file'],
})
})

it.each(multipartOperations)('documents the raw (non-multipart) body for %s', (operation) => {
const transform = createOpenApiTransform()
const { schema } = transform({
schema: {} as FastifySchema,
url: '/object/:bucketName/*',
route: routeWithOperation(operation),
})

const body = schema.body as { content: Record<string, { schema: Record<string, unknown> }> }
expect(body.content['*/*'].schema.type).toBe('string')
expect(body.content['*/*'].schema.format).toBe('binary')
expect(body.content['*/*'].schema.description).toContain('x-metadata')
})

it('leaves an unrelated operation unchanged', () => {
const transform = createOpenApiTransform()
const { schema } = transform({
schema: {},
url: '/object/:bucketName',
route: routeWithOperation(ROUTE_OPERATIONS.GET_AUTH_OBJECT),
})

expect(schema.body).toBeUndefined()
expect(schema.consumes).toBeUndefined()
})

it('leaves a route with no operation unchanged', () => {
const transform = createOpenApiTransform()
const { schema } = transform({
schema: {},
url: '/object/:bucketName',
route: routeWithOperation(undefined),
})

expect(schema.body).toBeUndefined()
expect(schema.consumes).toBeUndefined()
})
})

describe('defaultErrorResponse (via transformOpenApiSchema)', () => {
function routeAt(url: string): RouteOptions {
return { method: 'GET', url } as RouteOptions
Expand Down
76 changes: 76 additions & 0 deletions src/http/routes/openapi-transform.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { SwaggerTransformObject } from '@fastify/swagger'
import { FastifySchema, RouteOptions } from 'fastify'
import { ROUTE_OPERATIONS } from './operations'

/**
* @fastify/swagger names every de-duplicated component schema `def-0`, `def-1`, ... by
Expand Down Expand Up @@ -133,6 +134,80 @@ function defaultErrorResponse(schema: FastifySchema | undefined, url: string): F
}
}

const MULTIPART_UPLOAD_OPERATIONS = new Set<string>([
ROUTE_OPERATIONS.CREATE_OBJECT,
ROUTE_OPERATIONS.UPDATE_OBJECT,
ROUTE_OPERATIONS.UPLOAD_SIGN_OBJECT,
])

/**
* createObject/updateObject/uploadSignedObject never set `schema.body` - they read the raw
* upload stream directly via `uploadFromRequest` -> `fileUploadFromRequest`
* (see src/storage/uploader.ts), without registering `@fastify/multipart`'s
* `attachFieldsToBody`. That means `request.body` is always `undefined` on these routes, so a
* real `schema.body` would make Fastify validate that `undefined` (substituted as `null`)
* against a required-fields schema on every real upload and fail it with a 400 - a production
* regression, not a docs improvement. Document the request body here instead, transform-only,
* where - like `defaultErrorResponse` above - it can never reach live request validation.
*
* `fileUploadFromRequest` accepts two distinct request shapes depending on Content-Type
* (see src/storage/uploader.ts):
* - `multipart/form-data`: file plus form fields for cacheControl/metadata/contentType.
* - anything else: the raw file bytes as the entire body, with the same metadata carried
* over HTTP headers instead of form fields (Cache-Control, Content-Type, x-metadata).
* `schema.body.content` (rather than the `consumes`/`body` shorthand used elsewhere in this
* file) is @fastify/swagger's supported escape hatch for documenting more than one
* content-type with a genuinely different body schema per type - see its README's
* "Different content types" section (documented there for responses, supported identically
* for requests by the same resolveBodyParams code path).
*/
function documentMultipartUploadBody(schema: FastifySchema, route: RouteOptions): FastifySchema {
const operation = (route.config as { operation?: string } | undefined)?.operation
if (!operation || !MULTIPART_UPLOAD_OPERATIONS.has(operation)) {
return schema
}

return {
...schema,
body: {
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
cacheControl: { type: 'string', description: "Defaults to 'no-cache' if not set." },
metadata: {
type: 'string',
description: 'JSON-encoded custom metadata. Alias: userMetadata.',
},
userMetadata: { type: 'string', description: 'Alias for metadata.' },
contentType: {
type: 'string',
description: 'Overrides the auto-detected mime type.',
},
file: { type: 'string', format: 'binary' },
},
required: ['file'],
},
},
'*/*': {
schema: {
type: 'string',
format: 'binary',
description:
'Raw file bytes as the entire body (any Content-Type other than ' +
'multipart/form-data). Cache-Control and Content-Type headers set the ' +
"cache-control/mime type (Content-Type also defaults to 'application/" +
"octet-stream' if omitted); the x-metadata header carries custom metadata " +
'as base64-encoded JSON (unlike the plain JSON-encoded metadata/userMetadata ' +
'form field above).',
},
},
},
},
}
}

/**
* OpenAPI requires operationId to be unique across the whole document. A route can set
* `config.operationId` to pin its id explicitly (takes precedence over the derived
Expand Down Expand Up @@ -168,6 +243,7 @@ export function createOpenApiTransform() {

;({ schema, url } = renameWildcardParam(schema, url))
schema = defaultErrorResponse(schema, url)
schema = documentMultipartUploadBody(schema, route)

const baseId =
route.config?.operationId ??
Expand Down