Skip to content

Commit ab4dd27

Browse files
dodiegoGabrola
andauthored
feat(next): Add support for creating single url routes (#503)
Co-authored-by: Youssef Gaber <1728215+Gabrola@users.noreply.github.com>
1 parent c7e05d8 commit ab4dd27

4 files changed

Lines changed: 295 additions & 21 deletions

File tree

.changeset/olive-balloons-nail.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ts-rest/next': minor
3+
---
4+
5+
Add support for creating single url routes in Next.js

apps/docs/docs/next.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,26 @@ export default createNextRouter(contract, router);
4040

4141
`createNextRouter` is a function that takes a contract and a complete router, and creates endpoints, with the correct methods, paths and callbacks.
4242

43+
## Single Route Handler
44+
45+
It's also possible to create a handler for a single contract route
46+
47+
```typescript
48+
// pages/api/posts/index.tsx
49+
import { createSingleRouteHandler } from '@ts-rest/next';
50+
51+
export default createSingleRouteHandler(api.posts.createPost, async (args) => {
52+
const newPost = await posts.createPost(args.body);
53+
54+
return {
55+
status: 201,
56+
body: newPost,
57+
};
58+
});
59+
```
60+
61+
`createSingleRouteHandler` is a function that takes a single contract route and a handler, and creates a `nextApiHandler`
62+
4363
### JSON Query Parameters
4464

4565
To handle JSON query parameters, you can use the `jsonQuery` option.

libs/ts-rest/next/src/lib/ts-rest-next.spec.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from 'next';
33
import {
44
createNextRoute,
55
createNextRouter,
6+
createSingleRouteHandler,
67
RequestValidationError,
78
} from './ts-rest-next';
89
import { z } from 'zod';
@@ -41,6 +42,9 @@ const contract = c.router({
4142
responses: {
4243
200: c.response<{ id: string; test: string }>(),
4344
},
45+
pathParams: z.object({
46+
id: z.string(),
47+
}),
4448
},
4549
getZodQuery: {
4650
method: 'GET',
@@ -437,6 +441,168 @@ describe('createNextRouter', () => {
437441
});
438442
});
439443

444+
describe('createSingleUrlNextRouter', () => {
445+
beforeEach(() => {
446+
jest.clearAllMocks();
447+
});
448+
449+
it('should send back a 200', async () => {
450+
const resultingRouter = createSingleRouteHandler(
451+
contract.getWithParams,
452+
nextEndpoint.getWithParams,
453+
);
454+
455+
const req = mockSingleUrlReq('/test/123', {
456+
method: 'GET',
457+
query: { id: '123' },
458+
});
459+
460+
await resultingRouter(req, mockRes);
461+
462+
expect(mockRes.status).toHaveBeenCalledWith(200);
463+
expect(jsonMock).toHaveBeenCalledWith({
464+
id: '123',
465+
});
466+
});
467+
468+
it('should send back a 404', async () => {
469+
const resultingRouter = createSingleRouteHandler(
470+
contract.getWithParams,
471+
nextEndpoint.getWithParams,
472+
);
473+
474+
const req = mockSingleUrlReq('/wrong-url', {
475+
method: 'GET',
476+
});
477+
478+
await resultingRouter(req, mockRes);
479+
480+
expect(mockRes.status).toHaveBeenCalledWith(404);
481+
expect(jsonMock).not.toHaveBeenCalled();
482+
});
483+
484+
it('should send back a 404', async () => {
485+
const resultingRouter = createSingleRouteHandler(
486+
contract.getWithParams,
487+
nextEndpoint.getWithParams,
488+
);
489+
490+
const req = mockSingleUrlReq('/test', {
491+
method: 'GET',
492+
});
493+
494+
await resultingRouter(req, mockRes);
495+
496+
expect(mockRes.status).toHaveBeenCalledWith(404);
497+
expect(jsonMock).not.toHaveBeenCalled();
498+
});
499+
500+
it('should send body, params and query correctly', async () => {
501+
const resultingRouter = createSingleRouteHandler(
502+
contract.advanced,
503+
nextEndpoint.advanced,
504+
);
505+
506+
const req = mockSingleUrlReq('/advanced/123', {
507+
method: 'POST',
508+
body: {
509+
test: 'test-body',
510+
},
511+
query: { id: '123' },
512+
});
513+
514+
await resultingRouter(req, mockRes);
515+
516+
expect(mockRes.status).toHaveBeenCalledWith(200);
517+
expect(jsonMock).toHaveBeenCalledWith({
518+
id: '123',
519+
test: 'test-body',
520+
});
521+
});
522+
523+
it('should send json query correctly', async () => {
524+
const resultingRouter = createSingleRouteHandler(
525+
contract.getWithQuery,
526+
nextEndpoint.getWithQuery,
527+
{ jsonQuery: true },
528+
);
529+
530+
const req = mockSingleUrlReq('/test-query', {
531+
method: 'GET',
532+
query: { test: '"test-query-string"', foo: '123' },
533+
});
534+
535+
await resultingRouter(req, mockRes);
536+
537+
expect(mockRes.status).toHaveBeenCalledWith(200);
538+
expect(jsonMock).toHaveBeenCalledWith({
539+
test: 'test-query-string',
540+
foo: 123,
541+
});
542+
});
543+
544+
it('should differentiate between /test and /test/id', async () => {
545+
const resultingRouter = createSingleRouteHandler(
546+
contract.getWithParams,
547+
nextEndpoint.getWithParams,
548+
);
549+
550+
const req = mockSingleUrlReq('/test/3', {
551+
method: 'GET',
552+
query: { id: '3' },
553+
});
554+
555+
await resultingRouter(req, mockRes);
556+
557+
expect(mockRes.status).toHaveBeenCalledWith(200);
558+
expect(jsonMock).toHaveBeenCalledWith({
559+
id: '3',
560+
});
561+
});
562+
563+
describe('response validation', () => {
564+
it('should include default value and removes extra field', async () => {
565+
const resultingRouter = createSingleRouteHandler(
566+
contract.getZodQuery,
567+
nextEndpoint.getZodQuery,
568+
{ responseValidation: true },
569+
);
570+
571+
const req = mockSingleUrlReq('/test/123/name', {
572+
method: 'GET',
573+
query: { field: 'foo', id: '123', name: 'name' },
574+
});
575+
576+
await resultingRouter(req, mockRes);
577+
578+
expect(mockRes.status).toHaveBeenCalledWith(200);
579+
expect(jsonMock).toHaveBeenCalledWith({
580+
id: 123,
581+
name: 'name',
582+
defaultValue: 'hello world',
583+
});
584+
});
585+
586+
it('fails with invalid field', async () => {
587+
const errorHandler = jest.fn();
588+
const resultingRouter = createSingleRouteHandler(
589+
contract.getZodQuery,
590+
nextEndpoint.getZodQuery,
591+
{ responseValidation: true, errorHandler },
592+
);
593+
594+
const req = mockSingleUrlReq('/test/2000/name', {
595+
method: 'GET',
596+
query: { id: '2000', name: 'name' },
597+
});
598+
599+
await resultingRouter(req, mockRes);
600+
601+
expect(errorHandler).toHaveBeenCalled();
602+
});
603+
});
604+
});
605+
440606
export const mockReq = (
441607
url: string,
442608
args: {
@@ -458,3 +624,17 @@ export const mockReq = (
458624

459625
return req;
460626
};
627+
628+
const mockSingleUrlReq = (
629+
url: string,
630+
args: { query?: Record<string, unknown>; body?: unknown; method: string },
631+
): NextApiRequest => {
632+
const req = {
633+
url,
634+
query: args.query,
635+
body: args.body,
636+
method: args.method,
637+
} as unknown as NextApiRequest;
638+
639+
return req;
640+
};

libs/ts-rest/next/src/lib/ts-rest-next.ts

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ type AppRouterWithImplementation = {
5454
[key: string]: AppRouterWithImplementation | AppRouteWithImplementation<any>;
5555
};
5656

57+
type CreateNextRouterOptions = {
58+
jsonQuery?: boolean;
59+
responseValidation?: boolean;
60+
throwRequestValidation?: boolean;
61+
errorHandler?: (
62+
err: unknown,
63+
req: NextApiRequest,
64+
res: NextApiResponse,
65+
) => void;
66+
};
67+
5768
/**
5869
* Combine all AppRoutes with their implementations into a single object
5970
* which is easier to work with
@@ -186,42 +197,100 @@ export const createNextRoute = <T extends AppRouter>(
186197
export const createNextRouter = <T extends AppRouter>(
187198
routes: T,
188199
obj: RecursiveRouterObj<T>,
189-
options?: {
190-
jsonQuery?: boolean;
191-
responseValidation?: boolean;
192-
throwRequestValidation?: boolean;
193-
errorHandler?: (
194-
err: unknown,
195-
req: NextApiRequest,
196-
res: NextApiResponse,
197-
) => void;
198-
},
200+
options?: CreateNextRouterOptions,
199201
) => {
200-
const {
201-
jsonQuery = false,
202-
responseValidation = false,
203-
throwRequestValidation = false,
204-
} = options || {};
202+
return handlerFactory((req) => {
203+
const combinedRouter = mergeRouterAndImplementation(routes, obj);
205204

206-
const combinedRouter = mergeRouterAndImplementation(routes, obj);
207-
208-
return async (req: NextApiRequest, res: NextApiResponse) => {
205+
// eslint-disable-next-line prefer-const
209206
let { 'ts-rest': params, ...query } = req.query;
210-
params = (params as string[]) || [];
211207

208+
params = (params as string[]) || [];
212209
const route = getRouteImplementation(
213210
combinedRouter,
214211
params,
215212
req.method as string,
216213
);
214+
let pathParams;
215+
if (route) {
216+
pathParams = getPathParamsFromArray(params, route);
217+
} else {
218+
pathParams = {};
219+
}
220+
return { pathParams, query, route };
221+
}, options);
222+
};
217223

224+
/**
225+
* Turn a contract route and a handler into a Next.js compatible handler
226+
* Should be exported from your pages/api/path/to/handler.tsx file.
227+
*
228+
* e.g.
229+
*
230+
* ```typescript
231+
* export default createNextRouter(contract, implementationHandler);
232+
* ```
233+
*
234+
* @param appRoute
235+
* @param options
236+
* @returns
237+
*/
238+
export function createSingleRouteHandler<T extends AppRoute>(
239+
appRoute: AppRoute,
240+
implementationHandler: AppRouteImplementation<T>,
241+
options?: CreateNextRouterOptions,
242+
) {
243+
return handlerFactory((req) => {
244+
const route = { ...appRoute, implementation: implementationHandler };
245+
const urlChunks = req.url!.split('/').slice(1);
246+
const pathParams = getPathParamsFromArray(urlChunks, route);
247+
const query = req.query
248+
? Object.fromEntries(
249+
Object.entries(req.query).filter(
250+
([key]) => pathParams[key] === undefined,
251+
),
252+
)
253+
: {};
254+
255+
const isValidRoute = isRouteCorrect(
256+
appRoute,
257+
urlChunks,
258+
req.method as string,
259+
);
260+
261+
return { pathParams, query, route: isValidRoute ? route : null };
262+
}, options);
263+
}
264+
265+
/**
266+
* Create a next.js compatible handler for a given route
267+
* @param getArgumentsFromRequest
268+
* @param options
269+
* @returns
270+
*/
271+
const handlerFactory = (
272+
getArgumentsFromRequest: (req: NextApiRequest) => {
273+
pathParams: Record<string, string>;
274+
query: NextApiRequest['query'];
275+
route: AppRouterWithImplementation[keyof AppRouterWithImplementation];
276+
},
277+
options?: CreateNextRouterOptions,
278+
) => {
279+
return async (req: NextApiRequest, res: NextApiResponse) => {
280+
const {
281+
jsonQuery = false,
282+
responseValidation = false,
283+
throwRequestValidation = false,
284+
} = options || {};
285+
286+
const args = getArgumentsFromRequest(req);
287+
const { pathParams, route } = args;
288+
let { query } = args;
218289
if (!route) {
219290
res.status(404).end();
220291
return;
221292
}
222293

223-
const pathParams = getPathParamsFromArray(params, route);
224-
225294
const pathParamsResult = checkZodSchema(pathParams, route.pathParams, {
226295
passThroughExtraKeys: true,
227296
});

0 commit comments

Comments
 (0)