Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/api/server-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 77 additions & 4 deletions docs/guide/non-connect-frameworks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down
20 changes: 19 additions & 1 deletion docs/guide/server-response.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
>
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand Down
4 changes: 3 additions & 1 deletion docs/more/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/more/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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ˣ

Expand Down
5 changes: 5 additions & 0 deletions src/BaseSerializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import {
extractResponseFromCtx,
getCtx,
isNestHttpHost,
getGlobalCase,
getGlobalResponseStructure,
loadRuntimeConfig,
Expand Down Expand Up @@ -191,6 +192,10 @@ export abstract class BaseSerializer<TResource = any> {
* @returns
*/
protected resolveRawResponse<TRawResponse> (response?: TRawResponse): TRawResponse | undefined {
if (isNestHttpHost(response)) {
return extractResponseFromCtx(response) as TRawResponse
}

if (typeof response !== 'undefined') {
return response
}
Expand Down
18 changes: 11 additions & 7 deletions src/GenericResource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
CollectionLike,
Collectible,
FastifyReplyLike,
NestHttpHostLike,
NextApiResponseLike,
GenericBody,
MetaData,
NonCollectible,
Expand Down Expand Up @@ -53,7 +55,7 @@ export class GenericResource<
public collects?: typeof Resource<T>
protected withResponseContext?: {
response: ServerResponse<GenericBody<R>>
raw: Response | H3Event['res'] | FastifyReplyLike
raw: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike
}

constructor(rsc: R, ctx?: Response | H3Event | Record<string, any>) {
Expand Down Expand Up @@ -475,15 +477,17 @@ export class GenericResource<
response(res: H3Event['res']): ServerResponse<GenericBody<R>>
response(res: Response): ServerResponse<GenericBody<R>>
response(res: FastifyReplyLike): ServerResponse<GenericBody<R>>
response(res: NextApiResponseLike): ServerResponse<GenericBody<R>>
response(host: NestHttpHostLike): ServerResponse<GenericBody<R>>

/**
* Build a response object, writing to the provided raw response if possible.
*
* @param res
* @returns
*/
response(res?: Response | H3Event['res'] | FastifyReplyLike): ServerResponse<GenericBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike
response(res?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike | NestHttpHostLike): ServerResponse<GenericBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike

return this.runResponse({
ensureJson: () => this.json(),
Expand Down Expand Up @@ -512,7 +516,7 @@ export class GenericResource<
*/
withResponse(
_response?: ServerResponse<GenericBody<R>>,
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike
): any {
return this
}
Expand All @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 = {
Expand Down
18 changes: 11 additions & 7 deletions src/Resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
CollectionLike,
Collectible,
FastifyReplyLike,
NestHttpHostLike,
NextApiResponseLike,
MetaData,
NonCollectible,
PaginatorLike,
Expand Down Expand Up @@ -45,7 +47,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
public resource: R
protected withResponseContext?: {
response: ServerResponse<ResourceBody<R>>
raw: Response | H3Event['res'] | FastifyReplyLike
raw: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike
}

constructor(rsc: R, ctx?: Response | H3Event | Record<string, any>) {
Expand Down Expand Up @@ -370,14 +372,16 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
response(res: H3Event['res']): ServerResponse<ResourceBody<R>>
response(res: Response): ServerResponse<ResourceBody<R>>
response(res: FastifyReplyLike): ServerResponse<ResourceBody<R>>
response(res: NextApiResponseLike): ServerResponse<ResourceBody<R>>
response(host: NestHttpHostLike): ServerResponse<ResourceBody<R>>
/**
* 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<ResourceBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike
response(res?: H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike | NestHttpHostLike): ServerResponse<ResourceBody<R>> {
const rawResponse = this.resolveRawResponse(res ?? this.res) as H3Event['res'] | Response | FastifyReplyLike | NextApiResponseLike

return this.runResponse({
ensureJson: () => this.json(),
Expand Down Expand Up @@ -406,7 +410,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> ex
*/
withResponse(
_response?: ServerResponse<ResourceBody<R>>,
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike
_rawResponse?: Response | H3Event['res'] | FastifyReplyLike | NextApiResponseLike
): any {
return this
}
Expand All @@ -427,7 +431,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down Expand Up @@ -462,7 +466,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down Expand Up @@ -494,7 +498,7 @@ export class Resource<R extends ResourceData | NonCollectible = ResourceData> 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 = {
Expand Down
Loading
Loading