Back to flin
flin

Request Context Injection

How FLIN injects params, query, body, headers, cookies, and session into every route handler automatically -- zero imports, zero boilerplate, zero ceremony.

Thales & Claude | March 25, 2026 7 min flin
flincontextrequestinjection

In Express.js, every handler receives (req, res, next). In Django, every view receives request. In FastAPI, you declare parameters with type annotations and the framework injects them. In Go, you pass http.ResponseWriter and *http.Request to every handler function. Every framework requires you to declare, receive, and destructure the request context.

FLIN takes a different approach. The request context is injected implicitly. Inside any route handler or middleware, the variables params, query, body, headers, cookies, session, and request are simply available. No function parameters. No imports. No type declarations. They exist because you are inside an HTTP context, and FLIN knows that.

The Built-in Context Variables

Every route handler and middleware in FLIN has access to these variables:

// Available in every route handler and middleware

params // URL parameters from dynamic segments query // Query string parameters body // Parsed request body (JSON, form, multipart) headers // Request headers (case-insensitive) cookies // Request cookies session // Session data (read/write) request // Full request object (method, path, ip, etc.) ```

These are not global variables. They are scoped to the current request. Two concurrent requests each see their own params, their own body, their own session. The isolation is guaranteed by the runtime.

How It Works: The Request Scope

When the FLIN runtime dispatches a request to a handler, it creates a new execution scope with the context variables pre-populated:

fn execute_handler(
    vm: &mut VirtualMachine,
    handler: &CompiledFunction,
    ctx: &RequestContext,
) -> Result<Value, RuntimeError> {
    // Create new scope for this request
    let scope = vm.push_scope();

// Inject context variables scope.set("params", ctx.params.to_flin_value()); scope.set("query", ctx.query.to_flin_value()); scope.set("body", ctx.body.to_flin_value()); scope.set("headers", ctx.headers.to_flin_value()); scope.set("cookies", ctx.cookies.to_flin_value()); scope.set("session", ctx.session.to_flin_value()); scope.set("request", ctx.to_flin_value());

// Execute handler bytecode let result = vm.execute(handler)?;

// Pop scope (context variables are freed) vm.pop_scope();

Ok(result) } ```

The scope is pushed before execution and popped after. Each request gets a clean set of context variables. There is no possibility of leaking data between requests because the scope lifetime is tied to the request lifetime.

Using params

URL parameters from dynamic route segments are available through params:

// app/api/users/[id].flin
// Request: GET /api/users/42

route GET { user_id = params.id // "42" (always a string) user = User.find(to_int(user_id)) user } ```

// app/products/[category]/[id].flin
// Request: GET /products/electronics/789

route GET { category = params.category // "electronics" product_id = params.id // "789"

product = Product.where(category == category).find(to_int(product_id)) product } ```

Parameters are always strings because they come from URL segments. Use to_int(), to_float(), or other conversion functions when you need typed values. The validate block handles this conversion automatically.

Using query

Query string parameters are available through query:

// Request: GET /api/products?page=2&per_page=20&category=shoes&sort=price

route GET { page = to_int(query.page || "1") per_page = to_int(query.per_page || "20") category = query.category // "shoes" or "" if missing sort_field = query.sort || "created_at"

products = Product if category != "" { products = products.where(category == category) } products.order(sort_field).limit(per_page).offset((page - 1) * per_page) } ```

Missing query parameters return an empty string, not null or undefined. This eliminates the null-checking boilerplate that every other framework requires:

// Express.js: defensive coding required
const page = parseInt(req.query.page) || 1;
const category = req.query.category || '';
if (category && typeof category === 'string') { ... }
// FLIN: query parameters are always strings, never null
page = to_int(query.page || "1")
category = query.category
if category != "" { ... }

Using body

The parsed request body is available through body, as covered in detail in the previous article. The key point for context injection is that body adapts to the content type automatically:

// JSON request: body is a map with typed values
route POST {
    name = body.name           // text
    age = body.age             // int
    tags = body.tags           // [text]
}

