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
20 changes: 20 additions & 0 deletions .changeset/typed-multipart-json-documents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@cleverbrush/server": minor
"@cleverbrush/client": minor
"@cleverbrush/server-openapi": minor
"@cleverbrush/knex-schema": minor
"@cleverbrush/orm": minor
---

Add schema-based single and multiple file upload contracts, typed multipart client
serialization, and matching OpenAPI schemas. Enforce multipart body, file, field,
and part limits, reject truncated or duplicate singleton uploads, and support
file-only endpoints. Existing options-only uploads retain their single-file
shape and explicit MIME rejection reporting.

Add lossless JSONB object reads and writes using native object schemas with
`.acceptUnknownProps().jsonb()`. Preserve nested extension data through returning
rows and projections, validate JSON extensions in the database layer, align
nullable object column DDL with reads, and track nested edits independently in
the ORM. Fix the PostgreSQL
upsert returning path exercised by document round trips.
275 changes: 275 additions & 0 deletions docs/framework-feature-candidates.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions libs/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,34 @@ standard. Business messages and network exceptions are never guessed into fields
See the [multi-file action/form example](../react-form/README.md#server-validation-issues)
for serialization boundaries and the form issue lifecycle.

## Typed file uploads

An endpoint's upload schema determines its `files` argument. Single fields accept
`File`, `Blob`, or `FilePart`; array fields accept arrays of those values.

```ts
import { createClient } from '@cleverbrush/client';
import { defineApi, endpoint, file } from '@cleverbrush/server/contract';
import { array, object } from '@cleverbrush/schema';

const api = defineApi({ assets: {
upload: endpoint.post('/assets').upload(object({
images: array(file()).minLength(1),
cover: file().optional()
}))
} });
const client = createClient(api);
await client.assets.upload({ files: {
images: [new File(['first'], 'first.txt'), new File(['second'], 'second.txt')]
} });
```

File-only calls need no `body` argument. The client serializes arrays as repeated
multipart fields in order, omits undefined optional fields, and lets `FormData`
set the content-type boundary. Use `File` or `FilePart` to supply a filename;
a plain `Blob` uses the platform's default filename. Text fields remain in the
endpoint's separate `body` argument.

## Overview

`@cleverbrush/client` provides a Proxy-based HTTP client that infers all endpoint types (params, body, query, headers, responses) from an API contract defined with `defineApi()` from `@cleverbrush/server/contract`. No code generation or manual type annotations are needed.
Expand Down
40 changes: 24 additions & 16 deletions libs/client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,41 +203,49 @@ export function createClient<T extends ApiContract>(

// -- Body --
let body: string | FormData | undefined;
if (args?.body !== undefined && hasBody(method)) {
if (hasBody(method) && (meta.fileUpload || args?.body !== undefined)) {
if (meta.fileUpload) {
// Build FormData for multipart uploads
const fd = new FormData();
if (
args.body &&
args?.body &&
typeof args.body === 'object' &&
!(args.body instanceof Blob)
) {
for (const [key, val] of Object.entries(args.body)) {
fd.append(key, String(val));
if (val !== undefined) fd.append(key, String(val));
}
}
// Append file fields from args.files
if (args.files) {
if (args?.files) {
for (const [key, value] of Object.entries(
args.files as Record<string, FilePart | Blob>
)) {
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
);
for (const part of Array.isArray(value)
? value
: [value]) {
if (part === undefined) continue;
if (part instanceof Blob) {
fd.append(key, part);
} else {
const fp = part as FilePart;
fd.append(
key,
new Blob([fp.buffer], {
type: fp.mimeType
}),
fp.filename
);
}
}
}
}
body = fd;
// Let the browser set Content-Type with boundary
delete reqHeaders['Content-Type'];
for (const name of Object.keys(reqHeaders)) {
if (name.toLowerCase() === 'content-type')
delete reqHeaders[name];
}
} else {
reqHeaders['Content-Type'] = JSON_CONTENT_TYPE;
body = JSON.stringify(args.body);
Expand Down
Loading
Loading