Request

Read body, query string, route parameters, headers, and cookies from the incoming request.

Introduction

Every handler receives an HttpContext whose request property is a rich TekirRequest wrapper around Bun's native Request. It provides typed helpers for reading body data, query strings, headers, and cookies without reaching for the underlying Fetch API.

import type { HttpContext } from '@tekir/core'

export function store(ctx: HttpContext) {
  // ctx.request  →  TekirRequest (the rich wrapper documented here)
  // ctx.body     →  shortcut for the parsed request body
  // ctx.query    →  shortcut for the parsed query string
  // ctx.params   →  shortcut for URL route parameters
  // ctx.headers  →  shortcut for headers as a plain object
}

The Request Object

The full shape of TekirRequest:

interface TekirRequest {
  // Properties
  raw:         Request           // the underlying Bun Request
  url:         string            // full URL string
  method:      string            // HTTP method (GET, POST, …)
  path:        string            // pathname, e.g. '/api/users/1'
  hostname:    string            // domain, e.g. 'example.com'
  protocol:    string            // 'http' or 'https'
  ip:          string            // client IP address
  ips:         string[]          // proxy chain (X-Forwarded-For)
  completeUrl: string            // alias for url

  // Methods documented below
  all()
  input(key, defaultValue?)
  only(keys)
  except(keys)
  qs()
  param(key, defaultValue?)
  params()
  hasBody()
  header(name, defaultValue?)
  headers()
  is(types)
  accepts(types)
  language(languages)
  languages()
  cookie(name)
  signedCookie(name, secret)
  cookies()
  id()
  matchesRoute(name)
}

Body Methods

tekir parses the request body automatically before your handler runs (for JSON, URL-encoded, and multipart/form-data content types). The parsed result is available as ctx.body and through the methods below.

Query string and body parsing are hardened against prototype pollution by default. Dangerous keys such as __proto__, constructor, and prototype are rejected, and the objects returned by all(), only(), except(), and qs() are built without a prototype chain. A request like ?__proto__[admin]=true cannot pollute Object.prototype or leak into other objects, so you can merge request input safely without extra guarding.

all()

Returns a shallow merge of the query string and parsed body. Body fields win on collision. Use this when you don't care whether a value came from the URL or the body.

export async function search({ request }: HttpContext) {
  // Merges query string + parsed body into one flat object.
  // Body fields take priority over query string fields on collision.
  const data = request.all()
  // e.g. POST /search?sort=asc  with body { q: 'tekir' }
  // → { sort: 'asc', q: 'tekir' }
  return data
}

input(key, defaultValue?)

Looks up a single value by key. The lookup order is: body → query string → route params. Returns the default value (or undefined) if the key is absent everywhere.

export async function login({ request, response }: HttpContext) {
  // Looks in body first, then query string, then route params.
  const email    = request.input('email')
  const password = request.input('password')

  // With a default value (returned when the key is absent)
  const remember = request.input('remember', false)

  if (!email || !password) {
    return response.badRequest({ message: 'email and password required' })
  }
  // ...
}

only(keys)

Returns a new object containing only the specified keys from all(). Ideal for safe mass-assignment, any extra keys the client might have sent are silently discarded.

export async function update({ request, response }: HttpContext) {
  // Accept only these keys from the merged body + query: ignore everything else.
  const data = request.only(['name', 'email', 'bio'])
  // Safe mass-assignment: even if the client sends 'role' or 'password', they are excluded.
  return response.ok(data)
}

except(keys)

The inverse of only(): returns all keys except the ones listed.

export async function store({ request, response }: HttpContext) {
  // Grab all fields EXCEPT the ones listed.
  const data = request.except(['_token', '_method'])
  // Useful when you want to strip CSRF tokens or method-spoofing fields
  // without knowing every key in advance.
  return response.created(data)
}

hasBody()

Returns true if a parsed body is present and not null. Useful for guarding PUT or PATCH handlers that require a body.

export async function update({ request, response }: HttpContext) {
  if (!request.hasBody()) {
    return response.badRequest({ message: 'No body provided' })
  }
  // Safe to read request.all() / request.input() ...
}

ctx.bodyError

When the declared Content-Type does not match the actual payload (an empty body sent with Content-Type: application/json, malformed urlencoded, broken multipart, etc.) the parse error is captured on ctx.bodyError instead of crashing the route. Check it before reading ctx.body if you want to return a real 400:

import type { HttpContext } from '@tekir/core'

export async function store({ body, bodyError, response }: HttpContext) {
  if (bodyError) {
    return response.badRequest({
      message: 'Invalid request body',
      detail: bodyError.message,
    })
  }

  // Safe to read body now: parsing succeeded.
  return response.created({ ok: true, body })
}

Query String

qs()

Returns the raw query string as a Record<string, string | string[]>: keys that appear multiple times become an array. Unlike all(), this never includes the body.

