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:
MissingBoundary— the header was notmultipart/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 nonamein itsContent-Disposition.
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:
- 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:
[[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.