nestjs-endpoints v1.0.7
nestjs-endpoints
Introduction
nestjs-endpoints is a lightweight tool for writing clean and succinct HTTP APIs with NestJS that encourages the REPR design pattern, code colocation, and the Single Responsibility Principle.
It's inspired by the Fast Endpoints .NET library, tRPC, and Next.js' file-based routing.
An endpoint can be as simple as this:
src/hello-world.endpoint.ts
export default endpoint({
handler: () => 'Hello, World!',
});
❯ curl 'http://localhost:3000/hello-world'
Hello, World!%
Features
- Easy setup: Automatically scans your entire project for endpoint files and loads them.
- Stable: Produces regular NestJS Controllers under the hood.
- File-based routing: Each endpoint's HTTP path is based on their path on disk.
- User-Friendly API: Supports both basic and advanced per-endpoint configuration.
- Schema validation: Compile and run-time validation of input and output values using Zod schemas.
- HTTP adapter agnostic: Works with both Express and Fastify NestJS applications.
- Client SDK codegen: Annotates endpoints using
@nestjs/swagger
and nestjs-zod internally to output an OpenAPI document which orval can use to generate a client library.
Getting Started
Installation
npm install nestjs-endpoints @nestjs/swagger zod
Setup
src/app.module.ts
import { EndpointsRouterModule } from 'nestjs-endpoints';
@Module({
imports: [
EndpointsRouterModule.forRoot({
rootDirectory: './endpoints',
providers: [DbService], // available to all endpoints
}),
],
})
export class AppModule {}
Basic Usage
src/endpoints/user/find.endpoint.ts
import { endpoint, z } from 'nestjs-endpoints';
export default endpoint({
input: z.object({
// GET endpoints use query params for input,
// so we need to coerce the string to a number
id: z.coerce.number(),
}),
output: z
.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
.nullable(),
inject: {
db: DbService,
},
// The handler's parameters are fully typed, and its
// return value is type-checked against the output schema
handler: ({ input, db }) => db.user.find(input.id),
});
src/endpoints/user/create.endpoint.ts
import { endpoint, z } from 'nestjs-endpoints';
export default endpoint({
method: 'post',
input: z.object({
name: z.string(),
email: z.string().email(),
}),
output: z.object({
id: z.number(),
}),
inject: {
db: DbService,
},
handler: async ({ input, db }) => {
const user = await db.user.create(input);
return {
id: user.id,
// Removed during zod validation
extra: 'This will be stripped',
};
},
});
You call the above using:
❯ curl 'http://localhost:3000/user/find?id=1'
null%
# bad input
❯ curl -s -X 'POST' 'http://localhost:3000/user/create' \
-H 'Content-Type: application/json' \
-d '{"name": "Art", "emailTYPO": "art@vandelayindustries.com"}' | jq
{
"statusCode": 400,
"message": "Validation failed",
"errors": [
{
"code": "invalid_type",
"expected": "string",
"received": "undefined",
"path": [
"email"
],
"message": "Required"
}
]
}
# success
❯ curl -X 'POST' 'http://localhost:3000/user/create' \
-H 'Content-Type: application/json' \
-d '{"name": "Art", "email": "art@vandelayindustries.com"}'
{"id":1}%
File-based routing
HTTP paths for endpoints are derived from the file's path on disk:
rootDirectory
is removed from the start- Path segments that begin with an underscore (
_
) are removed - Filenames must either end in
.endpoint.ts
or beendpoint.ts
js
,cjs
,mjs
,mts
are also supported.- Route parameters are not suported (
user/:userId
)
Examples (assume rootDirectory
is ./endpoints
):
src/endpoints/user/find-all.endpoint.ts
->user/find-all
src/endpoints/user/_mutations/create/endpoint.ts
->user/create
Note: Bundled projects via Webpack or similar are not supported.
Advanced Usage
Depending on the project's requirements, the above should ideally suffice most of the time. In case you need access to more of NestJS' features like Interceptors, Guards, access to the request object, etc, or if you'd rather have isolated NestJS modules per feature with their own providers, here is a more complete example:
Note: You are also welcome to use both NestJS Controllers and endpoints in the same project.
src/app.module.ts
import { EndpointsRouterModule } from 'nestjs-endpoints';
@Module({
imports: [
EndpointsRouterModule.forRoot({
rootDirectory: './',
autoLoadEndpoints: false, // manually load endpoints
}),
],
})
export class AppModule {}
src/user/user.module.ts
import { EndpointsModule } from 'nestjs-endpoints';
import create from './appointment/_endpoints/create/endpoint';
@EndpointsModule({
endpoints: [create],
providers: [
DbService,
{
provide: AppointmentRepositoryToken,
useClass: AppointmentRepository as IAppointmentRepository,
},
],
})
export class UserModule {}
src/user/appointment/_endpoints/create/endpoint.ts
import { Inject, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { decorated, endpoint, schema, z } from 'nestjs-endpoints';
export default endpoint({
method: 'post',
summary: 'Create an appointment',
input: z.object({
userId: z.number(),
date: z.coerce.date(),
}),
output: {
201: schema(
z.object({
id: z.number(),
date: z.date().transform((date) => date.toISOString()),
address: z.string(),
}),
{
description: 'Appointment created',
},
),
400: z.union([
z.string(),
z.object({
message: z.string(),
errorCode: z.string(),
}),
]),
},
decorators: [UseGuards(AuthGuard)],
inject: {
db: DbService,
appointmentsRepository: decorated<IAppointmentRepository>(
Inject(AppointmentRepositoryToken),
),
},
injectMethod: {
req: decorated<Request>(Req()),
},
handler: async ({
input,
db,
appointmentsRepository,
req,
response,
}) => {
const user = await db.find(input.userId);
if (!user) {
// Need to use response fn when multiple output status codes
// are defined
return response(400, 'User not found');
}
if (await appointmentsRepository.hasConflict(input.date)) {
return response(400, {
message: 'Appointment has conflict',
errorCode: 'APPOINTMENT_CONFLICT',
});
}
return response(
201,
await appointmentsRepository.create(
input.userId,
input.date,
req.ip,
),
);
},
});
To call this endpoint:
❯ curl -X 'POST' 'http://localhost:3000/user/appointment/create' \
-H 'Content-Type: application/json' \
-H 'Authorization: secret' \
-d '{"userId": 1, "date": "2021-11-03"}'
{"id":1,"date":"2021-11-03T00:00:00.000Z","address":"::1"}%
OpenAPI, Codegen setup (optional)
It's a common practice to automatically generate a client SDK for your API that you can use in other backend or frontend projects and have the benefit of full-stack type-safety. tRPC and similar libraries have been written to facilitate this.
We can achieve the same here in two steps. We first build an OpenAPI document, then use that document's output with orval:
src/main.ts
import { setupOpenAPI } from 'nestjs-endpoints';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const { document, changed } = await setupOpenAPI(app, {
configure: (builder) => builder.setTitle('My Api'),
outputFile: process.cwd() + '/openapi.json',
});
if (changed) {
void import('orval').then(({ generate }) => generate());
}
await app.listen(3000);
}
And then you could have something like this available:
const { id } = await userCreate({
name: 'Tom',
email: 'tom@gmail.com',
});
Have a look at this test project to see how you might configure orval to generate an axios-based client and here to understand how you would use it.
3 months ago
3 months ago
3 months ago
3 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago
4 months ago