ProjectsmahiRequests

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Requests

Request is the object every route handler, middleware pipe, and controller receives. It wraps Hono's context after a single eager body parse, then exposes a fully synchronous, Laravel-shaped accessor surface.

export class GetPostController extends Controller {
  async handle(request: Request) {
    const post = await request.model(Post);
    return HttpResponse.json(await new PostResource(post).toJson());
  }
}

App code never takes a Hono Context. The kernel constructs one Request per call and threads it through every pipe and into the handler. request.raw() is the escape hatch back to Hono if you need it.

Construction

ConstructorUse
Request.from(c)From a Hono Context. Parses the body once, caches the instance on the context. Async.
Request.create(path, method?, input?, extras?)Unit tests and non-HTTP callers. Sync.
Request.fromExisting(source)Upgrade a base Request into a subclass, reusing already-parsed bags.
new Request(input?)Bare instance with only a body bag.

from() is what the router calls. If the context already carries a Request (because a global pipe built one earlier), it re-reads the route params and returns that instance rather than re-parsing the body.

create() is what you'll use in tests:

const request = Request.create("/posts", "POST", { body: "hello" }, {
  query: { page: "2" },
  params: { post: "42" },
  files: { image: someFile },
  headers: { "content-type": "application/json" },
  ip: "1.2.3.4",
});

RequestCreateExtras accepts query, headers, files, params, and ip. When query is omitted it's parsed out of the path string.

ip is the simulated socket peer, which is what request.ip() returns. Setting an x-forwarded-for header does not change ip(). See IP precedence.

fromExisting() is how form requests work. See Form requests. If the source is already an instance of the target class it's returned as-is; otherwise a new instance copies every bag by reference (including the shared bag and the model cache, so authorization and the handler share one fetched model) and takes over the context's cached-request slot.

The input bag

Three sources merge into one bag, later winning:

route params  →  query string  →  body
// GET /posts/42?v=query  with body { v: "body" }
request.input("v");      // "body"

// GET /posts/42?v=query  with no body
request.input("v");      // "query"

rebuildInput() recomputes the merge whenever the body bag changes (merge, mergeIfMissing, replace) or route params are re-synced.

Why params re-sync: a global use("*") pipe constructs the Request before Hono has matched a route, so c.req.param() is empty at that point. syncRouteParams(c) re-reads them when the route handler (or a route-level pipe) runs. You never call this yourself, but it explains why request.route("post") is empty inside a global pipe and populated inside the handler.

Body parsing

Parsing is driven entirely by Content-Type:

Content-TypeBehaviour
application/json, *+jsonParsed with c.req.json(). Invalid JSON, null, or a top-level array all become {}.
multipart/form-dataParsed with parseBody({ all: true }). File values (and arrays of File) go to the file bag; everything else through the bracket parser into the input bag.
application/x-www-form-urlencodedSame as multipart.
Anything elseBody ignored; input is route params + query only.

Bodies are capped before they are read, 1 MiB, or 10 MiB for multipart. Over the limit is a 413; see Built-in protections.

Malformed JSON becoming {} rather than throwing is deliberate: validation decides whether an empty body is an error. A rate limiter keying on request.input("email") runs before validation and must tolerate garbage. See the loginKey() helper in the generated AppServiceProvider.

Bracket notation

Query strings and urlencoded bodies expand PHP/qs-style brackets, so arrays and nested objects survive the trip:

// ?ids[]=1&ids[]=2
request.input("ids");            // ["1", "2"]

// ?user[name]=bob&user[role]=admin
request.input("user");           // { name: "bob", role: "admin" }

// ?items[0][id]=1&items[1][id]=2
request.input("items");          // [{ id: "1" }, { id: "2" }]

Without this, ids would be the single key "ids[]" holding "1", the second value silently dropped, and an array() validation rule on a query field impossible to satisfy.

