26. File uploads
FileHandlingPlugin connects declared file fields to storage adapters. The
Express connector supports real Busboy 1.x and Formidable 3.x multipart parsers.
Fastify currently accepts JSON resource documents and rejects multipart with 415.
File attributes contain the storage adapter’s URL or handle as a string. Ordinary file columns currently use binary SQL storage; their UTF-8 bytes are decoded before returning the public value. Canonical file slots store text. Invalid UTF-8 fails with a normalization error instead of silently corrupting the handle. A custom getter owns decoding its database representation; the response still follows the file-handle contract. Blob attributes retain their separate binary representation. This correction requires no schema migration.
Quick Start
Install the chosen optional parser along with the connector and storage driver:
npm install json-rest-api express knex better-sqlite3 busboy
The following example uses an in-memory database and a local upload directory:
import { JsonRestApi } from 'json-rest-api'
import express from 'express'
import knex from 'knex'
import { RestApiPlugin, RestApiKnexPlugin, FileHandlingPlugin, ExpressPlugin } from 'json-rest-api'
import { LocalStorage } from 'json-rest-api/plugins/storage/local-storage.js'
const app = express()
const db = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true })
const storage = new LocalStorage({ directory: './uploads', fileBaseUrl: '/uploads' })
const api = new JsonRestApi({ name: 'uploads' })
await api.use(RestApiPlugin, { format: 'plain', returning: 'full' })
await api.use(RestApiKnexPlugin, { knex: db })
await api.use(FileHandlingPlugin)
await api.use(ExpressPlugin, {
mountPath: '/api',
fileParser: 'busboy',
fileParserOptions: { limits: { fileSize: 10 * 1024 * 1024, files: 2 } }
})
await api.addResource('images', {
schema: {
id: { type: 'id' },
title: { type: 'string', required: true },
image: { type: 'file', storage, accepts: ['image/png'], maxSize: '10mb', required: true }
}
})
await api.resources.images.createKnexTable()
app.use('/uploads', express.static('./uploads'))
api.http.express.mount(app)
const server = app.listen(3000)
Install FileHandlingPlugin before ExpressPlugin, then declare resources.
An unavailable parser or invalid detector factory fails setup; it no longer
silently disables uploads. Close the HTTP server and destroy db during your
application’s shutdown. Choose the application’s own file-serving policy; the
example serves public files through Express static middleware.
curl -H 'Accept: application/vnd.api+json' \
-F 'title=Example' -F 'image=@photo.png;type=image/png' \
http://localhost:3000/api/images
Multipart field names match resource attribute and file-field names. Express builds the resource envelope using the route’s type. The response is a full JSON:API document containing the stored file URL, regardless of programmatic plain/JSON:API defaults.
Schema Configuration
| File field option | Meaning |
|---|---|
type: 'file' |
Store the resulting URL in this attribute |
storage |
Adapter implementing upload(file); delete(url) enables rollback cleanup |
accepts |
Match the part’s reported MIME type; exact types, image/*, or ['*'] |
maxSize |
Inclusive maximum in bytes, or a string such as '10mb' or '1.5gb' |
required |
Ordinary resource schema requirement for creation/replacement; PATCH may omit it |
Schema size and MIME checks run after parsing and before uploading to storage.
Parser limits bound the data accepted during parsing. A numeric maxSize of
zero permits only an empty file. MIME metadata checks do not inspect file bytes.
Resource POST, PUT and PATCH support multipart. PUT takes its identity from the URL, just like a regular resource call. PATCH may update only text fields while preserving the stored URL of an omitted file. Manage relationships through the normal resource/relationship methods; multipart fields carry attributes/files.
Repeated text fields become arrays. tags[] entries are grouped under tags;
indexed bracket suffixes also append in arrival order. Text remains text until
the resource schema applies its normal validation/coercion. Unknown attributes
and unknown uploaded file fields are rejected. One uploaded file per file field
is supported; repeating a file field produces 400 instead of keeping the first
or last file. Distinct declared file fields can be uploaded together.
Protocol Configuration
fileParser accepts 'busboy', 'formidable', or a factory returning a detector
with name, detect(params) and parse(params). The factory receives
fileParserOptions. enableFileUploads: false disables the connector’s multipart
handling. Express’s requestSizeLimit controls JSON parsing; use parser options
for multipart limits.
Busboy
await api.use(ExpressPlugin, {
mountPath: '/api',
fileParser: 'busboy',
fileParserOptions: {
limits: { fileSize: 1024 * 1024, files: 2, fields: 10, fieldSize: 64 * 1024, parts: 12 }
}
})
The detector uses the current Busboy factory and file-info event API. Files are
buffered in memory. Limits are inclusive: a file of exactly fileSize bytes is
accepted; one extra byte is rejected. Truncated fields and parts/files/fields
limit events reject the whole request. See Busboy’s API
for parser options.
Formidable
npm install formidable
await api.use(ExpressPlugin, {
mountPath: '/api',
fileParser: 'formidable',
fileParserOptions: {
uploadDir: './upload-temp',
maxFileSize: 1024 * 1024,
maxTotalFileSize: 2 * 1024 * 1024,
maxFiles: 2,
maxFields: 10,
maxFieldsSize: 64 * 1024
}
})
Each request gets a private directory beneath uploadDir, which defaults to
the operating system’s temp directory. Completed files are read into buffers;
the request directory is removed before parsing returns, including on error or
cancellation. Other requests and unrelated files in the parent directory are
preserved. The result has no persistent filepath or cleanup callback. See
Formidable’s options.
Both detectors buffer their final results; neither streams directly into the storage adapter. Empty files are accepted unless schema validation excludes them. Default limits are:
| Limit | Busboy | Formidable |
|---|---|---|
| Per file | 10 MiB | 10 MiB |
| Files | 10 | 10 |
| Text fields | 100 | 100 |
| Text bytes | 64 KiB per field | 6.25 MiB total |
| Combined file bytes | Bounded by file count and per-file size | 100 MiB |
| Parts | 110 | Bounded by file/field counts |
Storage Adapters
upload(file) receives filename, mimetype, size and a data Buffer, and
returns a stored URL. Implement delete(url) to remove a newly uploaded file
when the upload’s managed transaction rolls back. That deletion should be
idempotent for URLs produced by the adapter.
LocalStorage writes actual files. Its URL option is fileBaseUrl; supported
naming strategies are hash, timestamp, original and custom. Use nameGenerator with the custom naming strategy.
Filename generation is not a reservation. Uploads reserve their destination
with exclusive creation and select another name if a concurrent upload has
claimed it. Existing files, directories and symlinks are occupied names.
Concurrent uploads therefore cannot overwrite one another, including with
original or custom names. Availability/permission errors propagate instead
of being treated as evidence that a filename is unused.
Buffer uploads write to the reserved file; temporary-file uploads stream into
it and remove their source only after writing and closing succeed. A failed
upload attempts to close and remove only its reserved destination. If that
cleanup also fails, an AggregateError retains the original failure in cause
and lists it followed by cleanup failures in errors; the destination may
remain for reconciliation. The adapter does not retry a write or rerun a custom
name generator after partial failure. Only an occupied-name reservation is retried.
For remote object storage, provide an adapter implementing the same upload
and delete methods. The library includes LocalStorage only.
Custom detectors can still return file.cleanup(). FileHandlingPlugin awaits
those callbacks after processing, including validation failures. Built-in
Formidable cleanup now happens inside the detector instead.
Errors and cleanup
| Result | HTTP status |
|---|---|
| Malformed multipart, missing boundary, repeated file field | 400 |
| Parser byte/count/part limit | 413 |
| Declared MIME/size requirement or resource schema violation | 422 |
| Unexpected detector, parser or storage failure | 500 |
| Typed access failure from storage | 403 |
A matched detector’s failure propagates; the library does not try another parser on an already consumed request. Typed parser errors retain their original cause where available. A later validation failure rolls back an owned database write and deletes tracked new uploads. Tests also disconnect real HTTP clients after partial disk writes and verify temporary-file removal and database rollback.
Unexpected detector/parser/upload failures retain their original thrown value
as cause, with phase and field/detector context. Typed API errors retain their
identity. Storage failures are no longer converted into schema validation
errors; MIME, size and ordinary resource validation still return 422.
Custom temporary-file cleanup and uploaded-file deletion are best effort.
Failures are retained in the operation’s context.cleanupErrors as
{ phase, field, error }, using temporaryFileCleanup or uploadedFileCleanup.
A failed warning log is recorded as logging with its during phase. Cleanup
continues with later files, and early cleanup diagnostics survive a subsequent
write failure. Failed upload deletions remain in context.fileHandlingUploads
for inspection and reconciliation; the library does not silently retry them.
Uploads are tracked against their creating transaction. Rollback cleanup selects
that transaction’s uploads, so reusing a context cannot delete an earlier
transaction’s committed files. Confirmed commit releases its tracking entries.
Standalone writes own their transaction. To group writes, pass the handle from
api.transaction to each operation and use a separate context for each one.
Individual operations finish while that unit is pending; the outer helper waits
for commit or rollback and the enlisted completion hooks. Rollback cleans the
operations in reverse order, including repeated uploads to the same record.
A failed completion chain does not prevent later operations’ chains from running.
File deletion diagnostics remain on the operation’s context; rejected completion
hooks also appear in the owner’s error or indexed cleanupErrors.
Raw Knex transactions cannot own library writes. See the migration guide for the callback form.
If a commit wrapper rejects after the driver has completed the transaction, the failure handler does not attempt rollback or run rollback-file cleanup. Driver completion alone does not distinguish every committed, rolled-back or uncertain outcome; it is not a guarantee that replaying the write is safe.
Ownership after commit
File fields store opaque URLs; several records, resources or external callers can refer to the same object. The library owns cleanup of parser temporary files and newly uploaded objects until their transaction completes. Confirmed rollback attempts to delete new uploads; confirmed commit releases upload tracking and leaves the stored objects under application ownership.
PATCH/PUT replacement and DELETE do not delete previously committed files,
even after the last reference in that resource disappears. For example, if two
documents use /uploads/shared.png, replacing one document’s attachment keeps
that file available to the other. Rolling back the replacement deletes only the
new upload and restores the old field value.
Applications that need garbage collection must track ownership and references
across their own consumers, then call the storage adapter’s delete(url) after
they establish that the object is no longer needed. A successful record deletion
alone does not establish that. This also applies to intermediate uploads from
repeated writes in a transaction that commits. There is no automatic reference
registry or background garbage collector.
Run npm run test:multipart for native parser and resource tests on both storage
modes. The full gate also exercises multipart resource operations under Express
4 and 5. See the migration guide before updating callers.