diff --git a/docs/api/server-response.md b/docs/api/server-response.md index e14323f..d7caf12 100644 --- a/docs/api/server-response.md +++ b/docs/api/server-response.md @@ -66,6 +66,25 @@ API without triggering Resora's automatic dispatch. `afterSend` hooks remain reserved for actual dispatch through `send()` or awaiting the `ServerResponse`. +### toWebResponse() + +Resolves to a JSON Web `Response` built from the finalized body, status, status +text and headers: + +```ts +export async function GET() { + return new UserResource(await findUser()) + .response() + .setStatusCode(200) + .toWebResponse(); +} +``` + +It suits handlers that return a `Response`, such as Next.js App Router route +handlers. `Content-Type` defaults to `application/json` unless a header sets it. +Async `data()` is resolved first, and `beforeSend` plugin hooks run as they do +for `toResponseData()`. + ## Promise Support ```ts diff --git a/docs/guide/non-connect-frameworks.md b/docs/guide/non-connect-frameworks.md index 7a791aa..49299bb 100644 --- a/docs/guide/non-connect-frameworks.md +++ b/docs/guide/non-connect-frameworks.md @@ -85,10 +85,55 @@ fastify.get('/users/:id', async (request) => { }); ``` -## NestJS Example (Current Recommended Pattern) +## Next.js + +### Pages Router API routes + +Resora dispatches through the API route's `res` directly: + +```ts +import type { NextApiRequest, NextApiResponse } from 'next'; +import { Resource } from 'resora'; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const user = await findUser(req.query.id); + + await new Resource(user, { req, res }) + .response() + .setStatusCode(200) + .header('X-Resource', 'user'); +} +``` + +Passing `{ req, res }` also gives Resora the request path for pagination links. + +### App Router route handlers + +Route handlers return a Web `Response`, so build one with `toWebResponse()`: + +```ts +import { Resource } from 'resora'; + +export async function GET(request: Request) { + const user = await findUser(new URL(request.url).searchParams.get('id')); + + return new Resource(user) + .response() + .setStatusCode(200) + .header('X-Resource', 'user') + .toWebResponse(); +} +``` + +## NestJS + +NestJS hands controllers the platform's own response: an Express `res` or a +Fastify `reply`. Resora dispatches through either one. ### Return value style +Let Nest send the response and return the transformed body: + ```ts import { Resource } from 'resora' @@ -104,19 +149,47 @@ async findOne(@Param('id') id: string) { ### `@Res()` style +Inject the response with `@Res()` and let Resora send it: + ```ts import { Resource } from 'resora' @Get(':id') -async findOne(@Param('id') id: string, @Res() res: any) { +async findOne(@Param('id') id: string, @Req() req: Request, @Res() res: Response) { const user = await this.usersService.findOne(id) - const body = new Resource(user).getBody() + await new Resource(user, { req, res }) + .response() + .setStatusCode(200) + .header('X-Resource', 'user') +} +``` + +With the Fastify platform, pass `{ req, reply }` instead. + +### `ArgumentsHost` and `ExecutionContext` - return res.status(200).json(body) +Interceptors, guards and exception filters receive an `ArgumentsHost` or an +`ExecutionContext`. Resora accepts either one in place of a response or a +`{ req, res }` context, and reads the request and response from +`switchToHttp()`: + +```ts +import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common' +import { GenericResource } from 'resora' + +@Catch(NotFoundException) +export class NotFoundFilter implements ExceptionFilter { + async catch(exception: NotFoundException, host: ArgumentsHost) { + await new GenericResource({ message: exception.message }) + .response(host) + .setStatusCode(404) + } } ``` +A host can also be passed to the constructor or to `Resource.setCtx()`. + ## Collections and Pagination in Non-Connect Frameworks ```ts diff --git a/docs/guide/server-response.md b/docs/guide/server-response.md index f3242fb..90574cb 100644 --- a/docs/guide/server-response.md +++ b/docs/guide/server-response.md @@ -13,7 +13,7 @@ Binds the internal Resource instance to a framework-specific response object. ### Parameters - `res` - The framework’s native response object (e.g., H3 `res`, Express `res`, Fastify `reply`) or a full context object containing `{ req, res }`. + The framework’s native response object (e.g., H3 `res`, Express `res`, Fastify `reply`, Next.js API `res`), a full context object containing `{ req, res }`, or a NestJS `ArgumentsHost` / `ExecutionContext`. > For Express and other frameworks implementing the connect-style middleware, this can be skipped, in which case Resora will use the response object provided in the `Resource` or `ResourceCollection` constructor. > @@ -150,6 +150,21 @@ The snapshot runs `beforeSend` plugin hooks so it represents the final state an adapter should dispatch. It does not write to the raw response or run `afterSend`. +## Web `Response` + +Handlers that return a Web `Response`, such as Next.js App Router route +handlers, can call `toWebResponse()`: + +```ts +export async function GET() { + return new UserResource(await findUser()) + .response() + .setStatusCode(200) + .header('X-Resource', 'user') + .toWebResponse(); +} +``` + ## Execution Model When `.response()` is used: @@ -175,6 +190,9 @@ Transport state and payload state are intentionally separated. - H3 (native Fetch-style) - Express - Fastify (`reply`, or a `{ req, reply }` context) +- Next.js Pages Router API routes (`res`, or a `{ req, res }` context) +- Next.js App Router route handlers (through `toWebResponse()`) +- NestJS (`@Res()` response, or an `ArgumentsHost` / `ExecutionContext`) - Connect-style middleware frameworks - Any framework exposing a mutable response object diff --git a/docs/more/changelog.md b/docs/more/changelog.md index 43bce3d..3ec6f67 100644 --- a/docs/more/changelog.md +++ b/docs/more/changelog.md @@ -17,6 +17,9 @@ The format follows semantic versioning principles. - Added direct support for Arkormˣ Models and Collections across `ResourceCollection` and `GenericResource` flows. - Added direct support for Fastify replies in `withResponse()`, `.response()`, and `ServerResponse`, including clear-router's `{ req, reply }` context. +- Added direct support for Next.js Pages Router API responses in `withResponse()`, `.response()`, and `ServerResponse`. +- Added `ServerResponse.toWebResponse()` to build a JSON Web `Response`, for Next.js App Router route handlers. +- Added direct support for NestJS in `withResponse()`, `.response()`, and `ServerResponse`, including an `ArgumentsHost` or `ExecutionContext` as the response or context. ### Fixed @@ -32,7 +35,6 @@ The format follows semantic versioning principles. ### Planned - Plugin System for lifecycle extension without core modification -- Direct support for Next.Js API and Nest.JS response objects in `withResponse()`, `.response()`, and `ServerResponse` class. - Built-in support for Arkormˣ models for seamless transformation of Arkormˣ entities. ## [0.2.1] - [0.2.2] - Patch Release Series diff --git a/docs/more/roadmap.md b/docs/more/roadmap.md index 2b2070f..ecb02b5 100644 --- a/docs/more/roadmap.md +++ b/docs/more/roadmap.md @@ -104,7 +104,7 @@ Add direct support for NextJs API and NestJS response objects in: This will enable first-class integration without requiring custom wrappers. -🔲 Implemented +✅ Implemented ## Built in support for Arkormˣ diff --git a/src/BaseSerializer.ts b/src/BaseSerializer.ts index 2728add..5bce003 100644 --- a/src/BaseSerializer.ts +++ b/src/BaseSerializer.ts @@ -8,6 +8,7 @@ import { import { extractResponseFromCtx, getCtx, + isNestHttpHost, getGlobalCase, getGlobalResponseStructure, loadRuntimeConfig, @@ -191,6 +192,10 @@ export abstract class BaseSerializer { * @returns */ protected resolveRawResponse (response?: TRawResponse): TRawResponse | undefined { + if (isNestHttpHost(response)) { + return extractResponseFromCtx(response) as TRawResponse + } + if (typeof response !== 'undefined') { return response } diff --git a/src/GenericResource.ts b/src/GenericResource.ts index 468a460..1b19a88 100644 --- a/src/GenericResource.ts +++ b/src/GenericResource.ts @@ -3,6 +3,8 @@ import { CollectionLike, Collectible, FastifyReplyLike, + NestHttpHostLike, + NextApiResponseLike, GenericBody, MetaData, NonCollectible, @@ -53,7 +55,7 @@ export class GenericResource< public collects?: typeof Resource protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] | FastifyReplyLike + raw: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike } constructor(rsc: R, ctx?: Response | H3Event | Record) { @@ -475,6 +477,8 @@ export class GenericResource< response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> response(res: FastifyReplyLike): ServerResponse> + response(res: NextApiResponseLike): ServerResponse> + response(host: NestHttpHostLike): ServerResponse> /** * Build a response object, writing to the provided raw response if possible. @@ -482,8 +486,8 @@ export class GenericResource< * @param res * @returns */ - response(res?: Response | H3Event['res'] | FastifyReplyLike): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike + response(res?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike | NestHttpHostLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike return this.runResponse({ ensureJson: () => this.json(), @@ -512,7 +516,7 @@ export class GenericResource< */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] | FastifyReplyLike + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike ): any { return this } @@ -533,7 +537,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -568,7 +572,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -600,7 +604,7 @@ export class GenericResource< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/Resource.ts b/src/Resource.ts index b89e1a2..ceeea71 100644 --- a/src/Resource.ts +++ b/src/Resource.ts @@ -3,6 +3,8 @@ import { CollectionLike, Collectible, FastifyReplyLike, + NestHttpHostLike, + NextApiResponseLike, MetaData, NonCollectible, PaginatorLike, @@ -45,7 +47,7 @@ export class Resource ex public resource: R protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] | FastifyReplyLike + raw: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike } constructor(rsc: R, ctx?: Response | H3Event | Record) { @@ -370,14 +372,16 @@ export class Resource ex response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> response(res: FastifyReplyLike): ServerResponse> + response(res: NextApiResponseLike): ServerResponse> + response(host: NestHttpHostLike): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike + response(res?: H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike | NestHttpHostLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike return this.runResponse({ ensureJson: () => this.json(), @@ -406,7 +410,7 @@ export class Resource ex */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] | FastifyReplyLike + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike ): any { return this } @@ -427,7 +431,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -462,7 +466,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -494,7 +498,7 @@ export class Resource ex await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/ResourceCollection.ts b/src/ResourceCollection.ts index b9e8bcb..b4c4c7f 100644 --- a/src/ResourceCollection.ts +++ b/src/ResourceCollection.ts @@ -2,6 +2,8 @@ import type { H3Event } from 'h3' import { CollectionLike, FastifyReplyLike, + NestHttpHostLike, + NextApiResponseLike, ResourceData, Collectible, CollectionBody, @@ -52,7 +54,7 @@ export class ResourceCollection< public collects?: typeof Resource protected withResponseContext?: { response: ServerResponse> - raw: Response | H3Event['res'] | FastifyReplyLike + raw: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike } /** @@ -523,14 +525,16 @@ export class ResourceCollection< response(res: H3Event['res']): ServerResponse> response(res: Response): ServerResponse> response(res: FastifyReplyLike): ServerResponse> + response(res: NextApiResponseLike): ServerResponse> + response(host: NestHttpHostLike): ServerResponse> /** * Build a response object, optionally accepting a raw response to mutate in withResponse. * * @param res Optional raw response object (e.g. Express Response or H3Event res) * @returns */ - response(res?: H3Event['res'] | Response | FastifyReplyLike): ServerResponse> { - const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike + response(res?: H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike | NestHttpHostLike): ServerResponse> { + const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike return this.runResponse({ ensureJson: () => this.json(), @@ -559,7 +563,7 @@ export class ResourceCollection< */ withResponse( _response?: ServerResponse>, - _rawResponse?: Response | H3Event['res'] | FastifyReplyLike + _rawResponse?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike ): any { return this } @@ -586,7 +590,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -621,7 +625,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { @@ -653,7 +657,7 @@ export class ResourceCollection< await this.jsonAsync() }, body: () => this.body, - rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike, + rawResponse: this.resolveRawResponse(this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike, createServerResponse: (raw, body) => { const response = new ServerResponse(raw as never, body) this.withResponseContext = { diff --git a/src/ServerResponse.ts b/src/ServerResponse.ts index 7be3f43..f266108 100644 --- a/src/ServerResponse.ts +++ b/src/ServerResponse.ts @@ -1,4 +1,4 @@ -import { Collectible, FastifyReplyLike, NonCollectible, ResourceData } from './types' +import { Collectible, FastifyReplyLike, NextApiResponseLike, NonCollectible, ResourceData } from './types' import type { H3Event } from 'h3' import type { Response } from 'express' @@ -13,7 +13,7 @@ export interface ServerResponseData { /** * ServerResponse class to handle HTTP response construction and sending, compatible - * with Express, H3 and Fastify response objects. + * with Express, H3, Fastify and Next.js API response objects. * * @author Legacy (3m1n3nc3) * @since 0.1.0 @@ -34,8 +34,12 @@ export class ServerResponse< constructor(response: H3Event['res'], body: R) constructor(response: Response, body: R) constructor(response: FastifyReplyLike, body: R) + constructor(response: NextApiResponseLike, body: R) constructor(response: undefined, body: R) - constructor(private response: Response | H3Event['res'] | FastifyReplyLike | undefined, private body: R) { } + constructor( + private response: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike | undefined, + private body: R + ) { } /** * Set the HTTP status code for the response @@ -192,6 +196,30 @@ export class ServerResponse< } } + /** + * Build a Web `Response` from the finalized response state, for handlers that + * return one, such as Next.js App Router route handlers. + * + * @returns A JSON Web `Response` carrying the body, status and headers + */ + async toWebResponse(): Promise { + if (this.bodyResolver) { + const resolver = this.bodyResolver + this.bodyResolver = undefined + + this.setBody(await resolver()) + } + + const { body, status, statusText, headers } = this.toResponseData() + const webHeaders = new Headers(headers) + + if (!webHeaders.has('Content-Type')) { + webHeaders.set('Content-Type', 'application/json') + } + + return new globalThis.Response(JSON.stringify(body), { status, statusText, headers: webHeaders }) + } + /** * Dispatch the current body and apply any deferred transport state. * diff --git a/src/types/common.ts b/src/types/common.ts index 94b33e8..5938133 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -60,3 +60,25 @@ export interface FastifyReplyLike { headersSent?: boolean } } + +/** + * @description The parts of a Next.js Pages Router API `res` (`NextApiResponse`) that Resora uses to dispatch a response. + */ +export interface NextApiResponseLike { + status (statusCode: number): unknown + setHeader (name: string, value: any): unknown + send (body: any): void + statusCode: number + statusMessage?: string + headersSent: boolean +} + +/** + * @description The parts of a NestJS `ArgumentsHost` or `ExecutionContext` that Resora uses to reach the HTTP request and response. + */ +export interface NestHttpHostLike { + switchToHttp (): { + getRequest (): T + getResponse (): T + } +} diff --git a/src/utilities/state.ts b/src/utilities/state.ts index a5ad55f..48de2be 100644 --- a/src/utilities/state.ts +++ b/src/utilities/state.ts @@ -1,4 +1,4 @@ -import { CaseStyle, Config, ResponseFactory, ResponseStructureConfig } from '../types' +import { CaseStyle, Config, NestHttpHostLike, ResponseFactory, ResponseStructureConfig } from '../types' import { AsyncLocalStorage } from 'node:async_hooks' let globalPreferredCase: CaseStyle | undefined @@ -314,6 +314,10 @@ export const extractRequestUrl = (ctx: unknown): string | undefined => { return typeof ctx === 'string' ? ctx : undefined } + if (isNestHttpHost(ctx)) { + return extractUrlFromRequest(ctx.switchToHttp().getRequest()) + } + const obj = ctx as Record // Context with `req` property ({ req, res } from Express middleware or H3 HTTPEvent) @@ -352,6 +356,15 @@ const extractUrlFromRequest = (req: Record): string | undefined => return undefined } +/** + * Checks whether a value is a NestJS `ArgumentsHost` or `ExecutionContext`. + * + * @param ctx The value to check. + */ +export const isNestHttpHost = (ctx: unknown): ctx is NestHttpHostLike => { + return Boolean(ctx) && typeof (ctx as NestHttpHostLike).switchToHttp === 'function' +} + /** * Extracts the response object from an HTTP context. * @@ -367,6 +380,10 @@ export const extractResponseFromCtx = (ctx: unknown): any | undefined => { return undefined } + if (isNestHttpHost(ctx)) { + return ctx.switchToHttp().getResponse() + } + const obj = ctx as Record // Hono-style context exposes response mutation helpers directly. diff --git a/tests/nestjs.spec.ts b/tests/nestjs.spec.ts new file mode 100644 index 0000000..59d9926 --- /dev/null +++ b/tests/nestjs.spec.ts @@ -0,0 +1,132 @@ +import Fastify from 'fastify' +import { NestHttpHostLike, Resource, ResourceCollection, ServerResponse } from 'src' +import { afterEach, describe, expect, it } from 'vitest' + +import express from 'express' +import request from 'parasito' + +/** + * Builds a host shaped like NestJS's `ExecutionContextHost` for an HTTP request. + */ +const httpHost = (req: unknown, res: unknown): NestHttpHostLike => ({ + switchToHttp: () => ({ + getRequest: () => req as T, + getResponse: () => res as T, + }), +}) + +describe('NestJS', () => { + afterEach(() => { + Resource.setCtx(undefined) + ResourceCollection.setCtx(undefined) + }) + + describe('Express platform', () => { + it('should dispatch through the response from an ArgumentsHost', async () => { + const app = express() + app.get('/users/:id', async (req, res) => { + await new Resource({ id: 1 }, httpHost(req, res)) + .response() + .setStatusCode(201) + .header('X-Resource', 'user') + }) + + const response = await request(app).get('/users/1') + + expect(response.status).toEqual(201) + expect(response.headers.get('x-resource')).toEqual('user') + expect(response.body).toEqual({ data: { id: 1 } }) + }) + + it('should accept an ArgumentsHost in .response()', async () => { + const app = express() + app.get('/test', async (req, res) => { + await new Resource({ id: 1 }).response(httpHost(req, res)).setStatusCode(202) + }) + + await request(app).get('/test').expect(202).expect({ data: { id: 1 } }) + }) + + it('should use an ArgumentsHost set as the global context', async () => { + const app = express() + app.get('/test', async (req, res) => { + Resource.setCtx(httpHost(req, res)) + + await new Resource({ id: 1 }).response().setStatusCode(202) + }) + + await request(app).get('/test').expect(202).expect({ data: { id: 1 } }) + }) + + it('should include pagination meta with the request path', async () => { + const app = express() + app.get('/users', async (req, res) => { + await new ResourceCollection({ + data: [{ id: 1 }], + pagination: { currentPage: 1, total: 10 }, + }, httpHost(req, res)).response() + }) + + const response = await request(app).get('/users') + + expect(response.body).toEqual({ + data: [{ id: 1 }], + meta: { + current_page: 1, + total: 10, + path: '/users?page=1', + }, + }) + }) + + it('should pass the platform response to the withResponse hook', async () => { + let raw: unknown + + class CustomResource extends Resource { + withResponse(response: ServerResponse, rawResponse?: unknown) { + raw = rawResponse + response.header('X-From-Hook', '1') + } + } + + const app = express() + let expected: unknown + app.get('/test', async (req, res) => { + expected = res + + await new CustomResource({ id: 1 }, httpHost(req, res)).response() + }) + + const response = await request(app).get('/test') + + expect(raw).toBe(expected) + expect(response.headers.get('x-from-hook')).toEqual('1') + }) + }) + + describe('Fastify platform', () => { + it('should dispatch through the reply from an ArgumentsHost', async () => { + const app = Fastify() + app.get('/users', async (req, reply) => { + await new ResourceCollection({ + data: [{ id: 1 }], + pagination: { currentPage: 1, total: 10 }, + }, httpHost(req, reply)).response().setStatusCode(201) + + return reply + }) + + const response = await app.inject().get('/users') + + expect(response.statusCode).toEqual(201) + expect(response.json()).toEqual({ + data: [{ id: 1 }], + meta: { + current_page: 1, + total: 10, + path: '/users?page=1', + }, + }) + }) + }) +}) diff --git a/tests/nextjs.spec.ts b/tests/nextjs.spec.ts new file mode 100644 index 0000000..114dab2 --- /dev/null +++ b/tests/nextjs.spec.ts @@ -0,0 +1,151 @@ +import type { IncomingMessage, ServerResponse as NodeResponse } from 'node:http' +import { NextApiResponseLike, Resource, ResourceCollection, ServerResponse } from 'src' +import { beforeEach, describe, expect, it } from 'vitest' + +import request from 'parasito' + +type NextApiResponse = NodeResponse & NextApiResponseLike & { json (body: any): void } + +/** + * Wraps a handler the way Next.js Pages Router API routes do, adding + * `status()`, `send()` and `json()` to the Node response. + */ +const nextApi = (handler: (req: IncomingMessage, res: NextApiResponse) => unknown) => { + return async (req: IncomingMessage, raw: NodeResponse) => { + const res = raw as NextApiResponse + + res.status = (statusCode: number) => { + res.statusCode = statusCode + + return res + } + res.json = (body: any) => { + res.setHeader('Content-Type', 'application/json; charset=utf-8') + res.end(JSON.stringify(body)) + } + res.send = (body: any) => { + if (typeof body === 'object' && body !== null) { + return res.json(body) + } + + res.end(String(body)) + } + + await handler(req, res) + } +} + +describe('Next.js Pages Router API routes', () => { + it('should output correct JSON response', async () => { + const resource = { id: 1, name: 'Test Resource' } + const app = nextApi(async (_req, res) => { + await new Resource(resource, res).response() + }) + + await request(app).get('/api/test').expect(200).expect({ data: resource }) + }) + + it('should use the res from a { req, res } context', async () => { + const app = nextApi(async (req, res) => { + await new Resource({ id: 1 }, { req, res }).response().setStatusCode(201) + }) + + await request(app).get('/api/test').expect(201).expect({ data: { id: 1 } }) + }) + + it('should allow setting status code, headers and cookies', async () => { + const app = nextApi(async (_req, res) => { + await new Resource({ id: 1 }) + .response(res) + .setStatusCode(202) + .header('X-Custom-Header', 'CustomValue') + .setCookie('testCookie', 'testValue', { path: '/' }) + }) + + const response = await request(app).get('/api/test') + + expect(response.status).toEqual(202) + expect(response.headers.get('x-custom-header')).toEqual('CustomValue') + expect(response.headers.get('set-cookie')).toContain('testCookie=testValue;') + }) + + it('should include pagination meta with the request path', async () => { + const app = nextApi(async (req, res) => { + await new ResourceCollection({ + data: [{ id: 1 }], + pagination: { currentPage: 1, total: 10 }, + }, { req, res }).response() + }) + + const response = await request(app).get('/api/users') + + expect(response.body).toEqual({ + data: [{ id: 1 }], + meta: { + current_page: 1, + total: 10, + path: '/api/users?page=1', + }, + }) + }) + + it('should pass the res to the withResponse hook', async () => { + class CustomResource extends Resource { + withResponse(response: ServerResponse, raw?: NextApiResponseLike) { + response.header('X-From-Hook', String(raw?.headersSent)).setStatusCode(202) + } + } + + const app = nextApi(async (_req, res) => { + await new CustomResource({ id: 1 }, res).response() + }) + + const response = await request(app).get('/api/test') + + expect(response.status).toEqual(202) + expect(response.headers.get('x-from-hook')).toEqual('false') + }) +}) + +describe('Next.js App Router route handlers', () => { + beforeEach(() => { + Resource.setCtx(undefined) + ResourceCollection.setCtx(undefined) + }) + + it('should build a Web Response with status and headers', async () => { + const response = await new Resource({ id: 1 }) + .response() + .setStatusCode(201) + .header('X-Resource', 'user') + .toWebResponse() + + expect(response).toBeInstanceOf(Response) + expect(response.status).toEqual(201) + expect(response.headers.get('content-type')).toEqual('application/json') + expect(response.headers.get('x-resource')).toEqual('user') + expect(await response.json()).toEqual({ data: { id: 1 } }) + }) + + it('should resolve async data before building the Web Response', async () => { + class AsyncResource extends Resource { + async data() { + return { id: this.resource.id, loaded: true } + } + } + + const response = await new AsyncResource({ id: 2 }).response().toWebResponse() + + expect(await response.json()).toEqual({ data: { id: 2, loaded: true } }) + }) + + it('should keep a custom content type', async () => { + const response = await new ResourceCollection([{ id: 1 }]) + .response() + .header('Content-Type', 'application/vnd.api+json') + .toWebResponse() + + expect(response.headers.get('content-type')).toEqual('application/vnd.api+json') + expect(await response.json()).toEqual({ data: [{ id: 1 }] }) + }) +})