diff --git a/.changeset/tiny-socks-march.md b/.changeset/tiny-socks-march.md new file mode 100644 index 00000000..189370cd --- /dev/null +++ b/.changeset/tiny-socks-march.md @@ -0,0 +1,24 @@ +--- +"@cleverbrush/server": minor +"@cleverbrush/server-openapi": minor +--- + +feat(server): add file upload support via `.upload()` and `FilePart` type + +Adds `multipart/form-data` parsing with `@fastify/busboy`, a new `.upload()` +method on `EndpointBuilder`, and the `FilePart` type for handling uploaded +files in endpoint handlers. The OpenAPI generator emits `multipart/form-data` +request bodies for upload-enabled endpoints. + +```ts +const ep = endpoint + .post("/api/avatar") + .upload({ maxFileSize: 2 * 1024 * 1024 }) + .body(object({ description: string().optional() })); + +server.handle(ep, ({ files }) => { + const avatar = files["avatar"]; + // { filename, mimeType, buffer, size } +}); +``` + diff --git a/demos/todo-backend/migrations/20260502000001_add_todo_attachment.ts b/demos/todo-backend/migrations/20260502000001_add_todo_attachment.ts new file mode 100644 index 00000000..82e36bf3 --- /dev/null +++ b/demos/todo-backend/migrations/20260502000001_add_todo_attachment.ts @@ -0,0 +1,17 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('todos', (table) => { + table.binary('attachment_data').nullable(); + table.string('attachment_name', 1024).nullable(); + table.string('attachment_mime_type', 255).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.table('todos', (table) => { + table.dropColumn('attachment_data'); + table.dropColumn('attachment_name'); + table.dropColumn('attachment_mime_type'); + }); +} diff --git a/demos/todo-backend/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index 4b814715..10fb5e9f 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -176,6 +176,20 @@ export const api = defineApi({ route({ id: number().coerce() })`/${t => t.id}/attachment` ), + uploadAttachment: todosResource + .post( + route({ id: number().coerce() })`/${t => t.id}/attachment` + ) + .upload({ + maxFileSize: 10 * 1024 * 1024, + allowedMimeTypes: [ + 'image/*', + 'application/pdf', + 'text/plain' + ] + }) + .body(object({ description: string().optional() })), + listActivity: todosResource .get( route({ id: number().coerce() })`/${t => t.id}/activity` diff --git a/demos/todo-backend/src/api/endpoints.ts b/demos/todo-backend/src/api/endpoints.ts index 83dd52c4..59be1988 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -1,5 +1,6 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars import { POLYMORPHIC_TYPE_BRAND } from '@cleverbrush/orm'; +import { TodoResponseSchema } from './schemas.js'; import { defineWebhook } from '@cleverbrush/server'; import { DbToken, @@ -184,16 +185,32 @@ export const ExportTodosEndpoint = api.todos.exportCsv export const DownloadAttachmentEndpoint = api.todos.downloadAttachment .authorize(PrincipalSchema) - .inject({ db: DbToken }) - .producesFile('text/plain', 'A plain-text summary of the todo.') + .inject({ knex: KnexToken }) .summary('Download todo attachment') .description( - 'Downloads a plain-text summary of the todo as a file attachment. ' + - 'Demonstrates `.producesFile()` and `ActionResult.file()`.' + 'Downloads the uploaded file attachment for a todo. ' + + 'Returns the original file with its original content type.' ) .tags('todos') .operationId('downloadTodoAttachment'); +// ── Upload attachment ───────────────────────────────────────────────────────── +// Features: .upload(), multipart/form-data, FilePart, file persistence in DB + +export const UploadAttachmentEndpoint = api.todos.uploadAttachment + .authorize(PrincipalSchema) + .inject({ db: DbToken, knex: KnexToken }) + .responses({ 201: TodoResponseSchema }) + .summary('Upload todo attachment') + .description( + 'Uploads a file attachment for a todo. ' + + 'Supports images, PDFs, and plain text files up to 10 MB. ' + + 'The file is stored in the database and can be downloaded via ' + + 'the download attachment endpoint.' + ) + .tags('todos') + .operationId('uploadTodoAttachment'); + // ── Import todos ────────────────────────────────────────────────────────────── // Features: .example(), .examples(), .headers(), ActionResult.json(), ActionResult.accepted() @@ -396,6 +413,7 @@ export const endpoints = { sendEvent: SendTodoEventEndpoint, exportCsv: ExportTodosEndpoint, downloadAttachment: DownloadAttachmentEndpoint, + uploadAttachment: UploadAttachmentEndpoint, importBulk: ImportTodosEndpoint, legacyReplace: LegacyReplaceTodoEndpoint, complete: CompleteTodoEndpoint, diff --git a/demos/todo-backend/src/api/handlers/index.ts b/demos/todo-backend/src/api/handlers/index.ts index d35c3cb2..3b157463 100644 --- a/demos/todo-backend/src/api/handlers/index.ts +++ b/demos/todo-backend/src/api/handlers/index.ts @@ -26,7 +26,8 @@ import { listTodoActivityHandler, listTodosHandler, sendTodoEventHandler, - updateTodoHandler + updateTodoHandler, + uploadAttachmentHandler } from './todos.js'; import { deleteUserHandler, @@ -51,6 +52,7 @@ export const handlers: HandlerMap = { sendEvent: sendTodoEventHandler, exportCsv: exportTodosHandler, downloadAttachment: downloadAttachmentHandler, + uploadAttachment: uploadAttachmentHandler, importBulk: importTodosHandler, legacyReplace: legacyReplaceTodoHandler, complete: completeTodoHandler, diff --git a/demos/todo-backend/src/api/handlers/todos.ts b/demos/todo-backend/src/api/handlers/todos.ts index ffaa13b6..0c60aea1 100644 --- a/demos/todo-backend/src/api/handlers/todos.ts +++ b/demos/todo-backend/src/api/handlers/todos.ts @@ -1,4 +1,5 @@ -import { ActionResult, type Handler } from '@cleverbrush/server'; +import { ActionResult, BadRequestError, ForbiddenError, type Handler, NotFoundError } from '@cleverbrush/server'; +import type { Knex } from 'knex'; import { withSpan } from '@cleverbrush/otel'; import { TodoCompleted, @@ -23,6 +24,7 @@ import type { ListAllActivityEndpoint, ListTodoActivityEndpoint, ListTodosEndpoint, + UploadAttachmentEndpoint, SendTodoEventEndpoint, UpdateTodoEndpoint } from '../endpoints.js'; @@ -367,38 +369,98 @@ export const exportTodosHandler: Handler = async ( export const downloadAttachmentHandler: Handler< typeof DownloadAttachmentEndpoint -> = async ({ params, principal }, { db }) => { +> = async ({ params, principal }, { knex }) => { + const row = await (knex as Knex)('todos') + .select( + 'attachment_data', + 'attachment_name', + 'attachment_mime_type', + 'user_id' + ) + .where('id', params.id) + .first(); + + if (!row) { + throw new NotFoundError(`Todo ${params.id} not found.`); + } + + if ( + principal.role !== 'admin' && + (row as Record).user_id !== principal.userId + ) { + throw new ForbiddenError('You do not have access to this todo.'); + } + + if (!(row as Record).attachment_data) { + throw new NotFoundError('No attachment for this todo.'); + } + + const r = row as { + attachment_data: Buffer; + attachment_name: string; + attachment_mime_type: string; + }; + + return ActionResult.file( + r.attachment_data, + r.attachment_name, + r.attachment_mime_type + ); +}; + +// ── Upload todo attachment ──────────────────────────────────────────────────── + +export const uploadAttachmentHandler: Handler< + typeof UploadAttachmentEndpoint +> = async ({ params, principal, files, rejectedFiles }, { db, knex }) => { const todo = await db.todos.find(params.id); if (!todo) { - return ActionResult.notFound({ - message: `Todo ${params.id} not found.` - }); + throw new NotFoundError(`Todo ${params.id} not found.`); } if (principal.role !== 'admin' && todo.userId !== principal.userId) { - return ActionResult.forbidden({ - message: 'You do not have access to this todo.' - }); + throw new ForbiddenError('You do not have access to this todo.'); } - const mapped = await mapTodo(todo); - const text = [ - `Todo #${mapped.id}`, - `Title: ${mapped.title}`, - mapped.description ? `Description: ${mapped.description}` : null, - `Completed: ${mapped.completed ? 'Yes' : 'No'}`, - `Created: ${mapped.createdAt.toISOString()}`, - `Updated: ${mapped.updatedAt.toISOString()}` - ] - .filter(Boolean) - .join('\n'); + const file = files['attachment']; + if (!file) { + let detail = 'No file uploaded. Use field name "attachment".'; + if (rejectedFiles && rejectedFiles.length > 0) { + const reasons = rejectedFiles + .map(r => `${r.filename}: ${r.reason}`) + .join('; '); + detail += ` Rejected: ${reasons}`; + } + throw new BadRequestError(detail); + } - return ActionResult.file( - Buffer.from(text, 'utf-8'), - `todo-${params.id}.txt`, - 'text/plain' - ); + // Persist file in DB — raw knex for bytea column + await (knex as Knex)('todos') + .where('id', params.id) + .update({ + attachment_data: file.buffer, + attachment_name: file.filename, + attachment_mime_type: file.mimeType, + updated_at: new Date() + }); + + // Re-fetch the updated todo for the response + const updated = await db.todos.find(params.id); + if (!updated) throw new NotFoundError(`Todo ${params.id} not found.`); + + return ActionResult.created({ + id: updated.id, + title: updated.title, + description: updated.description, + completed: updated.completed, + userId: updated.userId, + createdAt: updated.createdAt, + updatedAt: updated.updatedAt, + attachmentName: updated.attachmentName, + attachmentMimeType: updated.attachmentMimeType, + attachmentSize: file.size + }); }; // ── Bulk import todos ───────────────────────────────────────────────────────── diff --git a/demos/todo-backend/src/api/mappers.ts b/demos/todo-backend/src/api/mappers.ts index f4258e6c..eaba2470 100644 --- a/demos/todo-backend/src/api/mappers.ts +++ b/demos/todo-backend/src/api/mappers.ts @@ -20,7 +20,9 @@ const TodoRowSchema = object({ completed: boolean(), userId: number(), createdAt: date(), - updatedAt: date() + updatedAt: date(), + attachmentName: string().optional(), + attachmentMimeType: string().optional() }); export const mappingRegistry = mapper() @@ -28,7 +30,11 @@ export const mappingRegistry = mapper() m.for(t => t.authProvider).compute(f => f.authProvider) ) .configure(TodoRowSchema, TodoResponseSchema, m => - m.for(t => t.description).compute(f => f.description ?? undefined) + m + .for(t => t.description) + .compute(f => f.description ?? undefined) + .for(t => t.attachmentSize) + .ignore() ); const _mapUserFn = mappingRegistry.getMapper(UserRowSchema, UserResponseSchema); diff --git a/demos/todo-backend/src/api/schemas.ts b/demos/todo-backend/src/api/schemas.ts index e71643db..d5dd9480 100644 --- a/demos/todo-backend/src/api/schemas.ts +++ b/demos/todo-backend/src/api/schemas.ts @@ -104,7 +104,16 @@ export const TodoResponseSchema = object({ .describe('ISO 8601 timestamp of when the todo was created.'), updatedAt: date() .coerce() - .describe('ISO 8601 timestamp of the last update.') + .describe('ISO 8601 timestamp of the last update.'), + attachmentName: string() + .optional() + .describe('Original filename of the uploaded attachment.'), + attachmentMimeType: string() + .optional() + .describe('MIME type of the uploaded attachment.'), + attachmentSize: number() + .optional() + .describe('Size of the uploaded attachment in bytes.') }).schemaName('TodoResponse'); export type TodoResponse = InferType; diff --git a/demos/todo-backend/src/db/schemas.ts b/demos/todo-backend/src/db/schemas.ts index 50b00c57..277907e2 100644 --- a/demos/todo-backend/src/db/schemas.ts +++ b/demos/todo-backend/src/db/schemas.ts @@ -118,6 +118,12 @@ const TodoSchema = object({ .index('idx_todos_user_id'), createdAt: date().hasColumnName('created_at'), updatedAt: date().hasColumnName('updated_at'), + attachmentName: string() + .hasColumnName('attachment_name') + .optional(), + attachmentMimeType: string() + .hasColumnName('attachment_mime_type') + .optional(), // navigation properties consumed by `defineEntity()` author: UserDbSchema.optional(), activity: array(TodoActivityDbSchema).optional() @@ -133,7 +139,9 @@ const TodoSchema = object({ 'completed', 'userId', 'createdAt', - 'updatedAt' + 'updatedAt', + 'attachmentName', + 'attachmentMimeType' ) .projection('ownership', 'id', 'userId') .scope( @@ -205,4 +213,6 @@ export type TodoDb = { userId: number; createdAt: Date; updatedAt: Date; + attachmentName?: string; + attachmentMimeType?: string; }; diff --git a/demos/todo-backend/tsup.config.ts b/demos/todo-backend/tsup.config.ts index f553d459..7bec1ccf 100644 --- a/demos/todo-backend/tsup.config.ts +++ b/demos/todo-backend/tsup.config.ts @@ -12,5 +12,7 @@ export default defineConfig({ // @opentelemetry/* packages are CJS and use require('async_hooks') + other // Node built-ins internally. Bundling them into ESM via tsup's shimmed // require breaks at runtime. Keep them external so Node loads them natively. - external: ['ws', /^@opentelemetry\//], + // @fastify/busboy is CJS and uses require('node:stream') internally. + // Bundling it into ESM via tsup's shimmed require breaks at runtime. + external: ['ws', /^@opentelemetry\//, '@fastify/busboy'], }); diff --git a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx index f22ddabb..e095545c 100644 --- a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx +++ b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { Badge, @@ -20,7 +20,6 @@ import { ApiError, isTimeoutError, isNetworkError } from '@cleverbrush/client'; import { client } from '../../api/client'; type TodoEvent = Parameters[0]['body']; -import { loadToken } from '../../lib/http-client'; import { ConfirmDialog } from '../../components/ConfirmDialog'; type TodoWithAuthor = Awaited>; @@ -46,6 +45,11 @@ export function TodoDetailPage() { const [eventLoading, setEventLoading] = useState(false); const [eventResult, setEventResult] = useState(null); + // Attachment upload + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [uploadLoading, setUploadLoading] = useState(false); + // User list for the "assigned" picker const [users, setUsers] = useState>([]); @@ -117,18 +121,46 @@ export function TodoDetailPage() { } }; + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setSelectedFile(file); + }; + + const handleUpload = async () => { + if (!selectedFile || !id) return; + setUploadLoading(true); + setError(null); + try { + await client.todos.uploadAttachment({ + params: { id: Number(id) }, + body: {}, + files: { attachment: selectedFile } + }); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + await load(); + } catch (e) { + setError(e instanceof ApiError ? e.message : 'Upload failed.'); + } finally { + setUploadLoading(false); + } + }; + const handleDownload = async () => { try { - const token = loadToken(); - const resp = await fetch(`/api/todos/${id}/attachment`, { - headers: token ? { Authorization: `Bearer ${token}` } : {} + const blob = await client.todos.downloadAttachment.file({ + params: { id: Number(id) } }); - if (!resp.ok) throw new Error('Download failed'); - const blob = await resp.blob(); + + // Extract filename from Content-Disposition header + // (not directly available from Blob, use todo attachment name) + const filename = + data?.todo.attachmentName ?? `todo-${id}-download`; + const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `todo-${id}.txt`; + a.download = filename; a.click(); URL.revokeObjectURL(url); } catch { @@ -250,8 +282,65 @@ export function TodoDetailPage() { + {/* Attachment upload */} + + Attachment + {todo?.attachmentName ? ( + + + {todo.attachmentName} + + + {todo.attachmentMimeType} + {todo.attachmentSize + ? ` — ${(todo.attachmentSize / 1024).toFixed(1)} KB` + : ''} + + + ) : ( + + No attachment uploaded. + + )} + + + + + {selectedFile && ( + + {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB) + + )} + {selectedFile && ( + + )} + {todo?.attachmentName && ( + + )} + + + - diff --git a/libs/client/src/client.ts b/libs/client/src/client.ts index 86709b1e..f85b012d 100644 --- a/libs/client/src/client.ts +++ b/libs/client/src/client.ts @@ -13,7 +13,7 @@ * @module */ -import type { ApiContract } from '@cleverbrush/server/contract'; +import type { ApiContract, FilePart } from '@cleverbrush/server/contract'; import { ApiError, NetworkError, WebError } from './errors.js'; import { composeMiddleware, PER_CALL_OPTIONS } from './middleware.js'; import { buildPath } from './path.js'; @@ -179,7 +179,7 @@ export function createClient( url: string; method: string; headers: Record; - body: string | undefined; + body: string | FormData | undefined; } { const meta = getMeta(ep); const method = meta.method.toUpperCase(); @@ -202,10 +202,46 @@ export function createClient( } // -- Body -- - let body: string | undefined; + let body: string | FormData | undefined; if (args?.body !== undefined && hasBody(method)) { - reqHeaders['Content-Type'] = JSON_CONTENT_TYPE; - body = JSON.stringify(args.body); + if (meta.fileUpload) { + // Build FormData for multipart uploads + const fd = new FormData(); + if ( + args.body && + typeof args.body === 'object' && + !(args.body instanceof Blob) + ) { + for (const [key, val] of Object.entries(args.body)) { + fd.append(key, String(val)); + } + } + // Append file fields from args.files + if (args.files) { + for (const [key, value] of Object.entries( + args.files as Record + )) { + if (value instanceof Blob) { + fd.append(key, value); + } else { + const fp = value as FilePart; + fd.append( + key, + new Blob([fp.buffer], { + type: fp.mimeType + }), + fp.filename + ); + } + } + } + body = fd; + // Let the browser set Content-Type with boundary + delete reqHeaders['Content-Type']; + } else { + reqHeaders['Content-Type'] = JSON_CONTENT_TYPE; + body = JSON.stringify(args.body); + } } return { url, method, headers: reqHeaders, body }; @@ -442,10 +478,33 @@ export function createClient( ); } - // Regular HTTP endpoints return a callable with .stream() + // Regular HTTP endpoints return a callable with .stream() and .file() const call = (args?: any) => execute(ep, args, groupName, endpointName); call.stream = (args?: any) => streamLines(ep, args); + call.file = async (args?: any): Promise => { + const { + url, + method, + headers: reqHeaders, + body + } = buildRequest(ep, args); + const init: RequestInit = { + method, + headers: reqHeaders, + body + }; + await runBeforeRequest(hooks, url, init); + const response = await composedFetch(url, init); + if (!response.ok) { + if (response.status === 401) onUnauthorized?.(); + throw new ApiError( + response.status, + response.statusText || `HTTP ${response.status}` + ); + } + return response.blob(); + }; return call; } }); diff --git a/libs/client/src/index.ts b/libs/client/src/index.ts index 12037754..faf2c6fa 100644 --- a/libs/client/src/index.ts +++ b/libs/client/src/index.ts @@ -45,6 +45,7 @@ export type { EndpointCall, EndpointCallArgs, EndpointResponse, + FilePart, PerCallOverrides, Subscription, SubscriptionCall, diff --git a/libs/client/src/react/createClient.ts b/libs/client/src/react/createClient.ts index 87993b4a..e4926d09 100644 --- a/libs/client/src/react/createClient.ts +++ b/libs/client/src/react/createClient.ts @@ -102,6 +102,7 @@ export function createClient( // Attach streaming from the web client call.stream = (...args: any[]) => webEndpoint.stream(...args); + call.file = (...args: any[]) => webEndpoint.file(...args); // Attach TanStack Query hooks call.useQuery = createUseQuery(webClient, group, endpoint); diff --git a/libs/client/src/types.ts b/libs/client/src/types.ts index 135dd85b..22624f7f 100644 --- a/libs/client/src/types.ts +++ b/libs/client/src/types.ts @@ -10,9 +10,15 @@ import type { InferType, SchemaBuilder } from '@cleverbrush/schema'; import type { EndpointBuilder, + FilePart, ApiContract as ServerApiContract, SubscriptionBuilder } from '@cleverbrush/server/contract'; + +// Re-export types shared between server and client +/** @see {@link FilePart} from `@cleverbrush/server` */ +export type { FilePart }; + import type { WebError } from './errors.js'; import type { Middleware } from './middleware.js'; @@ -50,11 +56,17 @@ type InferSchema = * Assembles the parts of the request argument object conditionally. * Only keys that carry data are included. */ -type CallArgsParts = - (HasKeys extends true ? { params: TParams } : {}) & - (TBody extends undefined ? {} : { body: InferSchema }) & - (HasKeys extends true ? { query: TQuery } : {}) & - (HasKeys extends true ? { headers: THeaders } : {}); +type CallArgsParts< + TParams, + TBody, + TQuery, + THeaders, + TUpload extends boolean +> = (HasKeys extends true ? { params: TParams } : {}) & + (TBody extends undefined ? {} : { body: InferSchema }) & + (HasKeys extends true ? { query: TQuery } : {}) & + (HasKeys extends true ? { headers: THeaders } : {}) & + (TUpload extends true ? { files: Record } : {}); /** * Extracts the typed request argument shape from an `EndpointBuilder`. @@ -95,12 +107,13 @@ export type EndpointCallArgs = any, // TPrincipal any, // TRoles any, // TResponse - any // TResponses + any, // TResponses + infer TUpload // TUpload > ? HasKeys< - Simplify> + Simplify> > extends true - ? Simplify> + ? Simplify> : undefined : never; @@ -215,20 +228,20 @@ export interface PerCallOverrides { * `AsyncIterable` yielding newline-delimited chunks (e.g. NDJSON). * An optional `AbortSignal` can be passed to cancel an in-flight stream. */ -export type EndpointCall = - EndpointCallArgs extends undefined - ? ((args?: PerCallOverrides) => Promise>) & { - stream: (options?: { - signal?: AbortSignal; - }) => AsyncIterable; - } - : (( - args: EndpointCallArgs & PerCallOverrides - ) => Promise>) & { - stream: ( - args: EndpointCallArgs & { signal?: AbortSignal } - ) => AsyncIterable; - }; +export type EndpointCall = (EndpointCallArgs extends undefined + ? (args?: PerCallOverrides) => Promise> + : ( + args: EndpointCallArgs & PerCallOverrides + ) => Promise>) & { + stream: EndpointCallArgs extends undefined + ? (options?: { signal?: AbortSignal }) => AsyncIterable + : ( + args: EndpointCallArgs & { signal?: AbortSignal } + ) => AsyncIterable; + file: EndpointCallArgs extends undefined + ? (args?: PerCallOverrides) => Promise + : (args: EndpointCallArgs & PerCallOverrides) => Promise; +}; // --------------------------------------------------------------------------- // Subscription types diff --git a/libs/server-openapi/src/generateOpenApiSpec.ts b/libs/server-openapi/src/generateOpenApiSpec.ts index 231431da..f566eb3e 100644 --- a/libs/server-openapi/src/generateOpenApiSpec.ts +++ b/libs/server-openapi/src/generateOpenApiSpec.ts @@ -7,6 +7,7 @@ import type { AuthenticationConfig, EndpointMetadata, EndpointRegistration, + UploadOptions, WebhookDefinition } from '@cleverbrush/server'; import { resolvePath } from './pathUtils.js'; @@ -167,22 +168,34 @@ function buildRequestBody( examples?: Record< string, { summary?: string; description?: string; value: unknown } - > | null + > | null, + fileUpload?: UploadOptions | null ): Record { - const jsonSchema = convertSchema(bodySchema, registry); const bodyInfo = bodySchema.introspect() as any; - const mediaType: Record = { schema: jsonSchema }; - if (example != null) { - mediaType['example'] = example; - } else if (examples != null) { - mediaType['examples'] = examples; - } const body: Record = { - required: bodyInfo.isRequired !== false, - content: { - 'application/json': mediaType - } + required: bodyInfo.isRequired !== false }; + + // When file uploads are enabled, emit multipart/form-data + if (fileUpload) { + const jsonSchema = convertSchema(bodySchema, registry); + const mediaType: Record = { schema: jsonSchema }; + body['content'] = { + 'multipart/form-data': mediaType + }; + } else { + const jsonSchema = convertSchema(bodySchema, registry); + const mediaType: Record = { schema: jsonSchema }; + if (example != null) { + mediaType['example'] = example; + } else if (examples != null) { + mediaType['examples'] = examples; + } + body['content'] = { + 'application/json': mediaType + }; + } + if (typeof bodyInfo.description === 'string' && bodyInfo.description !== '') body['description'] = bodyInfo.description; return body; @@ -521,7 +534,8 @@ function buildOperation( meta.bodySchema, registry, meta.example, - meta.examples + meta.examples, + meta.fileUpload ); } diff --git a/libs/server/README.md b/libs/server/README.md index 941cc1ce..6351892f 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -185,6 +185,48 @@ await server.listen(3000); | `ActionResult.stream(readable, contentType)` | 200 | Pipes a `Readable` | | `ActionResult.status(status)` | any | Bare status, no body | +## File Upload + +Accept file uploads via `multipart/form-data` by chaining `.upload()` on an endpoint: + +```ts +import { endpoint } from '@cleverbrush/server'; +import { object, string } from '@cleverbrush/schema'; + +const UploadAvatar = endpoint + .post('/api/avatar') + .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] }) + .body(object({ description: string().optional() })) + .authorize(UserPrincipal); + +const handler: Handler = async ({ body, files }) => { + const avatar = files['avatar']; + // avatar: FilePart { filename, mimeType, buffer, size } + return ActionResult.created({ name: avatar.filename }); +}; +``` + +The `files` object on the handler context contains one `FilePart` entry per uploaded file field. Non-file form fields are validated against the body schema and available via `body`. + +### Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `maxFileSize` | `number` | 10 MB | Maximum file size per file in bytes | +| `allowedMimeTypes` | `string[]` | all | MIME type allowlist (supports `image/*` glob) | +| `maxFileCount` | `number` | 10 | Maximum number of files per request | + +### FilePart type + +```ts +interface FilePart { + readonly filename: string; + readonly mimeType: string; + readonly buffer: Buffer; + readonly size: number; +} +``` + ## Middleware ```ts diff --git a/libs/server/package.json b/libs/server/package.json index 1f008da1..35b43b5f 100644 --- a/libs/server/package.json +++ b/libs/server/package.json @@ -8,9 +8,11 @@ "@cleverbrush/auth": "^4.0.0", "@cleverbrush/di": "^4.0.0", "@cleverbrush/schema": "^4.0.0", + "@fastify/busboy": "^3.2.0", "ws": "^8.20.0" }, "devDependencies": { + "@types/busboy": "1.5.4", "@types/ws": "^8.18.1" }, "description": "Schema-first HTTP server framework — schema-driven controllers, DI, auto-validation, RFC 9457 errors", diff --git a/libs/server/src/ActionResult.ts b/libs/server/src/ActionResult.ts index a3c00c25..7c29f00e 100644 --- a/libs/server/src/ActionResult.ts +++ b/libs/server/src/ActionResult.ts @@ -248,9 +248,15 @@ export class FileResult extends ActionResult { res: http.ServerResponse, _contentNegotiator: ContentNegotiator ): Promise { + const filename = this.fileName; + const sanitized = filename.replace(/[^\x20-\x7E]/g, '_'); + const disposition = + sanitized === filename + ? `attachment; filename="${filename}"` + : `attachment; filename="${sanitized}"; filename*=UTF-8''${encodeURIComponent(filename)}`; res.writeHead(200, { 'content-type': this.contentType, - 'content-disposition': `attachment; filename="${this.fileName}"`, + 'content-disposition': disposition, 'content-length': String(this.content.byteLength) }); res.end(this.content); diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index 7bfcce4e..578adc3e 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -26,7 +26,12 @@ import { type SubscriptionBuilder, type SubscriptionHandlerEntry } from './Subscription.js'; -import type { Middleware } from './types.js'; +import type { + FilePart, + Middleware, + RejectedFile, + UploadOptions +} from './types.js'; // --------------------------------------------------------------------------- // Simplify — flattens intersection types for clean IDE tooltips @@ -40,7 +45,14 @@ type Simplify = { [K in keyof T]: T[K] } & {}; type HasKeys = keyof T extends never ? false : true; -type ActionContextParts = { +type ActionContextParts< + TParams, + TBody, + TQuery, + THeaders, + TPrincipal, + TUpload extends boolean +> = { context: RequestContext; } & (HasKeys extends true ? { params: TParams } : {}) & (TBody extends undefined @@ -52,7 +64,10 @@ type ActionContextParts = { }) & (HasKeys extends true ? { query: TQuery } : {}) & (HasKeys extends true ? { headers: THeaders } : {}) & - (TPrincipal extends undefined ? {} : { principal: TPrincipal }); + (TPrincipal extends undefined ? {} : { principal: TPrincipal }) & + (TUpload extends true + ? { files: Record; rejectedFiles?: RejectedFile[] } + : {}); /** * The fully-typed argument object passed to endpoint handlers. @@ -70,10 +85,18 @@ export type ActionContext = infer TPrincipal, any, any, - any + any, + infer TUpload > ? Simplify< - ActionContextParts + ActionContextParts< + TParams, + TBody, + TQuery, + THeaders, + TPrincipal, + TUpload + > > : never; @@ -101,6 +124,7 @@ export type ServiceSchemas = any, any, any, + any, any > ? TServices @@ -116,6 +140,7 @@ type ResponseType = any, any, infer TResponse, + any, any > ? TResponse extends SchemaBuilder @@ -137,7 +162,8 @@ export type ResponsesOf = any, any, any, - infer TResponses + infer TResponses, + any > ? TResponses : never; @@ -202,7 +228,18 @@ export type Handler = // Handler mapping — compile-time complete endpoint → handler binding // --------------------------------------------------------------------------- -type AnyEndpoint = EndpointBuilder; +type AnyEndpoint = EndpointBuilder< + any, + any, + any, + any, + any, + any, + any, + any, + any, + any +>; type AnySubscriptionBuilder = SubscriptionBuilder< any, any, @@ -538,6 +575,12 @@ export interface EndpointMetadata { * OpenAPI Operation Object. */ readonly callbacks: Record | null; + /** + * When set, the endpoint accepts `multipart/form-data` uploads. + * The configuration controls max file size, allowed MIME types, etc. + * @see `EndpointBuilder.upload()` + */ + readonly fileUpload: UploadOptions | null; /** * Cache tags declared via `.clearsCacheTag()`, providing tag-based cache * key computation for the client middleware. @@ -639,7 +682,8 @@ export class EndpointBuilder< TPrincipal = undefined, TRoles extends string = string, TResponse = any, - TResponses extends Record = {} + TResponses extends Record = {}, + TUpload extends boolean = false > { readonly #method: string; readonly #basePath: string; @@ -703,6 +747,7 @@ export class EndpointBuilder< readonly #externalDocs: { url: string; description?: string } | null; readonly #links: Record | null; readonly #callbacks: Record | null; + readonly #fileUpload: UploadOptions | null; readonly #cacheTags: readonly CacheTagDefinition[]; constructor( @@ -768,6 +813,7 @@ export class EndpointBuilder< externalDocs: { url: string; description?: string } | null = null, links: Record | null = null, callbacks: Record | null = null, + fileUpload: UploadOptions | null = null, cacheTags: readonly CacheTagDefinition[] = [] ) { this.#method = method; @@ -793,6 +839,7 @@ export class EndpointBuilder< this.#externalDocs = externalDocs; this.#links = links; this.#callbacks = callbacks; + this.#fileUpload = fileUpload; this.#cacheTags = cacheTags; } @@ -808,7 +855,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -834,6 +882,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -852,7 +901,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -878,6 +928,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -896,7 +947,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -922,6 +974,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -940,7 +993,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -966,6 +1020,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -991,7 +1046,8 @@ export class EndpointBuilder< InferType, TRoles, TResponse, - TResponses + TResponses, + TUpload >; authorize( ...roles: TRoles[] @@ -1004,7 +1060,8 @@ export class EndpointBuilder< unknown, TRoles, TResponse, - TResponses + TResponses, + TUpload >; authorize( ...args: unknown[] @@ -1017,7 +1074,8 @@ export class EndpointBuilder< any, TRoles, TResponse, - TResponses + TResponses, + TUpload > { let roles: string[]; if ( @@ -1059,6 +1117,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1079,7 +1138,8 @@ export class EndpointBuilder< TPrincipal, TRoles, T, - TResponses + TResponses, + TUpload >; returns>( schema: TSchema @@ -1092,11 +1152,12 @@ export class EndpointBuilder< TPrincipal, TRoles, TSchema, - TResponses + TResponses, + TUpload >; returns( _schema?: unknown - ): EndpointBuilder { + ): EndpointBuilder { const schema = _schema != null && typeof _schema === 'object' && @@ -1127,6 +1188,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1169,7 +1231,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - InferResponsesMap + InferResponsesMap, + TUpload > { return new EndpointBuilder( this.#method, @@ -1195,6 +1258,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1211,7 +1275,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1237,6 +1302,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1253,7 +1319,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1279,6 +1346,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1295,7 +1363,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1321,6 +1390,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1337,7 +1407,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1363,6 +1434,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1377,7 +1449,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1403,6 +1476,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1426,7 +1500,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1452,6 +1527,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1478,7 +1554,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1504,6 +1581,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1529,7 +1607,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1555,6 +1634,79 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, + this.#cacheTags + ); + } + + /** + * Mark this endpoint as accepting `multipart/form-data` file uploads. + * + * When set, the server parses the request body with a streaming multipart + * parser instead of the default JSON deserializer. File fields are made + * available to the handler via `arg.files` (a `Record`), + * while non-file form fields are validated against the body schema and + * available via `arg.body`. + * + * @param options - Upload configuration (max file size, allowed MIME types, etc.). + * + * @example + * ```ts + * const UploadAvatar = endpoint + * .post('/api/avatar') + * .upload({ maxFileSize: 2 * 1024 * 1024, allowedMimeTypes: ['image/*'] }) + * .authorize(PrincipalSchema) + * .responses({ 200: AvatarSchema }); + * + * const handler: Handler = async ({ files }) => { + * const avatar = files['avatar']; + * // avatar: { filename, mimeType, buffer, size } + * }; + * ``` + */ + upload( + options?: UploadOptions + ): EndpointBuilder< + TParams, + TBody, + TQuery, + THeaders, + TServices, + TPrincipal, + TRoles, + TResponse, + TResponses, + true + > { + return new EndpointBuilder( + this.#method, + this.#basePath, + this.#pathTemplate, + this.#bodySchema, + this.#querySchema, + this.#headerSchema, + this.#serviceSchemas, + this.#authRoles, + this.#summary, + this.#description, + this.#tags, + this.#operationId, + this.#deprecated, + this.#responseSchema, + this.#responsesSchemas, + this.#example, + this.#examples, + this.#producesFile, + this.#produces, + this.#responseHeaderSchema, + this.#externalDocs, + this.#links, + this.#callbacks, + { + maxFileSize: options?.maxFileSize ?? 10 * 1024 * 1024, + allowedMimeTypes: options?.allowedMimeTypes, + maxFileCount: options?.maxFileCount ?? 10 + }, this.#cacheTags ); } @@ -1611,6 +1763,7 @@ export class EndpointBuilder< externalDocs: this.#externalDocs, links: this.#links, callbacks: this.#callbacks, + fileUpload: this.#fileUpload, cacheTags: this.#cacheTags }; } @@ -1650,7 +1803,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1676,6 +1830,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1711,7 +1866,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1737,6 +1893,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1761,7 +1918,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1787,6 +1945,7 @@ export class EndpointBuilder< { url, description }, this.#links, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1822,7 +1981,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1848,6 +2008,7 @@ export class EndpointBuilder< this.#externalDocs, defs as Record, this.#callbacks, + this.#fileUpload, this.#cacheTags ); } @@ -1885,7 +2046,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1911,6 +2073,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, defs as Record, + this.#fileUpload, this.#cacheTags ); } @@ -2069,6 +2232,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, [...this.#cacheTags, { name, properties: {} }] ); } @@ -2117,6 +2281,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, [...this.#cacheTags, definition] ); } diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index 7423655f..e1b22d56 100644 --- a/libs/server/src/Server.ts +++ b/libs/server/src/Server.ts @@ -14,6 +14,7 @@ import { requireRole } from '@cleverbrush/auth'; import { ServiceCollection, type ServiceProvider } from '@cleverbrush/di'; +import { Busboy } from '@fastify/busboy'; import { type WebSocket, WebSocketServer } from 'ws'; import { ActionResult, JsonResult } from './ActionResult.js'; import { ContentNegotiator } from './ContentNegotiator.js'; @@ -34,10 +35,13 @@ import { checkJsonDepth, safeJsonParse } from './safeJson.js'; import type { ContentTypeHandler, EndpointRegistration, + FilePart, Middleware, + RejectedFile, ServerBatchingOptions, ServerOptions, - SubscriptionRegistration + SubscriptionRegistration, + UploadOptions } from './types.js'; import { VirtualIncomingMessage, @@ -81,6 +85,107 @@ export interface AuthorizationConfig { policies?: Record void>; } +// --------------------------------------------------------------------------- +// Multipart / file-upload helpers +// --------------------------------------------------------------------------- + +async function parseMultipart( + req: http.IncomingMessage, + options: UploadOptions +): Promise<{ + fields: Record; + files: Record; + rejectedFiles: RejectedFile[]; +}> { + const maxFileCount = options.maxFileCount ?? 10; + const maxFileSize = options.maxFileSize ?? 10 * 1024 * 1024; + const allowedMimeTypes = options.allowedMimeTypes; + + return new Promise((resolve, reject) => { + const fields: Record = {}; + const files: Record = {}; + const rejectedFiles: RejectedFile[] = []; + let fileCount = 0; + + const busboy = Busboy({ + headers: req.headers as { + 'content-type': string; + } & http.IncomingHttpHeaders, + limits: { + fileSize: maxFileSize, + files: maxFileCount + } + }); + + busboy.on('field', (fieldname: string, value: string) => { + fields[fieldname] = value; + }); + + busboy.on( + 'file', + ( + fieldname: string, + stream: import('@fastify/busboy').BusboyFileStream, + filename: string, + _transferEncoding: string, + mimeType: string + ) => { + if (fileCount >= maxFileCount) { + rejectedFiles.push({ + filename, + mimeType, + reason: `Exceeded max file count (${maxFileCount})` + }); + stream.resume(); + return; + } + + if (allowedMimeTypes) { + const allowed = allowedMimeTypes.some(pattern => { + if (pattern.endsWith('/*')) { + return mimeType.startsWith(pattern.slice(0, -1)); + } + return mimeType === pattern; + }); + if (!allowed) { + rejectedFiles.push({ + filename, + mimeType, + reason: `MIME type "${mimeType}" not allowed (allowed: ${allowedMimeTypes.join(', ')})` + }); + stream.resume(); + return; + } + } + + fileCount++; + const chunks: Buffer[] = []; + + stream.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + stream.on('end', () => { + const buffer = Buffer.concat(chunks); + files[fieldname] = { + filename, + mimeType, + buffer, + size: buffer.length + }; + }); + + stream.on('error', reject); + } + ); + + busboy.on('error', reject); + busboy.on('finish', () => resolve({ fields, files, rejectedFiles })); + + req.pipe(busboy); + }); +} + /** * Fluent builder for constructing and starting an HTTP server. * @@ -600,39 +705,71 @@ export class Server { // Parse body if needed let parsedBody: unknown; + let uploadedFiles: Record | undefined; + let rejectedFiles: RejectedFile[] | undefined; if (needsBody(meta)) { - const contentType = req.headers['content-type']; - const ctHandler = - this.#contentNegotiator.selectRequestHandler( - contentType - ); - if (ctHandler) { - const rawBody = await ctx.body(); - const bodyText = rawBody.toString('utf-8'); - if (bodyText.length > 0) { - try { - parsedBody = ctHandler.deserialize(bodyText); - } catch { - const pd = createProblemDetails( - 400, - 'Malformed request body' - ); - res.writeHead(400, { - 'content-type': PROBLEM_JSON_CONTENT_TYPE - }); - res.end(serializeProblemDetails(pd)); - ctx.responded = true; - return; + const contentType = req.headers['content-type'] ?? ''; + + // Multipart / file-upload path + if ( + meta.fileUpload && + contentType.startsWith('multipart/form-data') + ) { + try { + const result = await parseMultipart( + req, + meta.fileUpload + ); + parsedBody = result.fields; + uploadedFiles = result.files; + rejectedFiles = result.rejectedFiles; + } catch { + const pd = createProblemDetails( + 400, + 'Malformed multipart request' + ); + res.writeHead(400, { + 'content-type': PROBLEM_JSON_CONTENT_TYPE + }); + res.end(serializeProblemDetails(pd)); + ctx.responded = true; + return; + } + } else { + const ctHandler = + this.#contentNegotiator.selectRequestHandler( + contentType + ); + if (ctHandler) { + const rawBody = await ctx.body(); + const bodyText = rawBody.toString('utf-8'); + if (bodyText.length > 0) { + try { + parsedBody = + ctHandler.deserialize(bodyText); + } catch { + const pd = createProblemDetails( + 400, + 'Malformed request body' + ); + res.writeHead(400, { + 'content-type': + PROBLEM_JSON_CONTENT_TYPE + }); + res.end(serializeProblemDetails(pd)); + ctx.responded = true; + return; + } } + } else if (contentType) { + const pd = createProblemDetails(415); + res.writeHead(415, { + 'content-type': PROBLEM_JSON_CONTENT_TYPE + }); + res.end(serializeProblemDetails(pd)); + ctx.responded = true; + return; } - } else if (contentType) { - const pd = createProblemDetails(415); - res.writeHead(415, { - 'content-type': PROBLEM_JSON_CONTENT_TYPE - }); - res.end(serializeProblemDetails(pd)); - ctx.responded = true; - return; } } @@ -654,6 +791,18 @@ export class Server { return; } + // Inject uploaded files into the action context + if (uploadedFiles && resolveResult.args.length > 0) { + const ctx = resolveResult.args[0] as Record< + string, + unknown + >; + ctx.files = uploadedFiles; + if (rejectedFiles && rejectedFiles.length > 0) { + ctx.rejectedFiles = rejectedFiles; + } + } + // Call handler let result = registration.handler(...resolveResult.args); if (result instanceof Promise) { diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 49423002..ac35f300 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -56,6 +56,7 @@ export { type TrackedEvent, tracked } from './Subscription.js'; +export type { FilePart, UploadOptions } from './types.js'; import type { EndpointBuilder as _EB } from './Endpoint.js'; import type { SubscriptionBuilder as _SB } from './Subscription.js'; diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 3513bd5c..4124fcb8 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -90,9 +90,12 @@ export { export type { ContentTypeHandler, EndpointRegistration, + FilePart, Middleware, + RejectedFile, ServerBatchingOptions, ServerOptions, - SubscriptionRegistration + SubscriptionRegistration, + UploadOptions } from './types.js'; export { defineWebhook, type WebhookDefinition } from './Webhook.js'; diff --git a/libs/server/src/middlewares/ResponseCache.ts b/libs/server/src/middlewares/ResponseCache.ts index 1e07f915..5286b25c 100644 --- a/libs/server/src/middlewares/ResponseCache.ts +++ b/libs/server/src/middlewares/ResponseCache.ts @@ -9,7 +9,7 @@ * @module */ -import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { ServerResponse } from 'node:http'; import type { RequestContext } from '../RequestContext.js'; import type { Middleware } from '../types.js'; @@ -150,7 +150,7 @@ export function cacheResponse(options: ServerCacheOptions = {}): Middleware { for (const [cachedKey] of cache) { if ( keys.includes(cachedKey) || - keys.some(k => cachedKey.startsWith(tag.name)) + keys.some(_k => cachedKey.startsWith(tag.name)) ) { cache.delete(cachedKey); } diff --git a/libs/server/src/types.ts b/libs/server/src/types.ts index 98df5730..e97acf13 100644 --- a/libs/server/src/types.ts +++ b/libs/server/src/types.ts @@ -119,7 +119,59 @@ export interface ServerOptions { } // --------------------------------------------------------------------------- -// Batching Options +// File Upload +// --------------------------------------------------------------------------- + +/** + * Represents a single uploaded file from a `multipart/form-data` request. + */ +export interface FilePart { + /** Original filename as provided by the client. */ + readonly filename: string; + /** MIME type of the file (e.g. `'image/jpeg'`). */ + readonly mimeType: string; + /** Full file contents as a Buffer. */ + readonly buffer: Buffer; + /** File size in bytes. */ + readonly size: number; +} + +/** + * Describes a file that was rejected during multipart parsing. + */ +export interface RejectedFile { + /** Original filename as provided by the client. */ + readonly filename: string; + /** MIME type of the file (e.g. `'application/xlsx'`). */ + readonly mimeType: string; + /** Human-readable reason the file was rejected. */ + readonly reason: string; +} + +/** + * Configuration for file upload endpoints declared via + * `EndpointBuilder.upload()`. + */ +export interface UploadOptions { + /** + * Maximum allowed file size per uploaded file in bytes. + * @default 10_485_760 (10 MB) + */ + maxFileSize?: number; + /** + * Allowed MIME types or patterns (e.g. `'image/*'`, `'application/pdf'`). + * When not set, all MIME types are accepted. + */ + allowedMimeTypes?: string[]; + /** + * Maximum number of files allowed in a single request. + * @default 10 + */ + maxFileCount?: number; +} + +// --------------------------------------------------------------------------- +// Server Batching Options // --------------------------------------------------------------------------- /** diff --git a/libs/server/tests/ResponseCache.test.ts b/libs/server/tests/ResponseCache.test.ts index 096efe4b..ed9dd812 100644 --- a/libs/server/tests/ResponseCache.test.ts +++ b/libs/server/tests/ResponseCache.test.ts @@ -1,4 +1,3 @@ -import { object, string } from '@cleverbrush/schema'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { cacheResponse } from '../src/middlewares/ResponseCache.js'; diff --git a/libs/server/tsup.config.ts b/libs/server/tsup.config.ts index 70e80e3d..bbdea003 100644 --- a/libs/server/tsup.config.ts +++ b/libs/server/tsup.config.ts @@ -9,5 +9,7 @@ export default defineConfig({ sourcemap: true, clean: true, target: 'es2022', - external: ['ws'] + // @fastify/busboy is CJS and uses require('node:stream') internally. + // Bundling it into ESM via tsup's shimmed require breaks at runtime. + external: ['ws', '@fastify/busboy'] }); diff --git a/package-lock.json b/package-lock.json index 6f8b441c..5918a68a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,7 +149,7 @@ }, "libs/async": { "name": "@cleverbrush/async", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "devDependencies": { "@types/node": "^25.4.0" @@ -167,17 +167,17 @@ }, "libs/auth": { "name": "@cleverbrush/auth", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" } }, "libs/benchmarks": { "name": "@cleverbrush/benchmarks", - "version": "2.0.1", + "version": "2.0.2", "dependencies": { - "@cleverbrush/schema": "^3.0.0", + "@cleverbrush/schema": "^4.0.0", "joi": "^17.13.3", "yup": "^1.6.1", "zod": "^3.24.4" @@ -185,11 +185,11 @@ }, "libs/client": { "name": "@cleverbrush/client", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0", - "@cleverbrush/server": "^3.1.0" + "@cleverbrush/schema": "^4.0.0", + "@cleverbrush/server": "^4.0.0" }, "devDependencies": { "@tanstack/react-query": "^5.75.0", @@ -213,29 +213,29 @@ }, "libs/deep": { "name": "@cleverbrush/deep", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause" }, "libs/di": { "name": "@cleverbrush/di", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" } }, "libs/env": { "name": "@cleverbrush/env", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/deep": "^3.1.0" + "@cleverbrush/deep": "^4.0.0" }, "devDependencies": { "@types/node": "^25.4.0" }, "peerDependencies": { - "@cleverbrush/schema": "^3.0.1" + "@cleverbrush/schema": "^4.0.0" } }, "libs/env/node_modules/@types/node": { @@ -250,11 +250,11 @@ }, "libs/knex-clickhouse": { "name": "@cleverbrush/knex-clickhouse", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/async": "^3.1.0", - "@cleverbrush/deep": "^3.1.0", + "@cleverbrush/async": "^4.0.0", + "@cleverbrush/deep": "^4.0.0", "@clickhouse/client": "^1.18.2" }, "peerDependencies": { @@ -263,10 +263,10 @@ }, "libs/knex-schema": { "name": "@cleverbrush/knex-schema", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" }, "peerDependencies": { "knex": ">=3.1.0" @@ -274,19 +274,19 @@ }, "libs/log": { "name": "@cleverbrush/log", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/async": "^3.1.0", - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/async": "^4.0.0", + "@cleverbrush/schema": "^4.0.0" }, "devDependencies": { "@types/node": "^25.4.0" }, "peerDependencies": { - "@cleverbrush/di": "^3.1.0", - "@cleverbrush/knex-clickhouse": "^3.1.0", - "@cleverbrush/server": "^3.1.0" + "@cleverbrush/di": "^4.0.0", + "@cleverbrush/knex-clickhouse": "^4.0.0", + "@cleverbrush/server": "^4.0.0" }, "peerDependenciesMeta": { "@cleverbrush/di": { @@ -312,19 +312,19 @@ }, "libs/mapper": { "name": "@cleverbrush/mapper", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" } }, "libs/orm": { "name": "@cleverbrush/orm", - "version": "0.1.0", + "version": "1.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/knex-schema": "^3.1.0", - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/knex-schema": "^4.0.0", + "@cleverbrush/schema": "^4.0.0" }, "peerDependencies": { "knex": ">=3.1.0" @@ -332,7 +332,7 @@ }, "libs/orm-cli": { "name": "@cleverbrush/orm-cli", - "version": "0.1.0", + "version": "1.0.0", "license": "BSD 3-Clause", "dependencies": { "@cleverbrush/knex-schema": "*", @@ -347,7 +347,7 @@ }, "libs/otel": { "name": "@cleverbrush/otel", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { "@opentelemetry/api": "^1.9.0", @@ -375,9 +375,9 @@ "knex": "^3.1.0" }, "peerDependencies": { - "@cleverbrush/di": "^3.1.0", - "@cleverbrush/log": "^3.1.0", - "@cleverbrush/server": "^3.1.0", + "@cleverbrush/di": "^4.0.0", + "@cleverbrush/log": "^4.0.0", + "@cleverbrush/server": "^4.0.0", "@opentelemetry/instrumentation-http": "^0.215.0", "@opentelemetry/instrumentation-runtime-node": "^0.28.0", "@opentelemetry/instrumentation-undici": "^0.25.0", @@ -419,10 +419,10 @@ }, "libs/react-form": { "name": "@cleverbrush/react-form", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" }, "devDependencies": { "@types/react": "^19.0.0", @@ -434,10 +434,10 @@ }, "libs/scheduler": { "name": "@cleverbrush/scheduler", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/schema": "^3.1.0" + "@cleverbrush/schema": "^4.0.0" }, "devDependencies": { "@types/node": "^25.4.0" @@ -455,10 +455,10 @@ }, "libs/schema": { "name": "@cleverbrush/schema", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "devDependencies": { - "@cleverbrush/deep": "^3.1.0" + "@cleverbrush/deep": "^4.0.0" }, "peerDependencies": { "@standard-schema/spec": "^1.1.0" @@ -466,46 +466,48 @@ }, "libs/schema-json": { "name": "@cleverbrush/schema-json", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "peerDependencies": { - "@cleverbrush/schema": "^3.0.1", + "@cleverbrush/schema": "^4.0.0", "@standard-schema/spec": "^1.1.0" } }, "libs/server": { "name": "@cleverbrush/server", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "dependencies": { - "@cleverbrush/auth": "^3.1.0", - "@cleverbrush/di": "^3.1.0", - "@cleverbrush/schema": "^3.1.0", + "@cleverbrush/auth": "^4.0.0", + "@cleverbrush/di": "^4.0.0", + "@cleverbrush/schema": "^4.0.0", + "@fastify/busboy": "^3.2.0", "ws": "^8.20.0" }, "devDependencies": { + "@types/busboy": "1.5.4", "@types/ws": "^8.18.1" } }, "libs/server-integration-tests": { "name": "@cleverbrush/server-integration-tests", - "version": "2.0.1", + "version": "2.0.2", "dependencies": { - "@cleverbrush/auth": "^3.0.0", - "@cleverbrush/di": "^3.0.0", - "@cleverbrush/schema": "^3.0.0", - "@cleverbrush/server": "^3.0.0" + "@cleverbrush/auth": "^4.0.0", + "@cleverbrush/di": "^4.0.0", + "@cleverbrush/schema": "^4.0.0", + "@cleverbrush/server": "^4.0.0" } }, "libs/server-openapi": { "name": "@cleverbrush/server-openapi", - "version": "3.1.0", + "version": "4.0.0", "license": "BSD 3-Clause", "peerDependencies": { - "@cleverbrush/auth": "^3.0.1", - "@cleverbrush/schema": "^3.0.1", - "@cleverbrush/schema-json": "^3.0.1", - "@cleverbrush/server": "^3.0.1" + "@cleverbrush/auth": "^4.0.0", + "@cleverbrush/schema": "^4.0.0", + "@cleverbrush/schema-json": "^4.0.0", + "@cleverbrush/server": "^4.0.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -2011,6 +2013,12 @@ } } }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -5945,6 +5953,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/busboy": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz", + "integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", diff --git a/websites/docs/app/server/page.tsx b/websites/docs/app/server/page.tsx index 717c12fc..23c5b1b4 100644 --- a/websites/docs/app/server/page.tsx +++ b/websites/docs/app/server/page.tsx @@ -212,6 +212,107 @@ return ActionResult.status(202);`) + {/* ── File Upload ────────────────────────────────── */} +
+

File Upload

+

+ Accept file uploads via multipart/form-data{' '} + by chaining .upload() on an endpoint. File + fields are received as FilePart objects on + the handler context's files property; + non-file form fields are validated against the body + schema and available via body. +

+
+                         {
+    const avatar: FilePart = files['avatar'];
+    // avatar.filename, avatar.mimeType, avatar.buffer, avatar.size
+    return ActionResult.created({ name: avatar.filename });
+});`)
+                            }}
+                        />
+                    
+ +

Options

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefaultDescription
+ maxFileSize + + number + 10 MBMaximum file size per file in bytes
+ allowedMimeTypes + + string[] + all + MIME type allowlist (supports{' '} + image/* glob) +
+ maxFileCount + + number + 10Maximum number of files per request
+
+ +

FilePart type

+
+                        
+                    
+
+ {/* ── HTTP Errors ──────────────────────────────────── */}

HTTP Errors

diff --git a/websites/schema/app/playground/schemaDeclarations.ts b/websites/schema/app/playground/schemaDeclarations.ts index d37d12f7..59269112 100644 --- a/websites/schema/app/playground/schemaDeclarations.ts +++ b/websites/schema/app/playground/schemaDeclarations.ts @@ -1544,6 +1544,131 @@ export declare class GenericSchemaBuilder Schema export declare function generic SchemaBuilder>(templateFn: TFn): GenericSchemaBuilder; export declare function generic SchemaBuilder>(defaults: readonly any[], templateFn: TFn): GenericSchemaBuilder; export {}; +`, + "file:///node_modules/@cleverbrush/schema/builders/IntersectionSchemaBuilder.d.ts": `import { type BRAND, type InferType, SchemaBuilder, type ValidationContext, type ValidationResult } from './SchemaBuilder.js'; +type IntersectionSchemaBuilderCreateProps, TRight extends SchemaBuilder, R extends boolean = true> = Partial['introspect']>>; +type SchemaIntersection, TRight extends SchemaBuilder> = InferType & InferType; +export type IntersectionSchemaValidationResult = ValidationResult; +export declare class IntersectionSchemaBuilder, TRight extends SchemaBuilder, TRequired extends boolean = true, TNullable extends boolean = false, TExplicitType = undefined, THasDefault extends boolean = false, TExtensions = {}> extends SchemaBuilder : TExplicitType, TRequired, TNullable, THasDefault, TExtensions> { + #private; + /** + * @hidden + */ + static create(props: IntersectionSchemaBuilderCreateProps): IntersectionSchemaBuilder; + protected constructor(props: IntersectionSchemaBuilderCreateProps); + introspect(): { + left: TLeft; + right: TRight; + type: string; + isRequired: boolean; + isNullable: boolean; + isReadonly: boolean; + preprocessors: readonly import("./SchemaBuilder.js").PreprocessorEntry : TExplicitType>[]; + validators: readonly import("./SchemaBuilder.js").ValidatorEntry : TExplicitType>[]; + requiredValidationErrorMessageProvider: import("./SchemaBuilder.js").ValidationErrorMessageProvider>; + extensions: { + [x: string]: unknown; + }; + hasDefault: boolean; + defaultValue: (TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | (() => TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | undefined; + description: string | undefined; + schemaName: string | undefined; + hasCatch: boolean; + catchValue: (TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | (() => TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | undefined; + example: unknown; + }; + /** + * @override + */ + protected get isNullRequiredViolation(): boolean; + /** + * @inheritdoc + */ + hasType(_notUsed?: T): IntersectionSchemaBuilder & TExtensions; + /** + * @inheritdoc + */ + clearHasType(): IntersectionSchemaBuilder & TExtensions; + /** + * @inheritdoc + */ + validate(object: TExplicitType extends undefined ? SchemaIntersection : TExplicitType, context?: ValidationContext): IntersectionSchemaValidationResult : TExplicitType>; + /** + * @inheritdoc + */ + validateAsync(object: TExplicitType extends undefined ? SchemaIntersection : TExplicitType, context?: ValidationContext): Promise : TExplicitType>>; + /** + * Performs synchronous validation. + * Validates left schema first, then right schema. + * Both must pass for the intersection to be valid. + */ + protected _validate(object: TExplicitType extends undefined ? SchemaIntersection : TExplicitType, context?: ValidationContext): IntersectionSchemaValidationResult : TExplicitType>; + /** + * Performs async validation. + */ + protected _validateAsync(object: TExplicitType extends undefined ? SchemaIntersection : TExplicitType, context?: ValidationContext): Promise : TExplicitType>>; + protected createFromProps, TR extends SchemaBuilder, TReq extends boolean>(props: IntersectionSchemaBuilderCreateProps): this; + /** + * @hidden + */ + required(errorMessage?: any): IntersectionSchemaBuilder & TExtensions; + /** + * @hidden + */ + optional(): IntersectionSchemaBuilder & TExtensions; + /** + * @hidden + */ + default(value: (TExplicitType extends undefined ? SchemaIntersection : TExplicitType) | (() => TExplicitType extends undefined ? SchemaIntersection : TExplicitType)): IntersectionSchemaBuilder & TExtensions; + /** + * @hidden + */ + clearDefault(): IntersectionSchemaBuilder & TExtensions; + /** + * @hidden + */ + brand(_name?: TBrand): IntersectionSchemaBuilder : TExplicitType) & { + readonly [K in BRAND]: TBrand; + }, THasDefault, TExtensions> & TExtensions; + /** + * @hidden + */ + readonly(): IntersectionSchemaBuilder : TExplicitType>, THasDefault, TExtensions> & TExtensions; + /** + * @hidden + */ + nullable(): IntersectionSchemaBuilder & TExtensions; + /** + * @hidden + */ + notNullable(): IntersectionSchemaBuilder & TExtensions; + /** + * Gets the left side of this intersection. + */ + get leftSchema(): TLeft; + /** + * Gets the right side of this intersection. + */ + get rightSchema(): TRight; +} +/** + * Creates an intersection schema. + * The resulting schema validates that the input satisfies both \`left\` and \`right\` schemas. + * + * @example + * \`\`\`ts + * const schema = intersection( + * object({ name: string() }), + * object({ age: number() }) + * ); + * // InferType === { name: string } & { age: number } + * \`\`\` + * + * @param left - first schema + * @param right - second schema + */ +export declare const intersection: , TRight extends SchemaBuilder>(left: TLeft, right: TRight) => IntersectionSchemaBuilder; +export {}; `, "file:///node_modules/@cleverbrush/schema/builders/LazySchemaBuilder.d.ts": `import { type BRAND, SchemaBuilder, type ValidationContext, type ValidationErrorMessageProvider, type ValidationResult } from './SchemaBuilder.js'; type LazySchemaBuilderCreateProps = Partial['introspect']>>;