# Upload a file as multipart form data

URL: /docs/cookbook/form_upload
Markdown: /docs/cookbook/form_upload.md

> Build a `multipart/form-data` body with `Form`, send it, and parse it back within limits.

Generated by `cargo run -p agent_docs` from `compiler/examples/`. Edit the example files, then regenerate.

Build a `multipart/form-data` body with `Form`, send it, and parse it back within limits.

`Form` is the web's `FormData` as a value: an ordered list of fields, each `Text` or a
`File` holding a `FileData` (the web's `File`: a `Blob` of bytes and a media type, a name and
a time). `Form.encode_multipart` writes HTML's form-submission layout, and `Form.boundary()`
draws a fresh boundary from `Random`, so tests run under `Random.seeded` and get the same
bytes every time. The request's `content-type` names the boundary. The receiving side calls
`Form.parse_multipart` with a required `FormLimits`: the part count, the whole body and each
part are bounded, and a body over a bound, or one with the wrong content type, is a typed
`FormError` the handler turns into `400`. A file's name is data, never a path that is opened.

The request body is a `Payload`, here `Bytes`, so a file may hold any bytes. The tests send
a CSV upload through `Http.scripted`, replay a binary PNG upload and its binary answer from a
cassette (`Http.recording` stores a body that is not UTF-8 as Base64), route an upload request
through the receiving endpoint with `Router.handle`, and check the limit and content-type
refusals. No permissions are needed.

