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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/tiny-socks-march.md
Original file line number Diff line number Diff line change
@@ -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 }
});
```

Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
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<void> {
await knex.schema.table('todos', (table) => {
table.dropColumn('attachment_data');
table.dropColumn('attachment_name');
table.dropColumn('attachment_mime_type');
});
}
14 changes: 14 additions & 0 deletions demos/todo-backend/src/api/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
26 changes: 22 additions & 4 deletions demos/todo-backend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -396,6 +413,7 @@ export const endpoints = {
sendEvent: SendTodoEventEndpoint,
exportCsv: ExportTodosEndpoint,
downloadAttachment: DownloadAttachmentEndpoint,
uploadAttachment: UploadAttachmentEndpoint,
importBulk: ImportTodosEndpoint,
legacyReplace: LegacyReplaceTodoEndpoint,
complete: CompleteTodoEndpoint,
Expand Down
4 changes: 3 additions & 1 deletion demos/todo-backend/src/api/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
listTodoActivityHandler,
listTodosHandler,
sendTodoEventHandler,
updateTodoHandler
updateTodoHandler,
uploadAttachmentHandler
} from './todos.js';
import {
deleteUserHandler,
Expand All @@ -51,6 +52,7 @@ export const handlers: HandlerMap<typeof endpoints> = {
sendEvent: sendTodoEventHandler,
exportCsv: exportTodosHandler,
downloadAttachment: downloadAttachmentHandler,
uploadAttachment: uploadAttachmentHandler,
importBulk: importTodosHandler,
legacyReplace: legacyReplaceTodoHandler,
complete: completeTodoHandler,
Expand Down
110 changes: 86 additions & 24 deletions demos/todo-backend/src/api/handlers/todos.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,6 +24,7 @@ import type {
ListAllActivityEndpoint,
ListTodoActivityEndpoint,
ListTodosEndpoint,
UploadAttachmentEndpoint,
SendTodoEventEndpoint,
UpdateTodoEndpoint
} from '../endpoints.js';
Expand Down Expand Up @@ -367,38 +369,98 @@ export const exportTodosHandler: Handler<typeof ExportTodosEndpoint> = 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<string, unknown>).user_id !== principal.userId
) {
throw new ForbiddenError('You do not have access to this todo.');
}

if (!(row as Record<string, unknown>).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 ─────────────────────────────────────────────────────────
Expand Down
10 changes: 8 additions & 2 deletions demos/todo-backend/src/api/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,21 @@ const TodoRowSchema = object({
completed: boolean(),
userId: number(),
createdAt: date(),
updatedAt: date()
updatedAt: date(),
attachmentName: string().optional(),
attachmentMimeType: string().optional()
});

export const mappingRegistry = mapper()
.configure(UserRowSchema, UserResponseSchema, m =>
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);
Expand Down
11 changes: 10 additions & 1 deletion demos/todo-backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof TodoResponseSchema>;
Expand Down
12 changes: 11 additions & 1 deletion demos/todo-backend/src/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -133,7 +139,9 @@ const TodoSchema = object({
'completed',
'userId',
'createdAt',
'updatedAt'
'updatedAt',
'attachmentName',
'attachmentMimeType'
)
.projection('ownership', 'id', 'userId')
.scope(
Expand Down Expand Up @@ -205,4 +213,6 @@ export type TodoDb = {
userId: number;
createdAt: Date;
updatedAt: Date;
attachmentName?: string;
attachmentMimeType?: string;
};
4 changes: 3 additions & 1 deletion demos/todo-backend/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
Loading
Loading