In Express.js, forgetting to add app.use(express.json()) means your POST handler receives undefined for req.body. In Flask, calling request.json on a request without the correct Content-Type header returns None. In Django, you must choose between request.POST, request.body, and json.loads(request.body) depending on the encoding, and getting it wrong produces a cryptic error.
FLIN parses request bodies automatically. Every request with a body -- POST, PUT, PATCH -- has its content parsed and available as body inside route handlers. JSON, URL-encoded forms, multipart uploads: FLIN detects the content type, parses the payload, and converts it to native FLIN values. Your code never touches a raw byte stream.
The Detection Algorithm
When a request arrives with a body, the FLIN runtime examines the Content-Type header and dispatches to the appropriate parser:
fn parse_body(content_type: &str, raw: &[u8]) -> Result<RequestBody, ParseError> {
let ct = content_type.to_lowercase();if ct.starts_with("application/json") { parse_json(raw) } else if ct.starts_with("application/x-www-form-urlencoded") { parse_form_urlencoded(raw) } else if ct.starts_with("multipart/form-data") { let boundary = extract_boundary(content_type)?; parse_multipart(raw, &boundary) } else if ct.starts_with("text/plain") { Ok(RequestBody::Text(String::from_utf8_lossy(raw).to_string())) } else { Ok(RequestBody::Raw(raw.to_vec())) } } ```
Five content types, five code paths, one body variable in FLIN. The developer never thinks about which parser to use.
If the Content-Type header is missing entirely, FLIN attempts JSON parsing first (since modern APIs overwhelmingly use JSON), falls back to form-urlencoded if JSON parsing fails, and stores the raw bytes as a last resort. This heuristic handles the surprisingly common case where a client forgets to set the content type header.
JSON Parsing
JSON is the default body format for FLIN API routes. The parser converts JSON types to FLIN types according to a deterministic mapping:
// Client sends: POST /api/orders
// Content-Type: application/json
// Body: {"product_id": 42, "quantity": 3, "notes": "Gift wrap please"}route POST { // body.product_id -> int (42) // body.quantity -> int (3) // body.notes -> text ("Gift wrap please")
product = Product.find(body.product_id) order = Order { product: product, quantity: body.quantity, notes: body.notes } save order order } ```
The type mapping is:
| JSON Type | FLIN Type |
|---|---|
string | text |
number (integer) | int |
number (decimal) | float |
boolean | bool |
null | none |
array | [value] (list) |
object | map (key-value pairs) |
Nested objects are accessible with dot notation: body.address.city, body.items[0].name. FLIN's dynamic value system means you do not need to declare the shape of the body in advance -- though you should use validate blocks if you want type-checked input.
JSON Parsing: Edge Cases
The FLIN JSON parser handles several edge cases that trip up other frameworks:
Large numbers. JSON numbers that exceed 64-bit integer range are automatically parsed as float or returned as text if exact precision is required. JavaScript's Number.MAX_SAFE_INTEGER is not a concern in FLIN because the VM uses proper 64-bit integers.
Unicode escapes. Strings with \uXXXX escape sequences are correctly decoded to UTF-8. This matters for international content, which is the norm on the African continent where FLIN is designed to be used.
Deeply nested objects. The parser enforces a maximum nesting depth of 64 levels to prevent stack overflow attacks from maliciously crafted JSON payloads. This is a security measure, not a limitation -- no legitimate API payload nests 64 levels deep.
const MAX_JSON_DEPTH: usize = 64;
const MAX_JSON_SIZE: usize = 10 * 1024 * 1024; // 10 MBfn parse_json(raw: &[u8]) -> Result
let value = serde_json::from_slice::
if json_depth(&value) > MAX_JSON_DEPTH { return Err(ParseError::NestingTooDeep(MAX_JSON_DEPTH)); }
Ok(RequestBody::Json(value_to_flin(value))) } ```
Form-Encoded Body Parsing
HTML forms submit data as URL-encoded key-value pairs by default. FLIN parses these into the same body object used for JSON:
// Client sends: POST /contact
// Content-Type: application/x-www-form-urlencoded
// Body: name=Thales&email=thales%40zerosuite.io&message=Hello+FLINroute POST { // body.name -> "Thales" // body.email -> "[email protected]" // body.message -> "Hello FLIN"
save ContactMessage { name: body.name, email: body.email, message: body.message } { success: true } } ```
The parser handles percent-encoding (%40 becomes @), plus-as-space (Hello+FLIN becomes Hello FLIN), and duplicate keys (the last value wins, matching standard browser behavior).
Form values are always strings. Unlike JSON, there is no type information in URL-encoded data. If your route expects a number, use to_int(body.quantity) to convert it. The validate block handles this automatically:
route POST {
validate {
quantity: int @required @min(1) // Parsed from string "5" to int 5
price: float @required // Parsed from string "29.99" to float 29.99
active: bool // Parsed from "true"/"false"/"1"/"0"
}// body.quantity is now int, not text // body.price is now float, not text } ```
Multipart Body Parsing
File uploads use multipart/form-data encoding. FLIN parses the multipart boundary, extracts each part, and makes both text fields and file uploads available through body:
// app/api/upload.flinroute POST { validate { title: text @required description: text file: file @required @max_size("10MB") @allow_types("image/png", "image/jpeg", "application/pdf") }
file_path = save_file(body.file, ".flindb/uploads/")
save Document { title: body.title, description: body.description || "", file_path: file_path, file_size: body.file.size, file_type: body.file.content_type }
response { status: 201 body: { path: file_path, size: body.file.size } } } ```
File parts are not loaded entirely into memory. FLIN streams large files to a temporary directory and provides a handle through body.file. The save_file() function moves the temporary file to the specified directory atomically.
Each file part exposes these properties:
body.file.name // Original filename: "photo.jpg"
body.file.content_type // MIME type: "image/jpeg"
body.file.size // Size in bytes: 245760
body.file.path // Temporary file path (internal)Size Limits and Safety
Every body parser enforces size limits to prevent denial-of-service attacks:
pub struct BodyLimits {
json_max: usize, // Default: 10 MB
form_max: usize, // Default: 1 MB
multipart_max: usize, // Default: 50 MB
file_max: usize, // Default: 25 MB per file
}These defaults are sensible for most applications. If a request exceeds the limit, FLIN returns 413 Payload Too Large before reading the entire body, preventing memory exhaustion.
Custom limits can be set in flin.config:
server {
limits {
body_size = "50mb"
file_size = "100mb"
}
}Error Responses for Malformed Bodies
When body parsing fails, FLIN returns a structured error response that tells the client exactly what went wrong:
{
"error": "Invalid JSON body",
"detail": "Expected ',' or '}' at line 3, column 15",
"status": 400
}For validation failures:
{
"error": "Validation failed",
"fields": {
"email": "Must be a valid email address",
"quantity": "Must be at least 1"
},
"status": 400
}These error responses are generated automatically. The developer does not write error formatting code. The error messages are clear enough for frontend developers to display them directly or map them to localized messages.
Why Automatic Parsing Matters
Body parsing is plumbing. It is the kind of code that every web application needs, that nobody enjoys writing, and that introduces subtle bugs when done incorrectly. Consider the Express.js ecosystem, where there are at least four popular body-parsing packages (body-parser, express.json(), multer, formidable), each with different APIs, different defaults, and different security characteristics.
FLIN's approach is to make this decision once, at the language level, and enforce it consistently for every application. You cannot forget to parse the body. You cannot use the wrong parser for the content type. You cannot accidentally accept a 2 GB JSON payload because you forgot to set a size limit.
This is the pattern that runs through all of FLIN's HTTP handling: identify the boilerplate that every application needs, implement it correctly in the runtime, and make it invisible to the developer. The goal is not to give developers more options. It is to give them fewer decisions to make, so they can focus on the logic that is unique to their application.
In the next article, we explore FLIN's request context injection system -- how every route handler gets access to params, query, body, headers, cookies, and session without importing anything or declaring any types.
---
This is Part 99 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [97] File-Based Routing in FLIN - [98] API Routes: Backend and Frontend in One File - [99] Auto JSON and Form Body Parsing (you are here) - [100] Request Context Injection