[Source project](https://github.com/icantcodefyi/sakuralang/tree/main/compiler/examples/form_upload) · harness mode: `test`.

From the repository root, with this checkout's `sakura` on `PATH`:

```sh
cd compiler/examples/form_upload
sakura check --json
sakura test --json
```

### Manifest and permissions [#manifest-and-permissions]

```toml
[package]
name = "form_upload"
version = "0.1.0"
edition = "2026"

[permissions]
```

### Source and tests [#source-and-tests]

`src/main.sak` includes all required imports and tests.

```sakura test id=cookbook-form_upload project=form_upload
import std/blob.{Blob, FileData}
import std/form.{Form, FormError, FormLimits, Part}
import std/fs.{Fs}
import std/http.{Http, Matcher}
import std/path.{Path}
import std/request.{BadRequest, Bytes, Post, Request, Text}
import std/response.{HttpError, Response, Status}
import std/router.{Router}
import std/url.{Url}

/// A file named `name` holding `bytes` of `media_type`.
fn file(name: String, bytes: Bytes, media_type: String) -> FileData {
  FileData { blob: Blob.of(bytes, media_type), name, last_modified: Instant.from_unix_millis(0) }
}

/// The upload as a `POST` of a `multipart/form-data` body with a fresh boundary. The body is
/// `Bytes`, so the file may be any bytes.
fn upload_request(target: Url, title: String, report: FileData) -> Request ! {Random} {
  let form = Form { entries: [("title", Part.Text(title)), ("report", Part.File(report))] }
  let boundary = Form.boundary()
  Request.of(Post, target)
    .with_header("content-type", "multipart/form-data; boundary={boundary}")
    .with_payload(Bytes(Form.encode_multipart(form, boundary)))
}

fn upload(target: Url, title: String, report: FileData) -> Status
  ! {Http, Random, Fail<HttpError>}
{ Http.send(upload_request(target, title, report)).status }

fn csv(text: String) -> FileData { file("report.csv", String.to_bytes(text), "text/csv") }

/// The receiving side: at most 4 parts, 64 KiB in all and 32 KiB per part.
fn receive(req: Request) -> Response {
  let limits = FormLimits { max_parts: 4, max_bytes: 65_536, max_field_bytes: 32_768 }
  let content_type = Request.header(req, "content-type").unwrap_or("")
  try {
    match Form.parse_multipart(Request.bytes(req, limits.max_bytes), content_type, limits) {
      Ok(form) => {
        let names = List.map(form.entries, fn(e) {
          match e.1 {
            Part.Text(v) => "{e.0}={v}",
            Part.File(f) => "{e.0}={f.name} ({f.blob.media_type}, {Blob.size(f.blob)} bytes)",
          }
        })
        Response.status(201, List.join(names, "; "))
      },
      Err(FormError { reason }) => Response.status(400, "bad form: {reason}"),
    }
  } catch {
    BadRequest { detail } => Response.status(400, detail),
  }
}

test "the upload is sent once, as multipart" {
  with Http.scripted([(Matcher.Path("/v1/reports"), Response.status(201, "stored"))]) {
    expect_eq(
      upload(url"https://files.example.test/v1/reports", "Q3", csv("a,b\n1,2\n")),
      Status.from(201),
    )
  }
}

test "a binary upload and its binary answer replay from the cassette" {
  let png = Bytes.from_list([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF])
  let thumb = Response { ..Response.status(201, ""), body: Bytes(Bytes.from_list([0xFF, 0xD8])) }
  with Fs.rooted(Test.fixtures()),
      Http.scripted([(Matcher.Path("/v1/images"), thumb)]),
      Http.recording(Path.of("upload.jsonl")) {
    let req = upload_request(
      url"https://files.example.test/v1/images",
      "logo",
      file("logo.png", png, "image/png"),
    )
    let resp = Http.send(req)
    expect_eq(resp.status, Status.from(201))
    expect_eq(Response.bytes(resp, 16), Bytes.from_list([0xFF, 0xD8]))
  }
}

test "the receiving side reads both parts back" {
  let req = upload_request(url"/v1/reports", "Q3 \u{1F338}", csv("a,b\n1,2\n"))
  let response = Router.handle(Router.new().post("/v1/reports", receive), req)
  expect_eq(response.status, Status.from(201))
  expect_eq(response.body, Text("title=Q3 \u{1F338}; report=report.csv (text/csv, 8 bytes)"))
}

test "a body over the limits or with the wrong content type is refused" {
  let big = upload_request(url"/v1/reports", "big", csv(String.repeat("x", 40_000)))
  let app = Router.new().post("/v1/reports", receive)
  expect_eq(Router.handle(app, big).body, Text("bad form: Limit(MaxFieldBytes)"))
  let plain = Request { ..big, headers: [("content-type", "text/plain")] }
  expect_eq(Router.handle(app, plain).body, Text("bad form: ContentType"))
}
```

### `tests/fixtures/README.md` [#testsfixturesreadmemd]

```text
# Recorded cassette

`upload.jsonl` was written by `sakura test --record` against `Http.scripted`, so no request
left the machine. Both bodies are binary, so they are stored as `body_base64` and
`response_body_base64`.
```

### `tests/fixtures/upload.jsonl` [#testsfixturesuploadjsonl]

```jsonl
{"schema_version":"1.0","kind":"cassette"}
{"method":"POST","url":"https://files.example.test/v1/images","headers":[["content-type","multipart/form-data; boundary=----SakuraFormBoundaryj-JGffEjUVnDfyNv"]],"body_base64":"LS0tLS0tU2FrdXJhRm9ybUJvdW5kYXJ5ai1KR2ZmRWpVVm5EZnlOdg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJ0aXRsZSINCg0KbG9nbw0KLS0tLS0tU2FrdXJhRm9ybUJvdW5kYXJ5ai1KR2ZmRWpVVm5EZnlOdg0KQ29udGVudC1EaXNwb3NpdGlvbjogZm9ybS1kYXRhOyBuYW1lPSJyZXBvcnQiOyBmaWxlbmFtZT0ibG9nby5wbmciDQpDb250ZW50LVR5cGU6IGltYWdlL3BuZw0KDQqJUE5HDQoaCgD/DQotLS0tLS1TYWt1cmFGb3JtQm91bmRhcnlqLUpHZmZFalVWbkRmeU52LS0NCg==","status":201,"response_headers":[],"response_body_base64":"/9g="}
```

### Compiler provenance and regeneration [#compiler-provenance-and-regeneration]

Finish only when no holes remain, independent tests pass, the requested target
builds, and contracts/tests/permissions have not been weakened. Repeat the frozen
baseline check after the final edits. Report missing prerequisites and failing
checks explicitly; do not claim a skipped test passed.

These ten projects teach supported Sakura APIs on the JavaScript backend. Each
project is exercised offline by `cargo test -p sakura_cli --test examples`;
HTTP and LLM exchanges are synthetic committed cassettes, with no network or API keys.
Use Node 26 for execution, including its built-in SQLite implementation.

The initial API baseline is compiler revision
[5cae349516c21714d90d36e87f1cfd6debe980c6](https://github.com/icantcodefyi/sakuralang/commit/5cae349516c21714d90d36e87f1cfd6debe980c6).
CI reruns these projects against the compiler in the same checkout. Build that
checkout's CLI and run `sakura --version` and `sakura prompt` before adapting a recipe.
Sakura Lang is pre-1.0: read STABILITY.md before depending on an API. An installed
skill's frontmatter records its compiler version and build revision. These teaching examples are separate from the agent
evaluation tasks.

The source of each page is its example's `recipe.md`, `sakura.toml`,
`src/main.sak` and fixture files. Run `cargo run -p agent_docs` from `compiler/`
to regenerate the cookbook and skill reference. Edit those sources, not the rendered pages.
