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.