backend_lecture6_example
  1. src
  2. lib
  3. apiRoute.ts
import 'server-only'
import type {NextRequest, NextResponse} from 'next/server'
import type {ZodType} from 'zod/v4'
import {z} from 'zod/v4'
import type {Profile} from '@/models/users'
import {getSessionProfileFromCookie} from '@/lib/sessionUtils'
import type {ApiRoute} from '@/models/apiRoute'
import {badRequest, internalServerError, ok, unauthorized} from '@/lib/routeResponses'
import {validateSchema} from '@/lib/validateSchema'
import {convertFormData} from '@/lib/convertFormData'
import type {Role} from '@/generated/prisma/enums'
import type {Logger} from 'pino'
import {getLogger} from '@/lib/logger'
import {validateJwtToken} from '@/lib/jwtUtils'

const emptySchema = z.object({})
type EmptySchema = ZodType<typeof emptySchema>

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>

type WrappedPublicAPIRouteFn<Params, Schema extends ZodType, Auth extends boolean> = (
  context: Context<Schema, Auth>,
  params: Params,
) => Promise<NextResponse | void> | NextResponse | void

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[]
}

/**
 * A utility function used to abstract the common logic of API routes which are only accessible by all users (including
 * unauthenticated ones).
 *
 * @param options An object containing the configuration for the API route.
 */
export function publicApiRoute<Params, Schema extends ZodType>(
  options: Omit<ApiRouteOptions<Params, Schema, false>, 'authenticated' | 'requiredRoles' | 'authenticationType'>,
) {
  return apiRoute<Params, Schema, false>({...options, authenticated: false})
}

/**
 * A utility function used to abstract the common logic of API routes which are only accessible by authenticated
 * users.
 *
 * @param options An object containing the configuration for the API route.
 */
export function protectedApiRoute<Params, Schema extends ZodType = EmptySchema>(
  options: Omit<ApiRouteOptions<Params, Schema, true>, 'authenticated'>,
) {
  return apiRoute<Params, Schema, true>(options)
}

function apiRoute<Params = unknown, Schema extends ZodType = EmptySchema, Auth extends boolean = true>(
  options: ApiRouteOptions<Params, Schema, Auth>,
): ApiRoute<Params> {
  const start = Date.now()
  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) {
      logger.trace(`Checking authentication through HTTP headers.`)
      const [_, token] = (request.headers.get('Authorization') || ' ').split(' ')
      profile = validateJwtToken(token)
    } else if (authenticated) {
      logger.trace(`Checking authentication through a session cookie.`)
      profile = await getSessionProfileFromCookie()
    }

    if (
      (!profile && authenticated) ||
      (profile && options.requiredRoles && !options.requiredRoles.includes(profile.role))
    ) {
      logger.warn(`Unauthorized user ${profile?.id} tried executing 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) {
      logger.trace(`Validation of submitted data failed for API Route.`)
      return badRequest(errors)
    }

    try {
      const context = {request, data, profile, logger} as Context<Schema, Auth>
      const result = await options.routeFn(context, awaitedParams)
      logger.info(`API Route completed successfully in ${Date.now() - start} ms`)
      return result ?? ok()
    } catch (error) {
      logger.error(error)
      return internalServerError()
    }
  }
}

/**
 * Utility function which retrieves the body of the request and gracefully fails with an empty object when there is no
 * parsable body.
 *
 * @param request The request from which to retrieve the body.
 */
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
  }
}

/**
 * Utility function which retrieves the submitted form data of the request and gracefully fails with an empty object
 * when there is no parsable form submission.
 *
 * @param request The request from which to retrieve the form data.
 */
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
  }
}