AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/content/docs/uploads.md

Latest change d88533200d3e5293cc2264fb91be84f0c0c5bcaa - feat(cli): wire relations, file uploads, and expand into the auto-API by Ólafur Búi Ólafsson

# Uploads

File uploads in AkurAI-Framework rest on two pure-`std` building blocks: a
`multipart/form-data` parser in `crates/http`, and a content-addressed blob
store in `crates/blobs`. Neither links an external crate — the same
win-by-subtraction philosophy as the rest of the runtime. This page documents
both, and how the CLI upload endpoint and a collection `file` field type are
wired on top of them.

## Parsing `multipart/form-data`

A browser submits a `<form enctype="multipart/form-data">` as a sequence of
parts separated by a boundary string named in the `Content-Type` header. The
`multipart` module turns that body into typed parts. It sits beside the
`form` module (which handles `application/x-www-form-urlencoded`) and follows
the same shape: bytes in, values out, no I/O.

```rust
use akurai_http::{parse_multipart, MultipartError, Part};

pub struct Part {
    pub name: String,                  // the form field name
    pub filename: Option<String>,      // present for file fields
    pub content_type: Option<String>,  // the part's own Content-Type, if any
    pub data: Vec<u8>,                 // raw payload bytes, verbatim
}

pub fn parse_multipart(
    content_type_header: &str,
    body: &[u8],
) -> Result<Vec<Part>, MultipartError>;
```

The boundary is extracted from the full `Content-Type` header value —
`multipart/form-data; boundary=----WebKitFormBoundaryXYZ` — and accepts a
quoted or unquoted boundary, case-insensitively. Each part's
`Content-Disposition: form-data; name="..."; filename="..."` is parsed for the
field name and optional filename (quoted or unquoted), and an optional per-part
`Content-Type` is captured. The payload is kept as raw `Vec<u8>` and **never
decoded** — a part may be UTF-8 text or a binary file containing its own CRLFs
and even the boundary text as data; all of it round-trips intact.

The parser is strict about structure and never panics on junk. Errors are
returned, not thrown:

- `MissingBoundary` — the header was not `multipart/form-data`, or carried no
  usable `boundary=` parameter.
- `NoOpeningBoundary` — the body never contained the opening delimiter.
- `MalformedPart` — a part lacked a header/body separator or a closing
  delimiter, or its header block was not valid UTF-8.
- `MissingName` — a part had no `name` in its `Content-Disposition`.

Typical use inside a handler:

```rust
if let Some(ct) = request.header("Content-Type") {
    if let Ok(parts) = parse_multipart(ct, &request.body) {
        for part in parts {
            match part.filename {
                Some(name) => { /* file field: store part.data */ }
                None => { /* text field: String::from_utf8_lossy(&part.data) */ }
            }
        }
    }
}
```

## The blob store

Parsed file bytes are stored in a `BlobStore` (`crates/blobs`,
`akurai-blobs`), a content-addressed store backed by the framework's own
`BTree` from `crates/storage`. It depends only on `akurai-storage` — no
external crates.

```rust
use akurai_blobs::{BlobStore, content_id};

impl BlobStore {
    pub fn open(path: impl AsRef<Path>) -> io::Result<BlobStore>;
    pub fn put(&mut self, bytes: &[u8]) -> io::Result<String>; // returns the id
    pub fn get(&mut self, id: &str) -> io::Result<Option<Vec<u8>>>;
    pub fn exists(&mut self, id: &str) -> io::Result<bool>;
    pub fn delete(&mut self, id: &str) -> io::Result<bool>;
}
```

**Content addressing.** A blob's id *is* a hash of its bytes, rendered as a
32-character lowercase hex string. Two consequences fall out for free:

- **Deduplication.** Identical bytes hash to the same id, so `put`-ing the same
  file twice is a no-op and costs no extra space — the second call returns the
  same id and writes nothing.
- **Stable reference.** An id names an exact byte sequence, not a mutable slot.
  A caller holding an id is naming precisely the data it hashed.

Every mutating call commits the underlying B+tree, so blobs survive a reopen.
`get` of an unknown id returns `None`; `delete` reports whether the blob was
present.

### The hash caveat

The id is a **128-bit FNV-1a** digest. FNV-1a is fast, deterministic, and
std-only — but it is **not cryptographic**. It is not a defense against an
adversary deliberately crafting two different inputs that collide. The 128-bit
width makes *accidental* collisions astronomically unlikely for the upload
volumes this store targets, which is all a content key for trusted local
uploads needs. If a future requirement needs collision resistance against
malicious input, swap `content_id` for a cryptographic hash — nothing else in
the API changes.

## Wired into the auto-API

The CLI wires both primitives into the collections auto-API, so uploads work
with no handwritten endpoint code. Declare a `file` field on a collection:

```toml
[[collection]]
name = "documents"

  [[collection.field]]
  name = "title"
  type = "text"
  required = true

  [[collection.field]]
  name = "attachment"
  type = "file"
```

The blob store lives at `data/blobs.db`, alongside the record store
(`data/collections.db`) and embeddings (`data/embeddings.db`).

### Uploading — `multipart/form-data`

`POST` (or `PATCH`) a `multipart/form-data` request to the collection's records
endpoint. Each part whose `name` matches a `file` field is `put` into the
`BlobStore`; the field is then set to a descriptor object recording the blob id,
original filename, content type, and size. Parts matching non-file fields are
read as text and coerced to the field's declared type. A request sent with a
JSON `Content-Type` is unaffected — multipart is detected from the header.

```
curl -X POST http://localhost:8090/api/collections/documents/records \
  -F 'title=My Document' \
  -F 'attachment=@./data.bin;type=application/octet-stream'
```

The stored record carries the descriptor:

```json
{ "id": 1, "created": 1750000000, "title": "My Document",
  "attachment": { "blob": "9f8e...c0", "filename": "data.bin",
                  "content_type": "application/octet-stream", "size": 1234 } }
```

In a plain JSON create/update the file field may be omitted or `null`; a
non-object value is rejected with `400`.

### Downloading — `GET .../records/<id>/<field>`

Fetch the bytes back through the record:

```
GET /api/collections/documents/records/1/attachment
```

The handler reads the record, resolves the field's `blob` id through
`BlobStore::get`, and serves the bytes with the stored `content_type` (default
`application/octet-stream`) and a `Content-Disposition` naming the stored
filename. A missing record, field, or blob is a `404`.

```
curl -OJ http://localhost:8090/api/collections/documents/records/1/attachment
```

See [Auto-generated REST API](/docs/auto-api) for the full route table and the
[Collections](/docs/collections) page for how field types are declared. This
keeps the parsing and storage layers small, pure, and independently testable;
the CLI is the only place that knows how an HTTP request becomes a stored,
record-referenced blob.