1.0.7 • Published 3 months ago

nestjs-endpoints v1.0.7

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

nestjs-endpoints

PR workflow

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 be endpoint.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.

1.0.7

3 months ago

1.0.6

3 months ago

1.0.5

3 months ago

1.0.4

3 months ago

1.0.3

4 months ago

1.0.2

4 months ago

1.0.1

4 months ago

1.0.0

4 months ago

0.2.2

4 months ago

0.2.1

4 months ago

0.2.0

4 months ago

0.1.4

4 months ago

0.1.3

4 months ago

0.1.2

4 months ago

0.1.1

4 months ago

0.1.0

4 months ago

0.0.7

4 months ago

0.0.6

4 months ago

0.0.5

4 months ago

0.0.4

4 months ago

0.0.3

4 months ago

0.0.2

4 months ago

0.0.1

4 months ago