From 7527294af8d8b2c30a74cbe1a223eb0dfc1c0ae3 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 2 May 2026 12:45:09 +0000 Subject: [PATCH 1/7] feat: add multipart/form-data parsing and file upload --- .changeset/tiny-socks-march.md | 24 ++ demos/todo-backend/src/api/contract.ts | 4 + demos/todo-backend/src/api/endpoints.ts | 21 ++ demos/todo-backend/src/api/handlers/index.ts | 4 +- demos/todo-backend/src/api/handlers/todos.ts | 33 ++- demos/todo-backend/src/api/schemas.ts | 9 + .../server-openapi/src/generateOpenApiSpec.ts | 40 ++- libs/server/README.md | 42 +++ libs/server/package.json | 1 + libs/server/src/Endpoint.ts | 260 ++++++++++++++---- libs/server/src/Server.ts | 190 ++++++++++--- libs/server/src/index.ts | 4 +- libs/server/src/types.ts | 42 ++- opencode.json | 23 ++ package-lock.json | 125 +++++---- websites/docs/app/server/page.tsx | 101 +++++++ .../app/playground/schemaDeclarations.ts | 139 +++++++++- 17 files changed, 902 insertions(+), 160 deletions(-) create mode 100644 .changeset/tiny-socks-march.md create mode 100644 opencode.json 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/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index bd5cc6f8..2f31d8eb 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -164,6 +164,10 @@ export const api = defineApi({ route({ id: number().coerce() })`/${t => t.id}/attachment` ), + uploadAttachment: todosResource.post( + route({ id: number().coerce() })`/${t => t.id}/attachment` + ), + 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 550733f4..990187c4 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -1,4 +1,5 @@ import { defineWebhook } from '@cleverbrush/server'; +import { number, object, string } from '@cleverbrush/schema'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { POLYMORPHIC_TYPE_BRAND } from '@cleverbrush/orm'; import { DbToken, KnexToken, LoggerToken, TrackedDbToken } from '../di/tokens.js'; @@ -8,6 +9,7 @@ import { ImportTodosBodySchema, PrincipalSchema, TodoNotificationPayloadSchema, + UploadAttachmentResponseSchema, WebhookAckSchema } from './schemas.js'; @@ -189,6 +191,24 @@ export const DownloadAttachmentEndpoint = api.todos.downloadAttachment .tags('todos') .operationId('downloadTodoAttachment'); +// ── Upload attachment ───────────────────────────────────────────────────────── +// Features: .upload(), multipart/form-data, FilePart + +export const UploadAttachmentEndpoint = api.todos.uploadAttachment + .upload({ maxFileSize: 10 * 1024 * 1024, allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'] }) + .body(object({ description: string().optional() })) + .authorize(PrincipalSchema) + .inject({ db: DbToken }) + .responses({ 201: UploadAttachmentResponseSchema }) + .summary('Upload todo attachment') + .description( + 'Uploads a file attachment for a todo. ' + + 'Supports images, PDFs, and plain text files up to 10 MB. ' + + 'Demonstrates `.upload()` and multipart/form-data handling.' + ) + .tags('todos') + .operationId('uploadTodoAttachment'); + // ── Import todos ────────────────────────────────────────────────────────────── // Features: .example(), .examples(), .headers(), ActionResult.json(), ActionResult.accepted() @@ -391,6 +411,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..13bc2381 100644 --- a/demos/todo-backend/src/api/handlers/todos.ts +++ b/demos/todo-backend/src/api/handlers/todos.ts @@ -1,4 +1,4 @@ -import { ActionResult, type Handler } from '@cleverbrush/server'; +import { ActionResult, BadRequestError, ForbiddenError, type Handler, NotFoundError } from '@cleverbrush/server'; import { withSpan } from '@cleverbrush/otel'; import { TodoCompleted, @@ -23,6 +23,7 @@ import type { ListAllActivityEndpoint, ListTodoActivityEndpoint, ListTodosEndpoint, + UploadAttachmentEndpoint, SendTodoEventEndpoint, UpdateTodoEndpoint } from '../endpoints.js'; @@ -401,6 +402,36 @@ export const downloadAttachmentHandler: Handler< ); }; +// ── Upload todo attachment ──────────────────────────────────────────────────── + +export const uploadAttachmentHandler: Handler< + typeof UploadAttachmentEndpoint +> = async ({ params, principal, files }, { db }) => { + const todo = await db.todos.find(params.id); + + if (!todo) { + throw new NotFoundError(`Todo ${params.id} not found.`); + } + + if (principal.role !== 'admin' && todo.userId !== principal.userId) { + throw new ForbiddenError('You do not have access to this todo.'); + } + + const file = files['attachment']; + if (!file) { + throw new BadRequestError( + 'No file uploaded. Use field name "attachment".' + ); + } + + return ActionResult.created({ + id: params.id, + fileName: file.filename, + fileSize: file.size, + mimeType: file.mimeType + }); +}; + // ── Bulk import todos ───────────────────────────────────────────────────────── export const importTodosHandler: Handler = async ( diff --git a/demos/todo-backend/src/api/schemas.ts b/demos/todo-backend/src/api/schemas.ts index e71643db..f9fa2ed2 100644 --- a/demos/todo-backend/src/api/schemas.ts +++ b/demos/todo-backend/src/api/schemas.ts @@ -318,4 +318,13 @@ export const TodoActivityResponseSchema = union(TodoActivityAssignedResponseSche .or(TodoActivityCompletedResponseSchema) .schemaName('TodoActivityResponse'); +// ── Attachment upload response ───────────────────────────────────────────────── + +export const UploadAttachmentResponseSchema = object({ + id: number().describe('Unique identifier of the todo.'), + fileName: string().describe('Name of the uploaded file.'), + fileSize: number().describe('Size of the uploaded file in bytes.'), + mimeType: string().describe('MIME type of the uploaded file.') +}).schemaName('UploadAttachmentResponse'); + export type TodoActivityResponse = InferType; 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 360e3b67..61f24fa1 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -155,6 +155,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..17ba7ade 100644 --- a/libs/server/package.json +++ b/libs/server/package.json @@ -11,6 +11,7 @@ "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/Endpoint.ts b/libs/server/src/Endpoint.ts index 7710e460..800f24a2 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -22,7 +22,7 @@ import { type SubscriptionBuilder, type SubscriptionHandlerEntry } from './Subscription.js'; -import type { Middleware } from './types.js'; +import type { FilePart, Middleware, UploadOptions } from './types.js'; // --------------------------------------------------------------------------- // Simplify — flattens intersection types for clean IDE tooltips @@ -36,7 +36,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 @@ -48,7 +55,8 @@ 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 } : {}); /** * The fully-typed argument object passed to endpoint handlers. @@ -66,10 +74,18 @@ export type ActionContext = infer TPrincipal, any, any, - any + any, + infer TUpload > ? Simplify< - ActionContextParts + ActionContextParts< + TParams, + TBody, + TQuery, + THeaders, + TPrincipal, + TUpload + > > : never; @@ -97,6 +113,7 @@ export type ServiceSchemas = any, any, any, + any, any > ? TServices @@ -112,6 +129,7 @@ type ResponseType = any, any, infer TResponse, + any, any > ? TResponse extends SchemaBuilder @@ -133,7 +151,8 @@ export type ResponsesOf = any, any, any, - infer TResponses + infer TResponses, + any > ? TResponses : never; @@ -198,7 +217,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, @@ -534,6 +564,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; } /** @@ -575,7 +611,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; @@ -639,6 +676,7 @@ export class EndpointBuilder< readonly #externalDocs: { url: string; description?: string } | null; readonly #links: Record | null; readonly #callbacks: Record | null; + readonly #fileUpload: UploadOptions | null; constructor( method: string, @@ -702,7 +740,8 @@ export class EndpointBuilder< > | null = null, externalDocs: { url: string; description?: string } | null = null, links: Record | null = null, - callbacks: Record | null = null + callbacks: Record | null = null, + fileUpload: UploadOptions | null = null ) { this.#method = method; this.#basePath = basePath; @@ -727,6 +766,7 @@ export class EndpointBuilder< this.#externalDocs = externalDocs; this.#links = links; this.#callbacks = callbacks; + this.#fileUpload = fileUpload; } /** Define the request body schema. Validation failures return 422 Problem Details. */ @@ -741,7 +781,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -766,7 +807,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -784,7 +826,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -809,7 +852,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -827,7 +871,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -852,7 +897,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -870,7 +916,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -895,7 +942,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -920,7 +968,8 @@ export class EndpointBuilder< InferType, TRoles, TResponse, - TResponses + TResponses, + TUpload >; authorize( ...roles: TRoles[] @@ -933,7 +982,8 @@ export class EndpointBuilder< unknown, TRoles, TResponse, - TResponses + TResponses, + TUpload >; authorize( ...args: unknown[] @@ -946,7 +996,8 @@ export class EndpointBuilder< any, TRoles, TResponse, - TResponses + TResponses, + TUpload > { let roles: string[]; if ( @@ -987,7 +1038,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1007,7 +1059,8 @@ export class EndpointBuilder< TPrincipal, TRoles, T, - TResponses + TResponses, + TUpload >; returns>( schema: TSchema @@ -1020,11 +1073,12 @@ export class EndpointBuilder< TPrincipal, TRoles, TSchema, - TResponses + TResponses, + TUpload >; returns( _schema?: unknown - ): EndpointBuilder { + ): EndpointBuilder { const schema = _schema != null && typeof _schema === 'object' && @@ -1054,7 +1108,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1096,7 +1151,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - InferResponsesMap + InferResponsesMap, + TUpload > { return new EndpointBuilder( this.#method, @@ -1121,7 +1177,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1137,7 +1194,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1162,7 +1220,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1178,7 +1237,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1203,7 +1263,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1219,7 +1280,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1244,7 +1306,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1260,7 +1323,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1285,7 +1349,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1299,7 +1364,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1324,7 +1390,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1347,7 +1414,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1372,7 +1440,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1398,7 +1467,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1423,7 +1493,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1448,7 +1519,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1473,7 +1545,79 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload + ); + } + + /** + * 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 + } ); } @@ -1528,7 +1672,8 @@ export class EndpointBuilder< responseHeaderSchema: this.#responseHeaderSchema, externalDocs: this.#externalDocs, links: this.#links, - callbacks: this.#callbacks + callbacks: this.#callbacks, + fileUpload: this.#fileUpload }; } @@ -1567,7 +1712,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1592,7 +1738,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1627,7 +1774,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1652,7 +1800,8 @@ export class EndpointBuilder< schema, this.#externalDocs, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1676,7 +1825,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1701,7 +1851,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, { url, description }, this.#links, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1736,7 +1887,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, @@ -1761,7 +1913,8 @@ export class EndpointBuilder< this.#responseHeaderSchema, this.#externalDocs, defs as Record, - this.#callbacks + this.#callbacks, + this.#fileUpload ); } @@ -1798,7 +1951,8 @@ export class EndpointBuilder< TPrincipal, TRoles, TResponse, - TResponses + TResponses, + TUpload > { return new EndpointBuilder( this.#method, diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index 7423655f..82d304ec 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,12 @@ import { checkJsonDepth, safeJsonParse } from './safeJson.js'; import type { ContentTypeHandler, EndpointRegistration, + FilePart, Middleware, ServerBatchingOptions, ServerOptions, - SubscriptionRegistration + SubscriptionRegistration, + UploadOptions } from './types.js'; import { VirtualIncomingMessage, @@ -81,6 +84,95 @@ export interface AuthorizationConfig { policies?: Record void>; } +// --------------------------------------------------------------------------- +// Multipart / file-upload helpers +// --------------------------------------------------------------------------- + +async function parseMultipart( + req: http.IncomingMessage, + options: UploadOptions +): Promise<{ + fields: Record; + files: Record; +}> { + 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 = {}; + 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) { + 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) { + 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 })); + + req.pipe(busboy); + }); +} + /** * Fluent builder for constructing and starting an HTTP server. * @@ -600,39 +692,69 @@ export class Server { // Parse body if needed let parsedBody: unknown; + let uploadedFiles: Record | 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; + } 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 +776,12 @@ export class Server { return; } + // Inject uploaded files into the action context + if (uploadedFiles && resolveResult.args.length > 0) { + (resolveResult.args[0] as Record).files = + uploadedFiles; + } + // Call handler let result = registration.handler(...resolveResult.args); if (result instanceof Promise) { diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index d813777b..1c797e7b 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -75,9 +75,11 @@ export { export type { ContentTypeHandler, EndpointRegistration, + FilePart, Middleware, ServerBatchingOptions, ServerOptions, - SubscriptionRegistration + SubscriptionRegistration, + UploadOptions } from './types.js'; export { defineWebhook, type WebhookDefinition } from './Webhook.js'; diff --git a/libs/server/src/types.ts b/libs/server/src/types.ts index 98df5730..ff4e2346 100644 --- a/libs/server/src/types.ts +++ b/libs/server/src/types.ts @@ -119,7 +119,47 @@ 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; +} + +/** + * 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/opencode.json b/opencode.json new file mode 100644 index 00000000..cc37eda1 --- /dev/null +++ b/opencode.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "edit": "allow", + "bash": "ask", + "webfetch": "allow", + "doom_loop": "allow", + "external_directory": "ask", + "glob": "allow", + "grep": "allow", + "list": "allow", + "read": "allow", + "lsp": "allow", + "question": "allow", + "skill": "allow", + "task": "allow", + "write": "allow", + "todowrite": "allow", + "websearch": "allow" + }, + "instructions": ["MIGRATION_PLAN.md"], + "plugin": [ "openrtk" ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6f8b441c..f6b76b69 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,47 @@ }, "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", "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": { @@ -5945,6 +5946,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 072e9b75..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']>>; @@ -2960,6 +3085,15 @@ export declare class ParseStringSchemaBuilder; protected constructor(props: ParseStringSchemaBuilderCreateProps); + /** + * The human-readable message template pattern with \`{property}\` holes, + * e.g. \`"Todo created: #{TodoId} \\"{Title}\\" by user {UserId}"\`. + * + * Useful for structured logging — pass this as the log \`messageTemplate\` + * so events with the same shape can be grouped, and pass + * \`serialize(params)\` as the rendered message. + */ + get template(): string; /** * Return a snapshot of this builder's configuration. * @@ -5770,8 +5904,9 @@ type MergeExtensionMethods[], TT ...infer TRest extends readonly ExtensionDescriptor[] ] ? ExtractMethods & MergeExtensionMethods : {}; /** - * Unique symbol used by {@link FixedMethods} to detect extension methods whose - * first-argument literal should be accumulated in the return type. + * Unique string-literal brand key used by {@link FixedMethods} to detect + * extension methods whose first-argument literal should be accumulated in the + * return type. * * Declare the return type of any extension method as * \`this & { readonly [METHOD_LITERAL_BRAND]?: N }\` (where \`N extends string\`) From 1de45cfadb12d4d9c9ab0dc57361373a492875e2 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 2 May 2026 13:13:38 +0000 Subject: [PATCH 2/7] feat: updated client to support file uploads --- .../20260502000001_add_todo_attachment.ts | 17 +++ demos/todo-backend/src/api/endpoints.ts | 18 +-- demos/todo-backend/src/api/handlers/todos.ts | 84 +++++++++----- demos/todo-backend/src/api/mappers.ts | 10 +- demos/todo-backend/src/api/schemas.ts | 20 ++-- demos/todo-backend/src/db/schemas.ts | 12 +- .../src/features/todos/TodoDetailPage.tsx | 108 +++++++++++++++++- libs/client/src/client.ts | 41 ++++++- libs/client/src/index.ts | 1 + libs/client/src/types.ts | 29 +++-- libs/server/src/contract.ts | 1 + 11 files changed, 274 insertions(+), 67 deletions(-) create mode 100644 demos/todo-backend/migrations/20260502000001_add_todo_attachment.ts 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/endpoints.ts b/demos/todo-backend/src/api/endpoints.ts index 990187c4..5bfbd4ca 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -3,13 +3,13 @@ import { number, object, string } from '@cleverbrush/schema'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { POLYMORPHIC_TYPE_BRAND } from '@cleverbrush/orm'; import { DbToken, KnexToken, LoggerToken, TrackedDbToken } from '../di/tokens.js'; +import { TodoResponseSchema } from './schemas.js'; import { api } from './contract.js'; import { type ImportTodosBody, ImportTodosBodySchema, PrincipalSchema, TodoNotificationPayloadSchema, - UploadAttachmentResponseSchema, WebhookAckSchema } from './schemas.js'; @@ -181,30 +181,30 @@ 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 +// Features: .upload(), multipart/form-data, FilePart, file persistence in DB export const UploadAttachmentEndpoint = api.todos.uploadAttachment .upload({ maxFileSize: 10 * 1024 * 1024, allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'] }) .body(object({ description: string().optional() })) .authorize(PrincipalSchema) - .inject({ db: DbToken }) - .responses({ 201: UploadAttachmentResponseSchema }) + .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. ' + - 'Demonstrates `.upload()` and multipart/form-data handling.' + 'The file is stored in the database and can be downloaded via ' + + 'the download attachment endpoint.' ) .tags('todos') .operationId('uploadTodoAttachment'); diff --git a/demos/todo-backend/src/api/handlers/todos.ts b/demos/todo-backend/src/api/handlers/todos.ts index 13bc2381..872c8618 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, BadRequestError, ForbiddenError, type Handler, NotFoundError } from '@cleverbrush/server'; +import type { Knex } from 'knex'; import { withSpan } from '@cleverbrush/otel'; import { TodoCompleted, @@ -368,37 +369,42 @@ export const exportTodosHandler: Handler = async ( export const downloadAttachmentHandler: Handler< typeof DownloadAttachmentEndpoint -> = async ({ params, principal }, { db }) => { - const todo = await db.todos.find(params.id); +> = 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 (!todo) { - return ActionResult.notFound({ - message: `Todo ${params.id} not found.` - }); + if (!row) { + 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.' - }); + if ( + principal.role !== 'admin' && + (row as Record).user_id !== principal.userId + ) { + 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'); + 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( - Buffer.from(text, 'utf-8'), - `todo-${params.id}.txt`, - 'text/plain' + r.attachment_data, + r.attachment_name, + r.attachment_mime_type ); }; @@ -406,7 +412,7 @@ export const downloadAttachmentHandler: Handler< export const uploadAttachmentHandler: Handler< typeof UploadAttachmentEndpoint -> = async ({ params, principal, files }, { db }) => { +> = async ({ params, principal, files }, { db, knex }) => { const todo = await db.todos.find(params.id); if (!todo) { @@ -424,11 +430,31 @@ export const uploadAttachmentHandler: Handler< ); } + // 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: params.id, - fileName: file.filename, - fileSize: file.size, - mimeType: file.mimeType + 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 }); }; 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 f9fa2ed2..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; @@ -318,13 +327,4 @@ export const TodoActivityResponseSchema = union(TodoActivityAssignedResponseSche .or(TodoActivityCompletedResponseSchema) .schemaName('TodoActivityResponse'); -// ── Attachment upload response ───────────────────────────────────────────────── - -export const UploadAttachmentResponseSchema = object({ - id: number().describe('Unique identifier of the todo.'), - fileName: string().describe('Name of the uploaded file.'), - fileSize: number().describe('Size of the uploaded file in bytes.'), - mimeType: string().describe('MIME type of the uploaded file.') -}).schemaName('UploadAttachmentResponse'); - export type TodoActivityResponse = 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-frontend/src/features/todos/TodoDetailPage.tsx b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx index f22ddabb..1573cb01 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, @@ -46,6 +46,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,6 +122,38 @@ 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 { + const token = loadToken(); + const fd = new FormData(); + fd.append('attachment', selectedFile); + const resp = await fetch(`/api/todos/${id}/attachment`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd + }); + if (!resp.ok) { + const body = await resp.text(); + throw new Error(body || 'Upload failed'); + } + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Upload failed.'); + } finally { + setUploadLoading(false); + } + }; + const handleDownload = async () => { try { const token = loadToken(); @@ -124,11 +161,20 @@ export function TodoDetailPage() { headers: token ? { Authorization: `Bearer ${token}` } : {} }); if (!resp.ok) throw new Error('Download failed'); + + // Use Content-Disposition filename if available, else fallback + const disposition = resp.headers.get('content-disposition'); + let filename = `todo-${id}`; + if (disposition) { + const match = disposition.match(/filename="?(.+?)"?$/); + if (match) filename = match[1]; + } + const blob = await resp.blob(); 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 +296,64 @@ 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 091b6a8a..8d230792 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,41 @@ 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, filePart] of Object.entries( + args.files as Record + )) { + fd.append( + key, + new Blob([filePart.buffer], { + type: filePart.mimeType + }), + filePart.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 }; diff --git a/libs/client/src/index.ts b/libs/client/src/index.ts index 1bdda650..4108d5da 100644 --- a/libs/client/src/index.ts +++ b/libs/client/src/index.ts @@ -43,6 +43,7 @@ export type { EndpointCall, EndpointCallArgs, EndpointResponse, + FilePart, PerCallOverrides, Subscription, SubscriptionCall, diff --git a/libs/client/src/types.ts b/libs/client/src/types.ts index 9cf9b112..296ee980 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; diff --git a/libs/server/src/contract.ts b/libs/server/src/contract.ts index 181bf826..e8232caf 100644 --- a/libs/server/src/contract.ts +++ b/libs/server/src/contract.ts @@ -52,6 +52,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'; From 1e0e24d5a1111fa93cc8f5ca8e6e82b23bb17b24 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Sat, 2 May 2026 13:21:50 +0000 Subject: [PATCH 3/7] feat: updated todo-client/backend --- demos/todo-backend/src/api/contract.ts | 16 +++++-- demos/todo-backend/src/api/endpoints.ts | 3 -- .../src/features/todos/TodoDetailPage.tsx | 38 +++++---------- libs/client/src/client.ts | 48 +++++++++++++++---- libs/client/src/types.ts | 30 ++++++------ 5 files changed, 78 insertions(+), 57 deletions(-) diff --git a/demos/todo-backend/src/api/contract.ts b/demos/todo-backend/src/api/contract.ts index 2f31d8eb..aa82178f 100644 --- a/demos/todo-backend/src/api/contract.ts +++ b/demos/todo-backend/src/api/contract.ts @@ -164,9 +164,19 @@ export const api = defineApi({ route({ id: number().coerce() })`/${t => t.id}/attachment` ), - uploadAttachment: todosResource.post( - 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( diff --git a/demos/todo-backend/src/api/endpoints.ts b/demos/todo-backend/src/api/endpoints.ts index 5bfbd4ca..af971c6e 100644 --- a/demos/todo-backend/src/api/endpoints.ts +++ b/demos/todo-backend/src/api/endpoints.ts @@ -1,5 +1,4 @@ import { defineWebhook } from '@cleverbrush/server'; -import { number, object, string } from '@cleverbrush/schema'; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { POLYMORPHIC_TYPE_BRAND } from '@cleverbrush/orm'; import { DbToken, KnexToken, LoggerToken, TrackedDbToken } from '../di/tokens.js'; @@ -194,8 +193,6 @@ export const DownloadAttachmentEndpoint = api.todos.downloadAttachment // Features: .upload(), multipart/form-data, FilePart, file persistence in DB export const UploadAttachmentEndpoint = api.todos.uploadAttachment - .upload({ maxFileSize: 10 * 1024 * 1024, allowedMimeTypes: ['image/*', 'application/pdf', 'text/plain'] }) - .body(object({ description: string().optional() })) .authorize(PrincipalSchema) .inject({ db: DbToken, knex: KnexToken }) .responses({ 201: TodoResponseSchema }) diff --git a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx index 1573cb01..883db76b 100644 --- a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx +++ b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx @@ -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>; @@ -132,23 +131,16 @@ export function TodoDetailPage() { setUploadLoading(true); setError(null); try { - const token = loadToken(); - const fd = new FormData(); - fd.append('attachment', selectedFile); - const resp = await fetch(`/api/todos/${id}/attachment`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: fd + await client.todos.uploadAttachment({ + params: { id: Number(id) }, + body: {}, + files: { attachment: selectedFile } }); - if (!resp.ok) { - const body = await resp.text(); - throw new Error(body || 'Upload failed'); - } setSelectedFile(null); if (fileInputRef.current) fileInputRef.current.value = ''; await load(); } catch (e) { - setError(e instanceof Error ? e.message : 'Upload failed.'); + setError(e instanceof ApiError ? e.message : 'Upload failed.'); } finally { setUploadLoading(false); } @@ -156,21 +148,15 @@ export function TodoDetailPage() { 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'); - - // Use Content-Disposition filename if available, else fallback - const disposition = resp.headers.get('content-disposition'); - let filename = `todo-${id}`; - if (disposition) { - const match = disposition.match(/filename="?(.+?)"?$/); - if (match) filename = match[1]; - } - 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; diff --git a/libs/client/src/client.ts b/libs/client/src/client.ts index 8d230792..0dda21c9 100644 --- a/libs/client/src/client.ts +++ b/libs/client/src/client.ts @@ -218,16 +218,21 @@ export function createClient( } // Append file fields from args.files if (args.files) { - for (const [key, filePart] of Object.entries( - args.files as Record + for (const [key, value] of Object.entries( + args.files as Record )) { - fd.append( - key, - new Blob([filePart.buffer], { - type: filePart.mimeType - }), - filePart.filename - ); + 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; @@ -413,9 +418,32 @@ 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); 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/types.ts b/libs/client/src/types.ts index 296ee980..7ca7eb67 100644 --- a/libs/client/src/types.ts +++ b/libs/client/src/types.ts @@ -66,7 +66,7 @@ type CallArgsParts< (TBody extends undefined ? {} : { body: InferSchema }) & (HasKeys extends true ? { query: TQuery } : {}) & (HasKeys extends true ? { headers: THeaders } : {}) & - (TUpload extends true ? { files: Record } : {}); + (TUpload extends true ? { files: Record } : {}); /** * Extracts the typed request argument shape from an `EndpointBuilder`. @@ -218,20 +218,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 From 88dbd3dc3c790d3cf7b528e57c3baab8edac23fc Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 14:30:20 +0000 Subject: [PATCH 4/7] feat: add @fastify/busboy dependency for file upload support --- libs/server/package.json | 1 + package-lock.json | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/libs/server/package.json b/libs/server/package.json index 17ba7ade..35b43b5f 100644 --- a/libs/server/package.json +++ b/libs/server/package.json @@ -8,6 +8,7 @@ "@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": { diff --git a/package-lock.json b/package-lock.json index f6b76b69..5918a68a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -481,6 +481,7 @@ "@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": { @@ -2012,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", From b37bcf3bd7cabf94ea6d77b4eed00d17f9f601d5 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 15:36:45 +0000 Subject: [PATCH 5/7] fix: runtime error --- demos/todo-backend/tsup.config.ts | 4 +++- libs/server/tsup.config.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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/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'] }); From 6a9c44fd1a89bf8a3712934090ef427695b1b806 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 21:20:08 +0000 Subject: [PATCH 6/7] fix: file upload --- demos/todo-backend/src/api/handlers/todos.ts | 13 ++++++--- .../src/features/todos/TodoDetailPage.tsx | 1 + libs/client/src/react/createClient.ts | 1 + libs/server/src/ActionResult.ts | 8 +++++- libs/server/src/Endpoint.ts | 11 ++++++-- libs/server/src/Server.ts | 27 ++++++++++++++++--- libs/server/src/index.ts | 1 + libs/server/src/types.ts | 12 +++++++++ 8 files changed, 64 insertions(+), 10 deletions(-) diff --git a/demos/todo-backend/src/api/handlers/todos.ts b/demos/todo-backend/src/api/handlers/todos.ts index 872c8618..0c60aea1 100644 --- a/demos/todo-backend/src/api/handlers/todos.ts +++ b/demos/todo-backend/src/api/handlers/todos.ts @@ -412,7 +412,7 @@ export const downloadAttachmentHandler: Handler< export const uploadAttachmentHandler: Handler< typeof UploadAttachmentEndpoint -> = async ({ params, principal, files }, { db, knex }) => { +> = async ({ params, principal, files, rejectedFiles }, { db, knex }) => { const todo = await db.todos.find(params.id); if (!todo) { @@ -425,9 +425,14 @@ export const uploadAttachmentHandler: Handler< const file = files['attachment']; if (!file) { - throw new BadRequestError( - 'No file uploaded. Use field name "attachment".' - ); + 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); } // Persist file in DB — raw knex for bytea column diff --git a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx index 883db76b..e095545c 100644 --- a/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx +++ b/demos/todo-frontend/src/features/todos/TodoDetailPage.tsx @@ -305,6 +305,7 @@ export function TodoDetailPage() { ( // 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/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 800f24a2..a6c42fbb 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -22,7 +22,12 @@ import { type SubscriptionBuilder, type SubscriptionHandlerEntry } from './Subscription.js'; -import type { FilePart, Middleware, UploadOptions } from './types.js'; +import type { + FilePart, + Middleware, + RejectedFile, + UploadOptions +} from './types.js'; // --------------------------------------------------------------------------- // Simplify — flattens intersection types for clean IDE tooltips @@ -56,7 +61,9 @@ type ActionContextParts< (HasKeys extends true ? { query: TQuery } : {}) & (HasKeys extends true ? { headers: THeaders } : {}) & (TPrincipal extends undefined ? {} : { principal: TPrincipal }) & - (TUpload extends true ? { files: Record } : {}); + (TUpload extends true + ? { files: Record; rejectedFiles?: RejectedFile[] } + : {}); /** * The fully-typed argument object passed to endpoint handlers. diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index 82d304ec..e1b22d56 100644 --- a/libs/server/src/Server.ts +++ b/libs/server/src/Server.ts @@ -37,6 +37,7 @@ import type { EndpointRegistration, FilePart, Middleware, + RejectedFile, ServerBatchingOptions, ServerOptions, SubscriptionRegistration, @@ -94,6 +95,7 @@ async function parseMultipart( ): Promise<{ fields: Record; files: Record; + rejectedFiles: RejectedFile[]; }> { const maxFileCount = options.maxFileCount ?? 10; const maxFileSize = options.maxFileSize ?? 10 * 1024 * 1024; @@ -102,6 +104,7 @@ async function parseMultipart( return new Promise((resolve, reject) => { const fields: Record = {}; const files: Record = {}; + const rejectedFiles: RejectedFile[] = []; let fileCount = 0; const busboy = Busboy({ @@ -128,6 +131,11 @@ async function parseMultipart( mimeType: string ) => { if (fileCount >= maxFileCount) { + rejectedFiles.push({ + filename, + mimeType, + reason: `Exceeded max file count (${maxFileCount})` + }); stream.resume(); return; } @@ -140,6 +148,11 @@ async function parseMultipart( return mimeType === pattern; }); if (!allowed) { + rejectedFiles.push({ + filename, + mimeType, + reason: `MIME type "${mimeType}" not allowed (allowed: ${allowedMimeTypes.join(', ')})` + }); stream.resume(); return; } @@ -167,7 +180,7 @@ async function parseMultipart( ); busboy.on('error', reject); - busboy.on('finish', () => resolve({ fields, files })); + busboy.on('finish', () => resolve({ fields, files, rejectedFiles })); req.pipe(busboy); }); @@ -693,6 +706,7 @@ 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'] ?? ''; @@ -708,6 +722,7 @@ export class Server { ); parsedBody = result.fields; uploadedFiles = result.files; + rejectedFiles = result.rejectedFiles; } catch { const pd = createProblemDetails( 400, @@ -778,8 +793,14 @@ export class Server { // Inject uploaded files into the action context if (uploadedFiles && resolveResult.args.length > 0) { - (resolveResult.args[0] as Record).files = - uploadedFiles; + const ctx = resolveResult.args[0] as Record< + string, + unknown + >; + ctx.files = uploadedFiles; + if (rejectedFiles && rejectedFiles.length > 0) { + ctx.rejectedFiles = rejectedFiles; + } } // Call handler diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 1c797e7b..387da92c 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -77,6 +77,7 @@ export type { EndpointRegistration, FilePart, Middleware, + RejectedFile, ServerBatchingOptions, ServerOptions, SubscriptionRegistration, diff --git a/libs/server/src/types.ts b/libs/server/src/types.ts index ff4e2346..e97acf13 100644 --- a/libs/server/src/types.ts +++ b/libs/server/src/types.ts @@ -136,6 +136,18 @@ export interface FilePart { 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()`. From c593a8dc9c713f9d6a477741942100b1422989a9 Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Tue, 5 May 2026 21:29:35 +0000 Subject: [PATCH 7/7] fix: merge errors --- libs/server/src/Endpoint.ts | 8 ++++++-- libs/server/src/middlewares/ResponseCache.ts | 4 ++-- libs/server/tests/ResponseCache.test.ts | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index 2ee46bca..578adc3e 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -581,6 +581,7 @@ export interface EndpointMetadata { * @see `EndpointBuilder.upload()` */ readonly fileUpload: UploadOptions | null; + /** * Cache tags declared via `.clearsCacheTag()`, providing tag-based cache * key computation for the client middleware. */ @@ -812,7 +813,7 @@ export class EndpointBuilder< externalDocs: { url: string; description?: string } | null = null, links: Record | null = null, callbacks: Record | null = null, - fileUpload: UploadOptions | null = null + fileUpload: UploadOptions | null = null, cacheTags: readonly CacheTagDefinition[] = [] ) { this.#method = method; @@ -1705,7 +1706,7 @@ export class EndpointBuilder< maxFileSize: options?.maxFileSize ?? 10 * 1024 * 1024, allowedMimeTypes: options?.allowedMimeTypes, maxFileCount: options?.maxFileCount ?? 10 - } + }, this.#cacheTags ); } @@ -2072,6 +2073,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, defs as Record, + this.#fileUpload, this.#cacheTags ); } @@ -2230,6 +2232,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, [...this.#cacheTags, { name, properties: {} }] ); } @@ -2278,6 +2281,7 @@ export class EndpointBuilder< this.#externalDocs, this.#links, this.#callbacks, + this.#fileUpload, [...this.#cacheTags, definition] ); } 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/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';