chowbea-axios
Advanced

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:

  1. You pass a real FormData instance, or
  2. The payload is a plain object and the runtime converts it because it contains File / Blob values (via the generated convertToFormData helper)

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.

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

FileUpload.tsx
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 successful

No 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.

Next Steps

On this page