Validating intent in action functions
You might use the action in a route for multiple types of actions (intents).
One trick for handling this is to add a hidden <input>
field to your form with intent
as its name and a descriptive action as its value
.
For example we could add a delete button like so:
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit">Delete</button>
</Form>
Deletion is a destructive action, so we want to make sure that it is the correct intent for the POST request.
In the action function we could check the intent like this:
export const action = async ({ context }: ActionFunctionArgs) => {
const { http, make } = context
const { intent } = http.request.only(['intent'])
if (intent === 'delete') {
// delete logic
} else {
throw new Response(`Unsupported intent ${intent}`, { status: 400 })
}
return null
}
This works, but doesn't provide much type safety or validation of the incoming payload. Let's add some more type safety to our application using VineJS!
Using VineJS for request validation
We want to be able to handle different intents with different payloads on the same endpoint, and that can be achieved by using the VineJS validation library.
We can use the field intent
to find out what validation we should use: our delete
schema might be different from other intent schemas, so we keep them separate.
The code for combining multiple intents look like the following:
const searchValidation = {
intent: vine.literal('search'),
searchQuery: vine.string().trim().minLength(1),
}
const addValidation = {
intent: vine.literal('add'),
guid: vine.string().trim().minLength(1),
}
const intent = vine.group([
vine.group.if((data) => data.intent === 'search', searchValidation),
vine.group.if((data) => data.intent === 'add', addValidation),
])
const actionValidation = vine.compile(
vine
.object({
intent: vine.enum(['search', 'add']),
})
.merge(intent)
)
Here we have two intents: search
and add
for the same endpoint. The search
intent expects a searchQuery
field to be present, while the add
intent expects a guid
field to be present.
We use the validation in our action like so:
export const action = async ({ context }: ActionFunctionArgs) => {
const { http, make } = context
const r = await http.request.validateUsing(actionValidation)
if (r.intent === 'search') {
const service = await make('search_service')
const searchResults = await service.search(r.searchQuery)
return json({ searchResults })
} else if (r.intent === 'add') {
const service = await make('episode_service')
const episode = await service.addByGuid({
guid: r.guid
})
return redirect(`/episode/${episode.endpoint}`)
} else {
// @ts-expect-error
throw new Response(`Invalid intent ${r.intent}`, { status: 400 })
}
}
When we switch on the intent
field, we get strongly typed scopes to work in. This is called a discriminatory union, which basically means that we are using the intent
field as a "discriminator" to understand which of the payloads we have received, and Typescript has full support for this.
Extracting a helper function for intent validation
We had to write a lot of validation code that could become repetitive if we have many actions. Luckily this is a repeating pattern when we have a standard intent
field.
Here is a helper function that could be useful if you want to use this pattern:
// resources/remix_app/utils/intent_validation.ts
import vine, { VineLiteral } from '@vinejs/vine'
import type { SchemaTypes } from '@vinejs/vine/types'
type FieldValidation = Record<string, SchemaTypes>
type ValidationObject = Record<string, FieldValidation>
type ConstrainedObject<T extends FieldValidation> = {
[Key in keyof T]: T[Key]
}
type ConstrainedValidation<T extends ValidationObject> = {
[Key in keyof T]: ConstrainedObject<T[Key]>
}
export function intentValidation<T extends ValidationObject>(
validations2: ConstrainedValidation<T>
) {
type ValidationWithIntent = {
[Key in keyof T]: ConstrainedObject<T[Key]> & { intent: VineLiteral<Key> }
}
const validations = validations2 as ValidationWithIntent
Object.keys(validations).forEach((key) => {
validations[key].intent = vine.literal(key)
})
const validationGroup = vine.group(
(
Object.entries(validations) as [
keyof ValidationWithIntent,
ValidationWithIntent[keyof ValidationWithIntent],
][]
).map(([key, entry]) => vine.group.if((data) => data.intent === key, entry))
)
return vine.compile(
vine
.object({
intent: vine.enum(Object.keys(validations)),
})
.merge(validationGroup)
)
}
With this helper we can rewrite our search action validator like so:
const actionValidation = intentValidation({
search: {
searchQuery: vine.string().trim().minLength(1),
},
add: {
guid: vine.string().trim().minLength(1),
},
})