export async function search({ request, response }: HttpContext) {
  // qs() returns only the query string, never the body.
  const q = request.qs()
  // GET /search?q=tekir&tags=fast&tags=small
  // → { q: 'tekir', tags: ['fast', 'small'] }
  // Repeated keys become arrays automatically.

  const term = q.q as string
  const tags = Array.isArray(q.tags) ? q.tags : q.tags ? [q.tags] : []

  return response.ok({ term, tags })
}

Route Parameters

params() and param()

params() returns all route parameters as a plain object. param(key, default?) reads a single parameter with an optional fallback. Values are always strings unless a .where() matcher with a cast function was applied.

export async function showPost({ request, params, response }: HttpContext) {
  // params is a shortcut for ctx.params: same object.
  // request.params() returns the same thing.
  const { userId, postId } = request.params()
  // → { userId: '42', postId: '7' }  (always strings unless cast by .where())

  // Read a single param with a fallback
  const id = request.param('userId', '0')

  return response.ok({ userId, postId })
}

Headers

Read a single request header by name. The lookup is case-insensitive. Returns the default value (or undefined) when the header is absent.

export async function index({ request, response }: HttpContext) {
  // Read a single header by name (case-insensitive)
  const contentType = request.header('content-type')
  // → 'application/json' or undefined

  // With a default value
  const lang = request.header('accept-language', 'en')

  return response.ok({ contentType, lang })
}

headers()

Returns all request headers as a plain Record<string, string> object.

export async function headers({ request, response }: HttpContext) {
  // All request headers as a plain { name: value } object
  const all = request.headers()
  // → { 'content-type': 'application/json', 'authorization': 'Bearer ...', ... }

  return response.ok(all)
}

is(types)

Returns true if the Content-Type header includes any of the given strings. Use it to validate the body format before attempting to parse it.

export async function upload({ request, response }: HttpContext) {
  // Returns true if the Content-Type header includes any of the given strings
  if (!request.is(['multipart/form-data'])) {
    return response.unsupportedMediaType({ message: 'Must be multipart/form-data' })
  }
  if (request.is(['application/json'])) { /* ... */ }
  if (request.is(['text/', 'html'])) { /* ... */ }
}

accepts(types)

Inspects the Accept header and returns the first type from your list that the client accepts, or false if there is no match. Use it for content negotiation.

export async function data({ request, response }: HttpContext) {
  // Returns the first matched Accept type, or false if none match.
  const format = request.accepts(['application/json', 'text/csv'])

  if (format === 'application/json') return response.ok({ data: [] })
  if (format === 'text/csv') return response.text('col1,col2\n')
  return response.notAcceptable()
}

language(languages)

Inspects the Accept-Language header and returns the first language from your list that the client accepts, or null if none match. Use languages() (no argument) to get all client-accepted languages sorted by preference.

export async function welcome({ request, response }: HttpContext) {
  // Returns the first language from your list that the client accepts
  const lang = request.language(['tr', 'en', 'de'])
  // Accept-Language: de-DE,de;q=0.9,en;q=0.8
  // → 'de'

  // null if none match
  if (!lang) return response.ok({ greeting: 'Hello' })
  return response.ok({ greeting: lang === 'tr' ? 'Merhaba' : lang === 'de' ? 'Hallo' : 'Hello' })

  // Get all accepted languages sorted by preference
  const all = request.languages()
  // → ['de', 'en']
}

Cookies

Read a plain cookie by name. Returns null if the cookie does not exist.

export async function me({ request, response }: HttpContext) {
  // Read a plain (unsigned) cookie by name: returns null if absent
  const theme = request.cookie('theme')
  // → 'dark' or null

  return response.ok({ theme })
}

Read a signed cookie. tekir verifies the HMAC signature using the provided secret before returning the value. Returns null if the cookie is absent, malformed, or the signature does not match, preventing tampering.

import env from '#env'

export async function cart({ request, response }: HttpContext) {
  // Read a signed cookie: verifies the HMAC signature automatically.
  // Returns null if the cookie is absent OR tampered with.
  const cartId = request.signedCookie('cart_id', env.APP_KEY)
  if (!cartId) return response.ok({ items: [] })

  return response.ok({ cartId })
}

id()

Returns a unique identifier for this request. tekir uses the value of the X-Request-Id header if present; otherwise it generates a UUID. The same value is returned every time id() is called within a single request lifecycle, so you can use it for correlating log lines.

export function index({ request }: HttpContext) {
  // Returns the value of the X-Request-Id header if present,
  // or a newly generated UUID for this request: stable for the request lifetime.
  const requestId = request.id()
  // → 'a1b2c3d4-...'
}

Raw Request

If you need access to something that TekirRequest does not expose, the underlying Bun Request is always available as request.raw.

export async function webhook({ request, response }: HttpContext) {
  // Access the underlying Bun Request for anything not covered by TekirRequest
  const rawBody = await request.raw.text()
  const signature = request.header('x-signature')

  if (!verifySignature(rawBody, signature)) {
    return response.unauthorized()
  }

  const event = JSON.parse(rawBody)
  return response.noContent()
}