Requests

On this page 10

Every Action's handle receives a request. It wraps the incoming HTTP request and merges query string, JSON body, form body and route parameters into one input bag, so request.get('email') finds the value wherever it arrived.

// app/Actions/Auth/LoginAction.ts
import type { RequestInstance } from '@stacksjs/types'
import { Action } from '@stacksjs/actions'

export default new Action({
  name: 'LoginAction',
  description: 'Authenticate a user',

  async handle(request: RequestInstance) {
    const email = request.get('email')
    const password = request.get('password')

    // ...
  },
})

Reading input

get and input are the same accessor - use whichever reads better. Both take an optional default:

const email = request.get('email')
const page = request.get('page', 1)
const perPage = request.input('per_page', 15)

Pass a type parameter when the value is not a string:

const ids = request.get<number[]>('ids', [])

Working with the whole bag

request.all()                              // every field
request.only(['email', 'password'])        // just these
request.except(['password'])               // everything but these
request.keys()                             // the field names
request.merge({ source: 'api' })           // add fields
request.isEmpty()                          // no input at all

only and except accept a type parameter, which is worth using - it is what makes the result typed rather than any:

const credentials = request.only<{ email: string, password: string }>([
  'email',
  'password',
])

Presence

Four predicates, and the difference between them matters:

request.has('remember')                    // the key exists
request.has(['email', 'password'])         // all of these exist
request.hasAny(['email', 'username'])      // at least one exists
request.filled('name')                     // exists AND is not empty
request.missing('nickname')                // does not exist

has is true for an empty string; filled is not. For "the user actually supplied something", reach for filled.

request.whenHas('sort', value => applySort(value))
request.whenFilled('search', term => applySearch(term))
request.isValue('status', 'published')

Typed accessors

Input arrives as strings. These coerce, so the rest of the Action does not have to:

request.string('name')                     // string
request.integer('page', 1)                 // number
request.float('price')                     // number
request.boolean('subscribed')              // boolean
request.array<number>('ids')               // T[]
request.date('published_at')               // Date | null
request.enum('status', PostStatus)         // the enum member
request.collect<Tag>('tags')               // Collection<T>

boolean understands the strings a form actually sends - '1', 'true', 'on', 'yes' - rather than treating every non-empty string as true.

Validation

An Action declares its rules, and validate applies them:

await request.validate({
  email: { rule: schema.string().email() },
  password: { rule: schema.string().min(8) },
})

const data = request.getValidated()        // only the validated fields
const safe = request.safe()                // the same, as SafeData

Validation failures throw, and the error handler turns them into a 422 with the field messages. See Validation for the rule set.

Route parameters

Parameters declared in the route are readable on their own, and are also part of the input bag:

// route.get('/posts/{id}', 'Actions/PostShowAction')
request.param('id')
request.getParam('id')
request.getParamAsInt('id')                // number | null
request.getParams()                        // all of them
request.route('id')

Files

request.file('avatar')                     // UploadedFile | null
request.getFiles('attachments')            // UploadedFile[]
request.hasFile('avatar')
request.allFiles()

See Storage for writing an uploaded file somewhere.

Request metadata

request.header('x-request-id')
request.bearerToken()
request.ip()
request.ipForRateLimit()
request.browser()
request.getMethod()
request.json()                             // the parsed JSON body
request.user()                             // the authenticated user, if any

ipForRateLimit is deliberately separate from ip: it is the address a rate limiter should key on, which is not always the one you would log.

Old input

After a failed validation, the previous input is available for re-rendering a form:

request.old('email')
request.flashInput()                       // flash everything
request.flashInputOnly(['email'])
request.flashInputExcept(['password'])

Typing the request

RequestInstance takes the field and parameter shapes, so get, only and the typed accessors know your keys:

interface LoginFields {
  email: string
  password: string
  remember?: boolean
}

async handle(request: RequestInstance<LoginFields>) {
  const email = request.get('email')       // string
  const remember = request.boolean('remember', false)
}