// Form request: body is a map with string values route POST { name = body.name // text (always) age = to_int(body.age) // convert from text }

// Multipart request: body has both fields and files route POST { title = body.title // text field file = body.avatar // file object } ```

Using headers

Request headers are available through headers with case-insensitive key access:

// Request headers:
// Authorization: Bearer eyJ...
// Accept-Language: fr-FR
// X-Custom-Header: some-value

route GET { auth = headers["Authorization"] // "Bearer eyJ..." lang = headers["Accept-Language"] // "fr-FR" custom = headers["x-custom-header"] // "some-value" (case-insensitive) } ```

Case-insensitive access is implemented by normalizing header names to lowercase during parsing. This prevents the common bug where code checks for Authorization but the client sends authorization.

Using cookies

Request cookies are available through cookies:

route GET {
    theme = cookies["theme"] || "light"
    locale = cookies["locale"] || "en"
    tracking_id = cookies["_tid"]
}

Setting response cookies is done through the response object or through helper functions:

route POST {
    set_cookie("theme", body.theme, {
        max_age: 365 * 24 * 60 * 60,   // 1 year
        path: "/",
        secure: true,
        httponly: false                   // Accessible to JS for theme
    })

{ success: true } } ```

Using session

The session is a persistent key-value store scoped to the user's browser. It is backed by an encrypted cookie and survives across requests:

// Reading session data
user_email = session.user           // "" if not set
user_name = session.userName        // "" if not set
user_id = session.userId            // "" if not set

// Writing session data session.user = found.email session.userName = found.name session.userId = to_text(found.id)

// Clearing session data session.user = none session.userName = none session.userId = none ```

Session values are always strings. This is a deliberate design constraint. Storing complex objects in sessions leads to serialization bugs, versioning problems, and security vulnerabilities. Strings are simple, serializable, and predictable.

Using request

The request object provides metadata about the raw HTTP request:

route GET {
    method = request.method      // "GET"
    path = request.path          // "/api/users/42"
    ip = request.ip              // "192.168.1.100"
    protocol = request.protocol  // "https"
    host = request.host          // "example.com"
    user_agent = request.user_agent  // "Mozilla/5.0 ..."
}

The request object is read-only in route handlers but writable in middleware. This allows middleware to attach computed values that downstream handlers can access:

// _middleware.flin
middleware {
    token = headers["Authorization"]
    if token != "" {
        claims = verify_token(token)
        request.user = claims
    }
    next()
}

// api/profile.flin route GET { // request.user was set by middleware if request.user == none { return error(401, "Not authenticated") } User.find(request.user.sub) } ```

Why Implicit Injection

The decision to inject context implicitly rather than requiring explicit parameter declarations was controversial. Explicit is usually better than implicit -- it is a core principle of good API design. So why did we choose implicit injection?

FLIN is a domain-specific language for web applications. Every FLIN route handler runs in an HTTP context. There is no scenario where a route handler does not have access to params or body. Making developers declare these explicitly would add ceremony without adding information.

Consistency with the rest of FLIN. FLIN entities are available by name without imports. Built-in functions like save, delete, and hash_password are available without imports. Making HTTP context variables require special declaration would be inconsistent.

Reduced boilerplate for beginners. FLIN is designed to be accessible to developers building their first web application. Forcing them to understand dependency injection, parameter binding, or type-annotated extractors before they can read a query parameter would raise the barrier to entry.

The trade-off is discoverability. A new developer reading a FLIN file might wonder where params comes from. The answer is documented, consistent, and always the same: these variables are available in every HTTP context. Once you learn them once, you know them forever.

In the next article, we explore FLIN's middleware system -- how _middleware.flin files create a composable pipeline of request processing that replaces the ad-hoc middleware patterns of Express, Koa, and their descendants.

---

This is Part 100 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: - [98] API Routes: Backend and Frontend in One File - [99] Auto JSON and Form Body Parsing - [100] Request Context Injection (you are here) - [101] The Middleware System

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles