Skip to content

Commit

Permalink
feat: add customizable options to HttpResponse and HttpResponseType
Browse files Browse the repository at this point in the history
  • Loading branch information
glebbash committed Mar 29, 2022
1 parent 3052402 commit 5832af2
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 94 deletions.
185 changes: 109 additions & 76 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Typesafe decorators for HTTP endpoints which integrates nicely with [Nest.js](ht
- [typed-http-decorators](#typed-http-decorators)
- [Installation](#installation)
- [Usage](#usage)
- [Customization](#customization)
- [Nest.js integration](#nestjs-integration)
- [Testing](#testing)

Expand Down Expand Up @@ -41,7 +42,7 @@ class ResourceDto {

export class ResourceController {
@Method.Get('resource/:resourceId', {
permissions: ['resource.get'],
permissions: ['resource.get'], // extension
responses: t(Ok.Type(ResourceDto), NotFound.Type(NotFoundDto)),
})
// You can remove return type annotation and still have type safety
Expand All @@ -58,13 +59,6 @@ Specify endpoint decorator logic:

import { setEndpointDecorator } from 'typed-http-decorators';

// You can add additional properties to EndpointOptions like this:
declare module './rest' {
interface EndpointOptions {
permissions: string[];
}
}

setEndpointDecorator((method, path, { permissions }) => (cls, endpointName) => {
// Your endpoint decoration logic:
console.log(
Expand All @@ -74,42 +68,71 @@ setEndpointDecorator((method, path, { permissions }) => (cls, endpointName) => {
});
```

## Customization

You can extend `EndpointOptions` like this:

```ts
declare module 'typed-http-decorators' {
interface EndpointOptions {
permissions: string[];
}
}
```

You can completely change `HttpResponseOptions` by setting the `override` field like this:

```ts
declare module 'typed-http-decorators' {
interface HttpResponseOptionsOverrides {
override: {
description?: string;
};
}
}
```

The original `HttpResponseOptions` is available with `HttpResponseOptionsOverrides["default"]`

You can also override `HttpResponseTypeOptions` in the same way.

## Nest.js integration

Controller example:

```ts
// Source: src/films/films.controller.ts

import { TypedResponseInterceptor } from '../common/typed-response.interceptor'
import { Controller, UseInterceptors } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { Method, Ok, t } from 'typed-http-decorators'
import { FilmsDto } from './dto/films.dto'
import { FilmsService } from './films.service'
import { TypedResponseInterceptor } from '../common/typed-response.interceptor';
import { Controller, UseInterceptors } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Method, Ok, t } from 'typed-http-decorators';
import { FilmsDto } from './dto/films.dto';
import { FilmsService } from './films.service';

@ApiTags('films')
@Controller('films')
/* You need to transform typed responses to the ones accepted by Nest.js */
@UseInterceptors(new TypedResponseInterceptor())
export class FilmsController {
constructor(private films: FilmsService) {}

@Method.Get('', {
summary: 'Get all films',
description: 'Gets all films from the database',
responses: t(Ok.Type(FilmsDto)),
})
async getAllFilms() {
return Ok(
new FilmsDto({
films: [
new FilmDto('id', 'name'),
new FilmDto('id', 'name'),
]
})
)
}
constructor(private films: FilmsService) {}

@Method.Get('', {
summary: 'Get all films', // extension
description: 'Gets all films from the database', // extension
responses: t(
Ok.Type(FilmsDto, {
description: 'A list of films', // extension
})
),
})
async getAllFilms() {
return Ok(
new FilmsDto({
films: [new FilmDto('id', 'name'), new FilmDto('id', 'name')],
})
);
}
}
```

Expand All @@ -118,31 +141,37 @@ Decorator logic:
```ts
// Source: src/common/http-decorators-logic.ts

import { applyDecorators, RequestMapping, RequestMethod } from '@nestjs/common'
import { ApiOperation, ApiResponse } from '@nestjs/swagger'
import { setEndpointDecorator } from 'typed-http-decorators'
import { applyDecorators, RequestMapping, RequestMethod } from '@nestjs/common';
import { ApiOperation, ApiResponse } from '@nestjs/swagger';
import { setEndpointDecorator } from 'typed-http-decorators';

declare module 'typed-http-decorators' {
interface EndpointOptions {
/* This way you force endpoint options to be specified */
summary: string
/* Or you can also make them optional */
description?: string
}
interface EndpointOptions {
/* This way you force endpoint options to be specified */
summary: string;
/* Or you can also make them optional */
description?: string;
}

interface HttpResponseOptionsOverrides {
override: {
description?: string;
};
}
}

setEndpointDecorator((method, path, { responses, summary, description }) =>
applyDecorators(
// Apply Nest.js specific endpoint decorators
RequestMapping({ method: RequestMethod[method], path }),
...responses.map(({ status, bodyType }) =>
ApiResponse({ status, type: bodyType }),
),

// Apply your custom decorators
ApiOperation({ summary, description }),
applyDecorators(
// apply Nest.js specific endpoint decorators
RequestMapping({ method: RequestMethod[method], path }),
...responses.map(({ status, body, options }) =>
ApiResponse({ status, type: body, description: options?.description })
),
)

// extensions are also available
ApiOperation({ summary, description })
)
);
```

Typed responses interceptor:
Expand All @@ -151,27 +180,30 @@ Typed responses interceptor:
// Source: src/common/typed-response.interceptor.ts

import {
CallHandler,
ExecutionContext,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common'
import { catchError, map, throwError } from 'rxjs'
BadRequestException,
CallHandler,
ExecutionContext,
Injectable,
InternalServerErrorException,
NestInterceptor,
} from '@nestjs/common';
import { catchError, map, throwError } from 'rxjs';

@Injectable()
export class TypedResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map(({ status, body }) => {
context.switchToHttp().getResponse().status(status)
return body
}),
catchError(() =>
throwError(() => new InternalServerErrorException()),
),
intercept(context: ExecutionContext, next: CallHandler) {
return next.handle().pipe(
map(({ status, body }) => {
context.switchToHttp().getResponse().status(status);
return body;
}),
catchError((error) =>
throwError(() =>
!(error instanceof BadRequestException) ? new InternalServerErrorException() : error
)
}
)
);
}
}
```

Expand All @@ -183,34 +215,35 @@ Entrypoint:
/*
!!! Remember to have decorator logic as the first import !!!
*/
import './common/http-decorators-logic'
import { NestFactory } from '@nestjs/core'
import { AppModule } from '@/app.module'
import './common/http-decorators-logic';
import { NestFactory } from '@nestjs/core';
import { AppModule } from '@/app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(process.env.PORT ?? 3000)
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap()
bootstrap();
```

Note: If all your endpoints return typed responses
you can apply TypedResponseInterceptor globally instead of applying it to each controller:

```ts
app.useGlobalInterceptors(new TypedResponseInterceptor())
app.useGlobalInterceptors(new TypedResponseInterceptor());
```

## Testing

When testing your controllers you must also import your decorator logic before applying typed-http-decorators.

You can do this automatically with [Jest](https://jestjs.io/):

```jsonc
{
// jest configuration ...
setupFiles: [
'./src/common/http-decorators-logic.ts', // your decorator logic
"setupFiles": [
"./src/common/http-decorators-logic.ts" // your decorator logic
// other setup files ...
]
}
Expand Down
24 changes: 19 additions & 5 deletions src/responses.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { HttpResponse, HttpResponseType, HttpStatus, InstanceOf, Type } from './types';
import {
HttpResponse,
HttpResponseOptions,
HttpResponseType,
HttpResponseTypeOptions,
HttpStatus,
InstanceOf,
Type,
} from './types';

const newResponse = <Status extends number>(status: Status) =>
Object.assign(
<BodyType extends Type>(body: InstanceOf<BodyType>): HttpResponse<Status, BodyType> => {
return { status, body };
<BodyType extends Type>(
body: InstanceOf<BodyType>,
options?: HttpResponseOptions
): HttpResponse<Status, BodyType> => {
return { status, body, options };
},
{
Type: <BodyType extends Type>(body: BodyType): HttpResponseType<Status, BodyType> => {
return { status, body };
Type: <BodyType extends Type>(
body: BodyType,
options?: HttpResponseTypeOptions
): HttpResponseType<Status, BodyType> => {
return { status, body, options };
},
}
);
Expand Down
29 changes: 16 additions & 13 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { OverrideOrDefault } from './utils/overrides';

export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ALL' | 'OPTIONS' | 'HEAD';

export enum HttpStatus {
Expand Down Expand Up @@ -56,28 +58,29 @@ export interface HttpResponseDefaults {
B(): unknown;
}

export type HttpResponseTypeOptions = OverrideOrDefault<HttpResponseTypeOptionsOverrides>;
export interface HttpResponseTypeOptionsOverrides {
default: Record<string, unknown>;
}

export type HttpResponseOptions = OverrideOrDefault<HttpResponseOptionsOverrides>;
export interface HttpResponseOptionsOverrides {
default: Record<string, unknown>;
}

export interface HttpResponseType<
Status extends number = number,
BodyType extends Type<unknown> = Type<unknown>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
A extends ReturnType<HttpResponseDefaults['A']> = ReturnType<HttpResponseDefaults['A']>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
B extends ReturnType<HttpResponseDefaults['B']> = ReturnType<HttpResponseDefaults['B']>
BodyType extends Type<unknown> = Type<unknown>
> {
status: Status;
body: BodyType;
options?: HttpResponseTypeOptions;
}

export interface HttpResponse<
Status extends number,
BodyType extends Type<unknown>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
A extends ReturnType<HttpResponseDefaults['A']> = ReturnType<HttpResponseDefaults['A']>,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
B extends ReturnType<HttpResponseDefaults['B']> = ReturnType<HttpResponseDefaults['B']>
> {
export interface HttpResponse<Status extends number, BodyType extends Type<unknown>> {
status: Status;
body: InstanceOf<BodyType>;
options?: HttpResponseOptions;
}

export type HttpResponseTypes = readonly HttpResponseType[];
Expand Down
3 changes: 3 additions & 0 deletions src/utils/overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type OverrideOrDefault<T extends { default: unknown }> = T extends { override: unknown }
? T['override']
: T['default'];

0 comments on commit 5832af2

Please sign in to comment.