5.5 apiRoute
5.5 apiRoute
In deze appendix bespreken we hoe we wrappers functies kunnen voorzien die de gemeenschappelijke code van elke api route 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 apiRoute functie is een wrapper rond een api route die alle stappen doorloopt die nodig zijn om een api route uit te voeren. Het doel is een functie die als volgt gebruikt kan worden:
// Een API route waarvoor de gebruiker INGELOGD moet zijn (via een JWT in de headers).
export const GET = protectedApiRoute({
routeFn: async ({data, profile, logger}) => {
// Database operaties en andere backend logica.
},
}
// Een API route waarvoor de gebruiker INGELOGD moet zijn (via een session cookie).
export const GET = protectedApiRoute({
routeFn: async ({data, profile, logger}) => {
// Database operaties en andere backend logica.
},
authenticationType: 'cookie',
}
// Een action waarvoor de gebruiker INGELOGD moet zijn (via een JWT in de headers) en de Admin rol moet hebben.
// Daarnaast moet er data ingestuurd worden via een JSON object in de body van het request en moet er een route parameter
// meegegeven worden.
export const PUT = protectedApiRoute({
schema: zodSchema,
routeFn: async ({data, profile, logger}, {fooId}: {fooId: string}) => {
// Database operaties en andere backend logica.
},
requiredRoles: [Role.Admin],
}
// Een action waarvoor de gebruiker NIET ingelogd moet zijn.
export const GET = = publicApiRoute({
routeFn: async ({data, logger}) => {
// Database operaties en andere backend logica.
},
}De functie moet dus op drie manieren gebruikt kunnen worden. Zonder user authenticatie, met user authenticatie en met user authenticatie en rollen controle. De authenticatie moet daarbovenop zowel uit de headers als uit een session cookie komen. Voor elk van deze mogelijkheden moet een zod schema meegegeven worden dat de input valideert, die input kan zowel uit de body van het request als uit queryparameters komen. Voor de volledigheid staan we ook toe dan de data in het body van het request als JSON data of als FormData ingestuurd wordt.
Zoals hierboven te zien is, moeten we een routeFn parameter kunnen meegeven die backend code uitvoert. Deze functie heeft een object als parameter waar minstens een data, profile en logger property in zitten. Daarnaast voegen we ook het request object toe.
Aangezien de profile property enkel beschikbaar mag zijn voor de protectedApiRoute 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; request: NextRequest}
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 routeFn property beschreven zijn, kunnen we de signatuur van routeFn 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 Params parameter toe die gebruikt kan worden om de route parameters (id's in de URL) te beschrijven.
Aangezien een api route zowel synchroon als asynchroon kan zijn en zowel iets als niets kan teruggeven, definiƫren we vier mogelijke returntypes.
type WrappedPublicAPIRouteFn<Params, Schema extends ZodType, Auth extends boolean> = (
context: Context<Schema, Auth>,
params: Params,
) => Promise<NextResponse | void> | NextResponse | voidNu dat we de nodige types hebben om de routeFn property te beschrijven, kunnen we verdergaan met de types voor de protectedApiRoute en publicApiRoute 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. De schema parameter wordt optioneel gemaakt zodat we ook api routes kunnen schrijven die geen parameters nodig hebben.
interface ApiRouteOptions<Params, Schema extends ZodType, Auth extends boolean> {
// The API function which contains the logic for the given function.
routeFn: WrappedPublicAPIRouteFn<Params, Schema, Auth>
// Whether the user should be logged in to use this route.
authenticated?: Auth
// The schema used to validate the submitted data.
schema?: Schema
// The source of the data, either the query body, the URL search params or submitted form data.
type?: 'body' | 'searchParams' | 'form'
// The type of authentication used, either a JWT token in the header (for external clients), or a session cookie (for
// calls from the Next app). Defaults to JWT authentication.
authenticationType?: 'jwt' | 'cookie'
// The roles by which the server function can be executed, if no argument was passed, anyone can execute the function.
requiredRoles?: Role[]
}}Nu we de ApiRouteOptions interface hebben, kunnen we deze gebruiken om een nieuwe apiRoute functie te definiƫren. Deze functie heeft opnieuw drie generische argumenten, dezelfde als de ApiRouteOptions interface.
const emptySchema = z.object({})
type EmptySchema = ZodType<typeof emptySchema>
function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
return async (request: NextRequest, {params}: {params: Promise<Params>}) => {
// Nog in te vullen
}
}Als laatste stap schrijven we wrappers rond de apiRoute functie die het expliciet maken of we een publieke of protected api route gebruiken. In zowel de protectedApiRoute als publicApiRoute wrappers kunnen we de waarde van de authenticated parameter vastleggen, daarom verwijderen we deze uit ApiRouteOptions. Voor de publicApiRoute heeft het geen zin om rollen door te geven, de gebruiker is tenslotte niet authenticated. Deze parameter wordt daar dus ook verwijderd uit ApiRouteOptions. Ook het authenticatietype (headers of cookie), wordt verwijderd voor de _publicApiRoute.
export function publicApiRoute<Params, Schema extends ZodType>(
options: Omit<ApiRouteOptions<Params, Schema, false>, 'authenticated' | 'requiredRoles' | 'authenticationType'>,
) {
return apiRoute<Params, Schema, false>({...options, authenticated: false})
}
export function protectedApiRoute<Params, Schema extends ZodType = EmptySchema>(
options: Omit<ApiRouteOptions<Params, Schema, true>, 'authenticated'>,
) {
return apiRoute<Params, Schema, true>(options)
}Implementatie
De apiRoute functie heeft hierboven nog geen body gekregen.
Om de body te implementeren beginnen we met de optionele parameters een defaultwaarde mee te geven in het geval deze niet gedefinieerd zijn.
Daarna lezen we de route parameters uit (als deze er zijn), en halen we de request specifieke instantie van de logger op.
function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const type = options?.type ?? 'body'
const schema = options?.schema ?? emptySchema
const authenticationType = options?.authenticationType ?? 'jwt'
return async (request: NextRequest, {params}: {params: Promise<Params>}) => {
const [logger, awaitedParams] = await Promise.all([getLogger(), params])
}
}Vervolgens moeten we de gebruikersinformatie ophalen, afhankelijk van de parameters gebeurt dit via de headers of via een session cookie. Daarna controleren we de rollen van de gebruiker als dit gevraagd werd. In het geval dat de gebruiker niet geauthenticeerd of geautoriseerd is, geven we een 401 Unauthorized terug via de utility methodes uit hoofdstuk 3.
function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const type = options?.type ?? 'body'
const schema = options?.schema ?? emptySchema
const authenticationType = options?.authenticationType ?? 'jwt'
return async (request: NextRequest, {params}: {params: Promise<Params>}) => {
const [logger, awaitedParams] = await Promise.all([getLogger(), params])
let profile: Profile | null | undefined = null
if (authenticationType === 'jwt' && authenticated) {
const [_, token] = (request.headers.get('Authorization') || ' ').split(' ')
profile = validateJwtToken(token)
} else if (authenticated) {
profile = await getSessionProfileFromCookie()
}
if (
(!profile && authenticated) ||
(profile && options.requiredRoles && !options.requiredRoles.includes(profile.role))
) {
logger.warn('Unauthorized user tried accessing API route.')
return unauthorized()
}
}
}Nadat de gebruiker geverifieerd is, moet de ingezonden data gevalideerd worden. In geval dat dit mislukt, sturen we de validatiefouten terug via een 401 Bad Request.
Tijdens het inlezen van de data maken we gebruik van de type parameter om de data van het request body dan wel de queryparameters uit te lezen. Als de type parameter body is, lezen we de body uit als een JSON object, als de type parameter form is, verwerken we het request body als een FormData. Voor beide situaties voorzien we een helper functie die een leeg object teruggeeft in geval van fouten, doen we dit niet, dan zou er een internal server error gegeneerd kunnen worden, wat minder aangenaam/duidelijk is voor gebruikers. Enkel als er echt een interne fout is, gooien we de error op.
function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const type = options?.type ?? 'body'
const schema = options?.schema ?? emptySchema
const authenticationType = options?.authenticationType ?? 'jwt'
return async (request: NextRequest, {params}: {params: Promise<Params>}) => {
const [logger, awaitedParams] = await Promise.all([getLogger(), params])
let profile: Profile | null | undefined = null
if (authenticationType === 'jwt' && authenticated) {
const [_, token] = (request.headers.get('Authorization') || ' ').split(' ')
profile = validateJwtToken(token)
} else if (authenticated) {
profile = await getSessionProfileFromCookie()
}
if (
(!profile && authenticated) ||
(profile && options.requiredRoles && !options.requiredRoles.includes(profile.role))
) {
logger.warn('Unauthorized user tried accessing API route.')
return unauthorized()
}
let unvalidatedData: unknown
if (type === 'body') {
unvalidatedData = await getBody(request)
} else if (type === 'searchParams') {
unvalidatedData = Object.fromEntries(request.nextUrl.searchParams.entries())
} else {
unvalidatedData = await getFormData(request)
}
const {data, errors} = validateSchema(schema, unvalidatedData)
if (errors || !data) {
return badRequest(errors)
}
}
}
async function getBody(request: NextRequest): Promise<unknown> {
try {
return await request.json()
} catch (error) {
if (error instanceof Error && error.message === 'Unexpected end of JSON input') {
return {}
}
throw error
}
}
async function getFormData(request: NextRequest): Promise<unknown> {
try {
return convertFormData(await request.formData())
} catch (error) {
if (
error instanceof Error &&
error.message === 'Content-Type was not one of "multipart/form-data" or "application/x-www-form-urlencoded".'
) {
return {}
}
throw error
}
}Als laatste stap voegen we een try-catch blok toe dat de routeFn oproept en het resultaat teruggeeft, als er geen resultaat is, geven we een eenvoudige 200 Ok terug. In het geval er iets mist gaat, vangen we deze error op, loggen we deze en geven we een 500 Internal Server Error terug.
function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
const authenticated = options?.authenticated === undefined ? true : options?.authenticated
const type = options?.type ?? 'body'
const schema = options?.schema ?? emptySchema
const authenticationType = options?.authenticationType ?? 'jwt'
return async (request: NextRequest, {params}: {params: Promise<Params>}) => {
const [logger, awaitedParams] = await Promise.all([getLogger(), params])
let profile: Profile | null | undefined = null
if (authenticationType === 'jwt' && authenticated) {
const [_, token] = (request.headers.get('Authorization') || ' ').split(' ')
profile = validateJwtToken(token)
} else if (authenticated) {
profile = await getSessionProfileFromCookie()
}
if (
(!profile && authenticated) ||
(profile && options.requiredRoles && !options.requiredRoles.includes(profile.role))
) {
logger.warn('Unauthorized user tried accessing API route.')
return unauthorized()
}
let unvalidatedData: unknown
if (type === 'body') {
unvalidatedData = await getBody(request)
} else if (type === 'searchParams') {
unvalidatedData = Object.fromEntries(request.nextUrl.searchParams.entries())
} else {
unvalidatedData = await getFormData(request)
}
const {data, errors} = validateSchema(schema, unvalidatedData)
if (errors || !data) {
return badRequest(errors)
}
try {
const context = {request, data, profile, logger} as Context<Schema, Auth>
const result = await options.routeFn(context, awaitedParams)
return result ?? ok()
} catch (error) {
logger.error(error)
return internalServerError()
}
}
}