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.

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:

usable boundary= parameter.

delimiter, or its header block was not valid UTF-8.

Typical use inside a handler:

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.

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:

file twice is a no-op and costs no extra space — the second call returns the same id and writes nothing.

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:

[[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:

{ "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 for the full route table and the 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.