Values stay strings; shape is decided here, type by the validator. (Coercing here would turn a zip code of 01234 into 1234.) A repeated plain key keeps the last value, as PHP does; write a[]=1&a[]=2 when you want both. Malformed keys (a[b) are kept verbatim rather than throwing, and depth and array indices are bounded. The input is attacker-controlled.

query() returns the expanded bag. query(key) is typed string | undefined, so a key holding an array or object reads as undefined there; use query() or input(key) for those. queryString() returns the raw, unexpanded string. Which is what signature verification hashes.

Note that { all: true } means a repeated field name yields an array.

Reading input

MethodReturns
input()The whole merged bag (a copy)
input(key, default?)One value, or default when the key is absent
query()The whole query bag (a copy)
query(key, default?)One query value, query only, ignoring params and body
all()Same as input()
only(...keys)Object with just those keys that are present
except(...keys)Everything but those keys
has(...keys)true when every key is present (own property)
hasAny(...keys)true when any key is present
filled(...keys)true when every key is present and not undefined/null/"". With no arguments, checks every key in the bag.
missing(...keys)true when every key is absent
collect(key?)A Collection: of the bag's values with no key; of the array/object's values with one

Presence checks use hasOwnProperty, so an explicit { "note": undefined } counts as present for has() but not for filled().

Coercion

MethodReturns
boolean(key)true for true, 1, "1", "true", "on", "yes", false otherwise
integer(key)parseInt(…, 10), or undefined for absent/null/""/non-finite
float(key)parseFloat, same undefined rules
string(key)String(value), or undefined for absent/null

These are for the form-encoded and query-string cases where everything arrives as a string. Real type coercion with error reporting belongs in validation.

export function perPageFrom(request: Request): number {
  const requested = request.integer("per_page");
  if (requested === undefined) return DEFAULT_PER_PAGE;
  return Math.min(MAX_PER_PAGE, Math.max(1, requested));
}

Mutating input

MethodEffect
merge(values)Assign into the body bag (highest precedence), then rebuild
mergeIfMissing(values)Same, but only for keys absent from the merged bag
replace(values)Replace the body bag wholesale

All three are fluent and mutate the body bag, route params and query are untouched, and since body wins the merge, merge() always takes effect. The main use is prepareForValidation().

URL and method

MethodReturns
method()Uppercased verb, e.g. "POST"
isMethod(m)Case-insensitive comparison
path()Path with a leading slash, no query
url()scheme://host/path: no query string
fullUrl()Full URL including query
fullUrlWithQuery(q)fullUrl() with q merged in; a null/undefined value deletes that param
root()scheme://host
httpHost()Host (with port)
scheme()"http" / "https"
secure()scheme() === "https"
is(pattern)Glob-match the path. * is the wildcard, everything else is escaped

root() is also published into the per-request context overlay on construction, which is how the URL generator borrows the live host for absolute URLs. Publishing is best-effort and silently no-ops when there's no container (a unit-constructed request).

Headers and client

MethodReturns
header(key, default?)Case-insensitive header lookup
headers()Every header, keys lowercased (a copy)
bearerToken()The token from Authorization: Bearer …, or undefined
ip()Client IP: the socket peer, unless trustProxies() resolved one
peerAddress()The immediate TCP peer, ignoring proxy resolution
ips()The unvalidated x-forwarded-for chain, plus the peer last
userAgent()The user-agent header
setResolvedIp(ip)Set the authoritative IP (called by trustProxies())

IP precedence

ip() returns the address that opened the socket, unless trustProxies() has established that the peer is a trusted proxy and resolved a real client address from X-Forwarded-For.

X-Forwarded-For is never consulted otherwise. It is a header, so any client can set it to anything; a framework that reads it by default lets an attacker pick their own rate-limit bucket, and gives every client that sends no header at all a shared one, which turns a throttle() on /login into a global lockout switch anyone can flip.

ip() is undefined only when there is genuinely no peer to name: an in-process dispatch (hono.request() in tests) or a non-Node adapter. Treat that as "unknown client", never as a usable identity.

ips() reports the raw forwarded chain with the socket peer appended, closest hop last. Only that last entry is proven, so it is for diagnostics; use ip() for decisions.

Cookies

Reading them:

MethodReturns
cookie(name, prefix?)One inbound cookie, decoded, or undefined
cookies()Every inbound cookie (a copy)

Writing them, queued on the request, not on the response:

MethodEffect
queueCookie(name, value, options?)Queue a cookie for this request's response
queueCookieForget(name, options?)Queue a deletion
unqueueCookie(name, options?)Drop a queued cookie without writing a deletion
queuedCookieHeaders()The queued cookies as Set-Cookie values
request.queueCookie("theme", "dark", { maxAge: 31_536_000, sameSite: "Lax" });

CookieOptions covers maxAge, expires, domain, path (default "/"), secure, httpOnly, sameSite, partitioned, priority, and prefix ("host"__Host-, "secure"__Secure-, each forcing the attributes the browser requires). Values are percent-encoded, and anything a browser would silently discard, a Max-Age beyond the 400-day cap, an invalid name, a ; inside an attribute, throws instead.

Why queue rather than set on the response

Mahi handlers return platform Response objects. Hono only merges its own context-queued headers into a response it built (c.json()/c.body()/c.newResponse()), so a cookie set via hono/cookie from framework code was silently dropped.

Queuing on the request also decouples deciding to set a cookie from building the response. A pipe or guard, SessionGuard.login(), csrf(), can queue one without knowing or caring what the handler eventually returns, including when a later pipe short-circuits with a 401. The HTTP boundary drains the queue onto whatever response comes back.

Queuing the same name (and path/domain) twice replaces the earlier entry, so a guard re-issuing a sliding session emits one Set-Cookie rather than two contradictory ones.

For a cookie a handler is setting itself, HttpResponse.cookie() is more direct. See Responses.

Content negotiation

MethodReturns
isJson()Content-Type contains application/json or +json
accepts(...types)true if Accept contains */*, or any listed type (substring, case-insensitive)
prefers(types)The first listed type present in Accept, falls back to types[0], never undefined in practice
wantsJson()The first Accept entry mentions json
expectsJson()wantsJson() || accepts("application/json", "json")

prefers() returning types[0] rather than undefined when nothing matches means you can treat it as "the format to use", not "did they ask". wantsJson() looks only at the first Accept entry, so a browser sending text/html,application/xhtml+xml,…,*/* is not treated as wanting JSON, while expectsJson() would say yes because of the */*. Pick the strict one for content negotiation and the loose one for error-format decisions.

Files

Uploaded files never enter the input bag. They live in a separate bag, so request.input("image") is undefined while request.file("image") is the File.

MethodReturns
file(key)The File, or the first of an array, or undefined
files(key)Always an array, [] when absent, [file] when single
hasFile(key)Whether the key is in the file bag
allFiles()The whole file bag (a copy)

These are Web-standard File objects. Validation reaches them because Validator is constructed with both bags, new Validator(this.all(), this.allFiles(), rules), so fileRule().image().max(5120) works against a field that isn't in all().

Route parameters

MethodReturns
route()Every route param (a copy)
route(name)One param, or undefined
parameter(name)One param: throws if absent

parameter() is the right call when the route pattern guarantees the segment, since it removes the undefined from the type:

const username = request.parameter("username");   // string
const user = await User.query().where("username", username).first();

The message is Expected route param "username" to be present.

Route-model binding

const post = await request.model(Post);              // reads {post}
const author = await request.model(User, "author");  // reads {author}

Binding is explicit. There is no implicit type-hint resolution, because there are no decorators or runtime type reflection to hang it on. You call model() where you want the fetch to happen.

  • The param name defaults to ModelClass.routeParamName(), derived from static morphName when set (morphName = "Post""post"), else the lowercased class name, falling back to "id" for classes that don't expose one.
  • 404 if the param is missing (HttpError.notFound()).
  • 404 if the row doesn't exist.
  • Cached per request, keyed ${ModelClass.table}:${param}. So a form request's authorize() and the controller's handle() calling request.model(Post) hit the database once, not twice.

The signature is structural ({ table, Row, find, routeParamName? }) rather than typeof Model, so concrete models with a typed Row stay assignable, Collection's variance rejects the nominal form.

See Models for morphName and routeParamName.

Per-request storage

MethodEffect
share(key, value)Stash a value on this request (fluent)
shared<T>(key)Read it back

The shared bag is carried across fromExisting() upgrades, so a global pipe can share("requestId", …) and a form request read it. It's a plain Map scoped to the request, for cross-cutting values that should also be visible to code that doesn't hold the request (loggers, the URL generator), use the context instead.

The current user

const user = request.user<UserTable>();

A thin delegate to Auth.userOrNull() when @mahiframework/auth is bound, and undefined otherwise. It is not a second user-storage mechanism. It resolves the "auth" token and calls through. Prefer Auth.user() / Auth.id() directly in controllers; request.user() exists so framework code (like a rate-limiter key callback) can ask without a hard dependency on the auth package.

Form requests

A form request is a Request subclass carrying validation rules and, optionally, authorization. Generate one with ./artisan make:request.

import { Request, rule, fileRule } from "@mahiframework/http";
import { authorize } from "@mahiframework/authorization";
import { Post } from "../../models/post.model.js";

export class CreatePostRequest extends Request {
  override authorize() {
    return authorize("create", Post);
  }

  rules() {
    return {
      body: rule().string().required().min(1).max(280),
      images: rule().array(fileRule().image().max(5120)).max(4).optional(),
    } as const;
  }
}

The as const matters: it preserves the literal shape of the rules object so validated() can infer a precise return type. See typed output.

authorize()

authorize(): boolean | void | Promise<boolean | void>

Defaults to true. A request that doesn't override it imposes no gate.

Only an explicit false produces a 403. The controller pipeline checks allowed === false, undefined (from a void-returning override) and true both pass. That's what makes delegating to @mahiframework/authorization work: authorize("create", Post) returns Promise<void> and throws on denial, so it never returns false and the check is a no-op for it.

You may also throw HttpError.forbidden() directly. Both routes give a 403; the error propagates to the central handler.

This is offered as a Laravel-shaped place to put authorization, not an enforced one. Authorizing inside the controller's handle() is equally supported, and is what you want when the check needs the model loaded first:

export class DeletePostController extends Controller {
  async handle(request: Request) {
    const existing = await request.model(Post);
    await authorize("delete", Post, existing);
    await Post.delete(existing.id);
    return HttpResponse.json({ deleted: true });
  }
}

rules()

Returns a Record<string, Rule>. Empty by default. A base Request runs no validation, and controllerToHandler skips validation entirely when rules() is empty rather than constructing a Validator that would pass trivially.

prepareForValidation()

Override it to normalize input via merge() / mergeIfMissing():

override prepareForValidation(): void {
  this.merge({ email: String(this.input("email") ?? "").toLowerCase() });
}

May be sync or async. In a controller it runs first, before authorize() (Laravel's order), so authorization sees the prepared input; a request that merges a tenant id or the current user and then authorizes against it works as written. validate() also ensures it has run, and it runs once either way.

Running validation

MethodBehaviour
prepareInput()Runs prepareForValidation() once. Called by the controller pipeline and by validate().
validate()Prepares, then runs the rules. Returns Promise<boolean>. Memoized. A second call returns the first result without re-running.
passes()Alias for validate()
fails()!(await validate())
validateOrFail()validate(), throwing ValidationException(errors) on failure. Returns this.
validated()The typed, validated payload. Synchronous.
errors()Record<string, string[]>: empty until validate() runs

validated() throws before a successful validate(), but only when there are rules to run.

Error: validated() was called before a successful validate().

validatedPayload is only assigned when validation passes, so this fires both when you forgot to validate and when validation failed. It's a plain Error, not an HttpError, a 500, because reaching it means the code is wrong, not the request.

A request with no rules returns {} instead of throwing: nothing was validated, and {} says exactly that. This is what lets a controller typed against the base Request call validated() without first knowing whether its subclass happens to declare rules.

Inside a controller that declares request = CreatePostRequest, the throwing case can't happen: the pipeline runs validateOrFail() before handle(). It applies when you construct and validate a form request by hand.

When rules are empty, validate() sets validatedPayload to {} and returns true, so validated() on a rule-less request returns {} rather than throwing.

Validating by hand, outside a controller:

const request = LoginRequest.fromExisting(incoming);
if (await request.fails()) {
  return HttpResponse.json({ errors: request.errors() }, 422);
}
const { email, password } = request.validated();

Escape hatch

request.raw();   // Hono Context | undefined

undefined for requests built with create() or new. trustProxies() uses it to reach getConnInfo() for the real peer address.