5.2 serverFunction & formAction
5.2 serverFunction & formAction
In deze appendix bespreken we hoe we wrappers functies kunnen voorzien die de gemeenschappelijke code van elke server functie (of server action) afzonderen.
We beginnen met de TypeScript types voor de server functions en server actions te bouwen, daarna bespreken we de implementatie van een gemeenschappelijke functie die de logica afhandelt.
Signatuur
server functions
De serverFunction functie is een wrapper rond een server functions die alle stappen doorloopt die nodig zijn om een server function uit te voeren. Het doel is een functie die als volgt gebruikt kan worden:
// Een server function waarvoor de gebruiker INGELOGD moet zijn.
export const deleteFoo = protectedServerFunction({
schema: zodSchema,
serverFn: async ({data, profile}) => {
// Database operaties en andere backend logica.
},
functionName: 'Delete foo server function',
}
// Een action waarvoor de gebruiker INGELOGD moet zijn en de Admin rol moet hebben.
export const deleteFoo = protectedServerFunction({
schema: zodSchema,
serverFn: async ({data, profile}) => {
// Database operaties en andere backend logica.
},
functionName: 'Delete foo server function',
requiredRoles: [Role.Admin],
}
// Een action waarvoor de gebruiker NIET ingelogd moet zijn.
export const deleteFoo = = publicServerFunction({
schema: zodSchema,
serverFn: async ({data}) => {
// Database operaties en andere backend logica.
},
functionName: 'Delete foo server function',
}De functie moet dus op drie manieren gebruikt kunnen worden. Zonder user authenticatie, met user authenticatie en met user authenticatie en rollen controle, voor elk van deze mogelijkheden moet een zod schema meegegeven worden dat de input valideert.
Zoals hierboven te zien is, moeten we een serverFn parameter kunnen meegeven die backend code uitvoert. Deze functie heeft een object als parameter waar minstens een data property en een profile property in zitten. Naast deze dingen voegen we ook een instantie van de logger toe.
Aangezien de profile property enkel beschikbaar mag zijn voor de protectedServerFunction versie, maken twee types aan, één voor elke situatie. Vervolgens kunnen we een algemeen type definiëren dat via een generische boolean parameter aangeeft of we de publieke dan wel de protected variant moeten gebruiken. Hiervoor maken we gebruik van conditional types.
type PublicContext<Schema extends ZodType> = {data: z.infer<Schema>; logger: Logger}
type ProtectedContext<Schema extends ZodType> = PublicContext<Schema> & {profile: Profile}
type Context<Schema extends ZodType, Auth extends boolean> = Auth extends true
? ProtectedContext<Schema>
: PublicContext<Schema>Nu de parameters van de serverFn property beschreven zijn, kunnen we de signatuur van serverFn definiëren. Deze functie heeft drie generische parameters, we moeten natuurlijk de Schema en Auth parameters van hierboven terug definiëren, daarnaast voegen we ook een ReturnType parameter toe die meegegeven kan worden aan de FormActionResponse interface zoals besproken in hoofdstuk 6.
type WrappedServerFn<Schema extends ZodType, ReturnType, Auth extends boolean> = (
context: Context<Schema, Auth>,
) => Promise<FormActionResponse<ReturnType> | void>Nu dat we de nodige types hebben om de serverFn property te beschrijven, kunnen we verdergaan met de types voor de protectedServerFunction en publicServerFunction helpers.
We beginnen met een algemene interface te beschrijven die voor beide situaties geldig is, daartoe voegen we bovenop de parameters die bovenaan deze appendix beschreven ook een authenticated property toe die aangeeft of de gebruiker al dan niet ingelogd is. Tenslotte voegen we ook een globalErrorMessage property toe die gebruikt wordt om een foutboodschap terug te geven in het geval er zich een internal server error voorgedaan heeft.
De schema parameter wordt optioneel gemaakt zodat we ook server functions kunnen schrijven die geen parameters nodig hebben.
interface ServerFunctionOptions<Schema extends ZodType, ReturnType, Auth extends boolean> {
// The function which contains the logic for the given server function or actions
serverFn: WrappedServerFn<Schema, ReturnType, Auth>
// The schema to validate the submitted data against, defaults to an empty schema.
schema?: Schema
// Whether the function should be protected and require an authenticated user, defaults to true.
authenticated?: Auth
// A message to display in case of a server error.
globalErrorMessage?: string
// The roles by which the server function can be executed, if no argument was passed, anyone can execute the function.
requiredRoles?: Role[]
// The name of the server function, used in logs.
functionName: string
}Nu we de ServerFunctionOptions interface hebben, kunnen we deze gebruiken om een nieuwe serverFunction functie te definiëren. Deze functie heeft opnieuw drie generische argumenten, dezelfde als de ServerFunctionOptions interface. Verder geeft deze functie een andere functie terug deze bestaat in twee varianten
- ServerFunction is een server functie die parameters heeft, i.e. ingestuurde data in de vorm van een JSON object.
- ServerFunctionWithoutParams is een server functie die geen invoer vereist.
type ServerFunction<Schema extends ZodType> = (data: z.infer<Schema>) => Promise<void>
type ServerFunctionWithoutParams = () => Promise<void>
function serverFunction<Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ServerFunctionOptions<Schema, void, Auth>,
): ServerFunction<Schema> | ServerFunctionWithoutParams {
return async (unvalidatedData?: z.infer<Schema>): Promise<void> => {
// Nog in te vullen
}
}Als laatste stap schrijven we wrappers rond de serverFunction functie die het expliciet maken of we een publieke of protected server functie gebruiken. In zowel de protectedServerFunction als publicServerFunction wrappers kunnen we de waarde van de authenticated parameter vastleggen, daarom verwijderen we deze uit ServerFunctionOptions. Voor de publicServerFunction heeft het geen zin om rollen door te geven, de gebruiker is tenslotte niet authenticated. Deze parameter wordt daar dus ook verwijderd uit ServerFunctionOptions.
Omdat we ofwel een ServerFunctionWithoutParams of een ServerFunction teruggeven uit de serverFunction wrapper, gebruiken we overloading om te specifiëren in welk geval wat teruggegeven wordt.
In het geval dat er geen schema meegegeven wordt zetten we de Schema generic op een leeg schema (een leeg object), omdat er geen schema meegegeven wordt weten we ook dat het returntype ServerFunctionWithoutParams is. We verwijderen de schema parameter hier ook uit de ServerFunctionOptions, doen we dit niet, dan verwacht TypeScript dat we expliciet EmptySchema meegeven als parameter, ook al moet er niets gevalideerd worden.
De tweede overload is eenvoudiger, hier geven we een schema mee en dus geeft de functie een ServerFunction terug.
De laatste overload combineert de twee voorgaande en voorziet een implementatie, hier roepen we de serverFunction functie op, maar zetten de we authenticated parameter expliciet op true of false (afhankelijk van het doel van de functie.)
const emptySchema = z.object({})
type EmptySchema = ZodType<typeof emptySchema>
export function protectedServerFunction(
options: Omit<ServerFunctionOptions<EmptySchema, void, true>, 'authenticated' | 'schema'> & {schema?: undefined},
): ServerFunctionWithoutParams
export function protectedServerFunction<Schema extends ZodType = EmptySchema>(
options: Omit<ServerFunctionOptions<Schema, void, true>, 'authenticated'>,
): ServerFunction<Schema>
export function protectedServerFunction<Schema extends ZodType = EmptySchema>(
options: Omit<ServerFunctionOptions<Schema, void, true>, 'authenticated'>,
): ServerFunction<Schema> | ServerFunctionWithoutParams {
return serverFunction<Schema, true>({...options, authenticated: true})
}
export function publicServerFunction(
options: Omit<ServerFunctionOptions<EmptySchema, void, false>, 'authenticated' | 'requiredRoles' | 'schema'> & {
schema?: undefined
},
): ServerFunctionWithoutParams
export function publicServerFunction<Schema extends ZodType>(
options: Omit<ServerFunctionOptions<Schema, void, false>, 'authenticated' | 'requiredRoles'>,
): ServerFunction<Schema>
export function publicServerFunction<Schema extends ZodType = EmptySchema>(
options: Omit<ServerFunctionOptions<Schema, void, false>, 'authenticated' | 'requiredRoles'>,
): ServerFunction<Schema> | ServerFunctionWithoutParams {
return serverFunction<Schema, false>({...options, authenticated: false})
}server actions
We kunnen de ServerFunctionOptions interface opnieuw gebruiken om een formAction functie te definiëren die gebruikt kan worden om een server action te implementeren. Deze functie heeft opnieuw drie generische argumenten, dezelfde als de ServerFunctionOptions interface.
In tegenstelling tot de serverFunction moeten we voor de formAction functie geen onderscheid maken tussen een inzending met of zonder data. In de context van formulier, moet er per definitie data aanwezig zijn.
Merk op dat we in onderstaande code opnieuw gebruik maken van de FormActionResponse en FormActionResponse interfaces zoals besproken in hoofdstuk 6.
function formAction<Schema extends ZodType = EmptySchema, ReturnType = void, Auth extends boolean = true>(
options: ServerFunctionOptions<Schema, ReturnType, Auth>,
): FormAction<ReturnType> {
return async (
_prevState: FormActionResponse<ReturnType>,
unvalidatedData: FormData,
): Promise<FormActionResponse<ReturnType>> => {
// Nog in te vullen
}
}Nu dat de formAction functie gedefinieerd is, kunnen we terug wrappers schrijven die publieke dan wel authenticated toegang toestaan. Omdat het onmogelijk is om een formulier in te zenden waar geen data voor nodig is, moeten we nu geen overloading gebruiken.
export function protectedFormAction<Schema extends ZodType = EmptySchema, ReturnType = void>(
options: Omit<ServerFunctionOptions<Schema, ReturnType, true>, 'authenticated'>,
): FormAction<ReturnType> {
return formAction<Schema, ReturnType, true>({...options, authenticated: true})
}
export function publicFormAction<Schema extends ZodType = EmptySchema, ReturnType = void>(
options: Omit<ServerFunctionOptions<Schema, ReturnType, false>, 'authenticated' | 'requiredRoles'>,
): FormAction<ReturnType> {
return formAction<Schema, ReturnType, false>({...options, authenticated: false})
}Implementatie
De serverFunction en formActions functies hebben hierboven nog geen body gekregen. Om de body te implementeren schrijven we een nieuwe functie handleServerFunction die zowel een server function als een server action afhandelt.
Het enige verschil tussen een server function en een form action is de manier waarop de data doorgestuurd wordt, daarom geven we de options parameter rechtreeks door aan handleServerFunction, maar zorgen we ervoor dat de data correct uitgelezen en doorgegeven wordt.
function serverFunction<Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ServerFunctionOptions<Schema, void, Auth>,
): ServerFunction<Schema> | ServerFunctionWithoutParams {
return async (unvalidatedData?: z.infer<Schema>): Promise<void> => {
await handleServerFunction<Schema, void, Auth>({
...options,
unvalidatedData: unvalidatedData ?? {},
})
}
}
function formAction<Schema extends ZodType = EmptySchema, ReturnType = void, Auth extends boolean = true>(
options: ServerFunctionOptions<Schema, ReturnType, Auth>,
): FormAction<ReturnType> {
return async (
_prevState: FormActionResponse<ReturnType>,
unvalidatedData: FormData,
): Promise<FormActionResponse<ReturnType>> => {
return handleServerFunction<Schema, ReturnType, Auth>({
...options,
unvalidatedData,
})
}
}
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
// Nog te implementeren
}Om de body te implementeren beginnen we met de optionele parameters een defaultwaarde mee te geven in het geval deze niet gedefinieerd zijn. Verder lezen we ook de start timestamp uit zodat we in de logs kunnen aangeven hoelang elke operatie duurde en printen we al een eerste log uit dat aangeeft dat de functie opgeroepen is.
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
const start = Date.now()
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const schema = options?.schema ?? emptySchema
const unvalidatedData = options.unvalidatedData
const logger = await getLogger()
const functionName = options.functionName ?? 'Server function'
logger.info(`${functionName} called`)
}Zowel met het ophalen van de gebruikersgegevens als de functie in de serverFn parameter, kan er iets misgaan. Daarom omringen we de rest van de functie in een try-catch blok. Bovenaan dit blok halen we de gebruikersinformatie op als authenticated op true staat en controleren we ook de rollen als hierom gevraagd werd.
In het geval de gebruiker niet de juiste rollen heeft stoppen we de functie en printen we een warning log uit.
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
const start = Date.now()
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const schema = options?.schema ?? emptySchema
const unvalidatedData = options.unvalidatedData
const logger = await getLogger()
const functionName = options.functionName ?? 'Server function'
logger.info(`${functionName} called`)
try {
logger.trace(`Checking authentication for ${functionName}.`)
const profile = authenticated ? await getSessionProfileFromCookieOrThrow() : undefined
logger.trace(`Checking authorization for ${functionName}.`)
if (authenticated && options.requiredRoles && !options.requiredRoles.includes(profile!.role)) {
logger.warn(`Unauthorized user ${profile?.id} tried executing ${functionName ?? 'a server function'}.`)
return {
success: false,
}
}
} catch (e) {
}
}Nadat de gebruiker geverifieerd is, moet de ingezonden data gevalideerd worden. In geval dat dit mislukt, sturen we de validatiefouten terug zodat de gebruiker deze kan oplossen. Merk op dat we hier twee varianten voor gebruiken, voor een formAction wordt de data ingestuurd als een FormData object en als een JSON-object in een serverFunction. Als we de data teruggeven moeten we hier rekening mee houden.
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
const start = Date.now()
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const schema = options?.schema ?? emptySchema
const unvalidatedData = options.unvalidatedData
const logger = await getLogger()
const functionName = options.functionName ?? 'Server function'
logger.info(`${functionName} called`)
try {
logger.trace(`Checking authentication for ${functionName}.`)
const profile = authenticated ? await getSessionProfileFromCookieOrThrow() : undefined
logger.trace(`Checking authorization for ${functionName}.`)
if (authenticated && options.requiredRoles && !options.requiredRoles.includes(profile!.role)) {
logger.warn(`Unauthorized user ${profile?.id} tried executing ${functionName ?? 'a server function'}.`)
return {
success: false,
}
}
logger.trace(`Validating submitted data for ${functionName}.`)
const {data, errors} = validateSchema(schema, unvalidatedData)
const submittedData = (
unvalidatedData instanceof FormData ? Object.fromEntries(unvalidatedData.entries()) : unvalidatedData
) as Record<string, string>
if (errors) {
logger.trace(`Validation of submitted data failed for ${functionName}.`)
return {
errors,
success: false,
submittedData,
}
}
} catch (e) {
}
}Als laatste stap in het normale pad (de try), moeten we de serverFn oproepen die de logica voor een specifieke server functie implementeert. Als serverFn iets teruggeeft, wordt dat gebruikt als return type van de handleServerFunction helper, als serverFn niets teruggeeft, geven we {success: true} terug.
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
const start = Date.now()
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const schema = options?.schema ?? emptySchema
const unvalidatedData = options.unvalidatedData
const logger = await getLogger()
const functionName = options.functionName ?? 'Server function'
logger.info(`${functionName} called`)
try {
logger.trace(`Checking authentication for ${functionName}.`)
const profile = authenticated ? await getSessionProfileFromCookieOrThrow() : undefined
logger.trace(`Checking authorization for ${functionName}.`)
if (authenticated && options.requiredRoles && !options.requiredRoles.includes(profile!.role)) {
logger.warn(`Unauthorized user ${profile?.id} tried executing ${functionName ?? 'a server function'}.`)
return {
success: false,
}
}
logger.trace(`Validating submitted data for ${functionName}.`)
const {data, errors} = validateSchema(schema, unvalidatedData)
const submittedData = (
unvalidatedData instanceof FormData ? Object.fromEntries(unvalidatedData.entries()) : unvalidatedData
) as Record<string, string>
if (errors) {
logger.trace(`Validation of submitted data failed for ${functionName}.`)
return {
errors,
success: false,
submittedData,
}
}
// Await is required here, if we return fn directly, any thrown errors are not caught and returned through the
// promise's catch method.
const result = await options.serverFn({data, profile, logger} as Context<ZodType, Auth>)
logger.info(`${functionName} completed successfully in ${Date.now() - start} ms`)
return result ?? {success: true}
} catch (e) {
}
}Tenslotte moeten we de catch-tak nog implementeren. Hier onderscheiden we twee situaties, enerzijds kan deze tak geactiveerd worden door een verwachte error zoals de redirect functie uit Next. In dat geval moeten we de error opnieuw opgooien, anders werkt de redirect niet meer.
Anderzijds kan de tak ook geactiveerd worden door een interne serverfout, in dat geval geven we de global foutmelding terug. Ook de ingestuurde data wordt teruggezonden zodat de gebruiker opnieuw kan proberen of de data kan aanpassen.
async function handleServerFunction<Schema extends ZodType, ReturnType, Auth extends boolean>(
options: ServerFunctionOptions<Schema, ReturnType, Auth> & {unvalidatedData: unknown},
): Promise<FormActionResponse<ReturnType>> {
const start = Date.now()
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const schema = options?.schema ?? emptySchema
const unvalidatedData = options.unvalidatedData
const logger = await getLogger()
const functionName = options.functionName ?? 'Server function'
logger.info(`${functionName} called`)
try {
logger.trace(`Checking authentication for ${functionName}.`)
const profile = authenticated ? await getSessionProfileFromCookieOrThrow() : undefined
logger.trace(`Checking authorization for ${functionName}.`)
if (authenticated && options.requiredRoles && !options.requiredRoles.includes(profile!.role)) {
logger.warn(`Unauthorized user ${profile?.id} tried executing ${functionName ?? 'a server function'}.`)
return {
success: false,
}
}
logger.trace(`Validating submitted data for ${functionName}.`)
const {data, errors} = validateSchema(schema, unvalidatedData)
const submittedData = (
unvalidatedData instanceof FormData ? Object.fromEntries(unvalidatedData.entries()) : unvalidatedData
) as Record<string, string>
if (errors) {
logger.trace(`Validation of submitted data failed for ${functionName}.`)
return {
errors,
success: false,
submittedData,
}
}
// Await is required here, if we return fn directly, any thrown errors are not caught and returned through the
// promise's catch method.
const result = await options.serverFn({data, profile, logger} as Context<ZodType, Auth>)
logger.info(`${functionName} completed successfully in ${Date.now() - start} ms`)
return result ?? {success: true}
} catch (e) {
const error = e as Error
// The Next redirect function works by throwing an error, so we should not catch this error, but throw it again so
// that Next can properly redirect the user.
if (error.message === 'NEXT_REDIRECT') {
logger.info(`${functionName} completed successfully in ${Date.now() - start} ms`)
throw e
}
logger.error({
msg: `An error occurred in ${functionName}.`,
error: error.message,
})
}
return {
errors: {
errors: [options.globalErrorMessage ?? 'Something went wrong, please ensure you are logged in and try again'],
},
success: false,
submittedData: (unvalidatedData instanceof FormData
? Object.fromEntries(unvalidatedData.entries())
: unvalidatedData) as Record<string, string>,
}
}