File Uploads
Multipart uploads without folklore about path names.
Uploads are where OpenAPI clients usually get creative and wrong. chowbea-axios keeps it boring on purpose: the wire format follows the body you pass and the types the spec already declared.
How multipart actually happens
The client sends multipart/form-data when:
- You pass a real
FormDatainstance, or - The payload is a plain object and the runtime converts it because it contains
File/Blobvalues (via the generatedconvertToFormDatahelper)
There is no “path looks like /upload so it must be multipart” heuristic. That era is over. If your endpoint is multipart, your OpenAPI body should say so (multipart/form-data + format: binary), and your call site should pass files like files.
Types come from the spec
Binary fields are typed from OpenAPI format: binary — typically File | Blob on the generated contract (UploadFileBody, etc.). Name-based guesses (*image*, *document*) are not how this works anymore.
import type { UploadFileBody } from "@/services/api/_generated/api.contracts";
import { api } from "@/services/api/api.client";
// Prefer building FormData yourself — clearest intent, fewest surprises
const formData = new FormData();
formData.append("file", file);
formData.append("description", "Quarterly report");
const { data, error } = await api.op.uploadFile(
formData as unknown as UploadFileBody,
);Yes, the cast is ugly. OpenAPI types describe the logical body; browsers speak FormData. The cast is the handshake. Columbus does the same thing in production and sleeps fine.
Operation-based upload (recommended)
import type { UploadAvatarBody } from "@/services/api/_generated/api.contracts";
async function uploadAvatar(file: File) {
const formData = new FormData();
formData.append("file", file);
const { data, error } = await api.op.uploadAvatar(
formData as unknown as UploadAvatarBody,
);
if (error) throw new Error(error.message); // or QueryError — see Error Handling
return data;
}Path-based upload
const formData = new FormData();
formData.append("file", file);
const { data, error } = await api.post("/files/upload", formData);Same rule: pass FormData (or a File-bearing object the client can convert). Don’t rely on the URL looking upload-y.
React sketch
import { useState } from "react";
import type { UploadFileBody } from "@/services/api/_generated/api.contracts";
import { api } from "@/services/api/api.client";
function FileUpload() {
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const onUpload = async () => {
if (!file) return;
setBusy(true);
setMessage(null);
const formData = new FormData();
formData.append("file", file);
const { data, error } = await api.op.uploadFile(
formData as unknown as UploadFileBody,
);
setBusy(false);
if (error) {
setMessage(error.message);
return;
}
setMessage(`Uploaded: ${data.id}`);
setFile(null);
};
return (
<div>
<input
type="file"
disabled={busy}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
<button type="button" onClick={onUpload} disabled={!file || busy}>
{busy ? "Uploading…" : "Upload"}
</button>
{message && <p>{message}</p>}
</div>
);
}Upload progress
Need a progress bar? Pass Axios’s onUploadProgress through the request config. Some apps drop down to the raw axiosInstance for this — that’s fine. Generated types don’t forbid pragmatism.
const formData = new FormData();
formData.append("file", largeFile);
const { data, error } = await api.post("/files/upload", formData, {
onUploadProgress: (event) => {
const percent = Math.round((event.loaded * 100) / (event.total ?? 1));
setProgress(percent);
},
});Spec shape that actually works
paths:
/files/upload:
post:
operationId: uploadFile
requestBody:
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description:
type: string
responses:
"200":
description: Upload successfulNo operationId? That operation will not show up on api.op. The CLI is not psychic.
When to bypass the Result client
Jobs that need streaming, exotic progress UX, or binary download gymnastics sometimes call Axios directly while still importing types from api.contracts.ts. Keep the contracts. Borrow the transport when you must. Just don’t invent a second source of truth for shapes.