npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

nest-standard-response

v1.2.3

Published

Standardized and configurable API responses for NestJS

Downloads

140

Readme

Standardized API responses for NestJS

  • Metadata-based wrapper to provide customizable and standardized API response objects;
  • Built-in handling of pagination, sorting and filtering;
  • Allows route handlers to keep returning classes instead of wrapper objects, so they remain fully compatible with interceptors;
  • Automatic OpenAPI documentation with proper response schema for all features
  • Generation of OpenAPI response examples with proper serialization for each user role

Getting started

🚀   Install

$ npm install nest-standard-response

🔮   Add to your app's imports array

app.module.ts

import { StandardResponseModule } from 'nest-standard-response';

@Module({
  imports: [
    StandardResponseModule.forRoot(options), // options can be ommited
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Check out the options that this module accepts in the Advanced Configuration section.

📦   All routes are now wrapped

By default, all routes are automatically wrapped in a standard response object:

// route returns dtos
@get("/books")
listBooks(): BookDto[] {
  const books = [
    new BookDto({ title: "Dune", year: 1965 }),
    new BookDto({ title: "Jaws", year: 1974 }),
    new BookDto({ title: "Emma", year: 1815 }),
  ];
  return books;
}
// but response is wrapped
{
  success: true,
  isArray: true, // auto infered
  data: [
    { title: "Dune", year: 1965 },
    { title: "Jaws", year: 1974 },
    { title: "Emma", year: 1815 },
  ]
}

To skip wrapping a particular route, just decorate the handler with @RawResponse().

It's possible to invert this behavior to not wrap any route automatically, and only wrap routes annotated with @StandardResponse() instead. Check out how.

🚦   Wrapping only happens at the end of the NestJS' request pipeline

So interceptors like ClassSerializer and RoleSerializer work transparently without any custom logic.

🔥   Add features to your route

Just decorate a route with @StandardResponse({...options}) and pass in the options you want. Adding features will:

  • Automatically prepare a route to receive query parameters for that feature;
  • Parse and validate the input of these query parameters, and make them injectable into the handler;
  • Add fields to the response object to let the client know the state of these features (and to allow discoverability of defaults when the route is called without any query params);
  • Add documentation to Swagger with fully qualified schemas and examples;

To access this information during the request, use the @StandardParam() parameter decorator to inject a params object into your handler. This object contains the parsed query params, all the configuration values you set in the @StandardResponse(), plus methods to manipulate and add data into the response.

// route
@get("/books")
@StandardResponse({ isPaginated: true })
async listBooks(
  @StandardParam() params: StandardParams
): BookDto[] {
  const {
    books,
    count
  } = await this.bookService.list({
    // already validated values safe to use
    limit: params.pagination.limit,
    offset: params.pagination.offset,
  });
  // add extra information into the response
  params.setPaginationInfo({ count: count })
  return books;
}
// response
{
  success: true,
  isArray: true,
  isPaginated: true,
  pagination: {
    limit: 10,
    offset: 0,
    defaultLimit: 10,
    // 👇 added in handler
    count: 33
  },
  data: [
    { title: "Dune", year: 1965 },
    { title: "Jaws", year: 1974 },
    { title: "Emma", year: 1815 },
  ]
}

🎁   Combine features!

Features can be freely combined, or used all at once.

For example, using the features shown bellow, the route could be called like this:

/books?limit=8&offset=16&sort=-author,title&filter=author^=Frank;year>=1960;year>=1970
Note: This url was NOT url-encoded for readability (but you would need to encode yours)

// route
@get("/books")
@StandardResponse({
  // 👇 declare type to get OpenApi docs
  type: [BookDto],
  isPaginated: true,
  defaultLimit: 12,
  maxLimit: 20,
  isSorted: true,
  sortableFields: ["title", "author"],
  isFiltered: true,
  filterableFields: ["author", "year"],
})
async listBooks(
  @StandardParam() params: StandardParams
): BookDto[] {
  const {
    books,
    count
  } = await this.bookService.list({
    limit: params.pagination.limit,
    offset: params.pagination.offset,
    sort: params.sorting.sort,
    filter: params.filtering.filter,
  });
  // 👆 to see how the 'sort' and 'filter'
  // params are parsed, look at the 
  // SortingInfo and FilteringInfo classes
  // in the @StandardParam() section of
  // this document

  // 👇 add extra information into the response
  params.setPaginationInfo({ count: count })
  params.setMessage('A full-featured example!')
  return books;
}


























// response
{
  success: true,
  message: "A full-featured example!",
  isArray: true,
  isPaginated: true,
  isSorted: true,
  isFiltered: true,
  pagination: {
    query: "limit=8&offset=16",
    limit: 8,
    offset: 16,
    defaultLimit: 12,
    maxLimit: 20,
    count: 33
  },
  sorting: {
    sortableFields: ["title", "author"],
    query: "-author,title",
    sort: [
      {
        field: "author",
        order: "des"
      },
      {
        field: "title",
        order: "asc"
      }
    ]
  },
  filtering: {
    filterableFields: ["author", "year"],
    query: "author^=Frank;year>=1960;year>=1970",
    filter: {
      allOf: [
        { anyOf: [
          {
            field: 'author',
            operation: '^=',
            value: "Frank"
          },
        ]},
        { anyOf: [
          {
            field: 'year',
            operation: '>=',
            value: 1960
          },
        ]},
        { anyOf: [
          {
            field: 'year',
            operation: '<=',
            value: 1970
          },
        ]}
      ]
    }
  },
  data: [ ... ]
}

For detailed information on the objects generated by filtering and sorting, as well as a list of all operations available, see the documentation for the @StandardParam() decorator.


Reference

🟠   @StandardResponse(options?: StandardResponseOptions)

A decorator that wraps the return of a route into a standardized API response object (while still allowing the handler to return true DTOs or other model class instances — this makes interceptors like caching, ClassSerializer, or RoleSerializer work transparently.)

The wrapper allows custom messages to be set in the response, and has optional features to handle common tasks, like pagination, sorting and filtering.

It can also optionally apply swagger's documentation, providing the correct combined schema for the DTO and the wrapper including any of its features. If given an array of Roles, it can also build Swagger route response examples for each user role, containing the reponse as it would be serialized for that user group.

import { UserDto } from './dto/user.dto';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
  ) {}

  @Get('/')
  @StandardResponse({ type: [UserDto] })
  async findAll(): Promise<UserDto[]> {
    const users = await this.usersService.findAll();
    return users // <--- returns an array of UserDtos
  }
}

// get /api/users
// Response:
{
  "success": true,
  "isArray": true,
  "data": [
    Users... // <--- The returned array is delivered inside the data property
  ]
}

(TODO image of swagger UI with the response examples dropdown open. Comparing a response for User and Admin, with arrows showcasing the extra fields returned only to admin)

🔸   StandardResponseOptions


🟠   @RawResponse()

The default behavior of StandardResponse is to wrap the response from all routes application wide. This keeps the API consistent and predictable. However, if you need to skip this behavior for a particular route, just set the @RawResponse() decorator:

@Controller('external-api-integration')
export class ExternalApiIntegrationController {
  @Get('/')
  @RawResponse() // <--- will skip wrapping
  async findAll(): Promise<SomeCustomObject> {
    return customObject;
  }
}

If you're adding StandardResponse into an existing app, it might be useful to invert this behavior to create a gradual transition path. To do this, set the interceptAll option to false when importing the StandardResponseModule in your application. This way, routes will only be wrapped if they have explicitly set the @StandardResponse() decorator. See more information in the "Configuring" section bellow.


🟠   @StandardParam()

A parameter decorator used to inject a StandardParams object in the route handler.

This object allows access to:

  • All options set in @StandardResponse();
  • Information captured from query parameters, parsed and validated;
  • Methods to include and modify fields in the response object;
import { UserDto } from './dto/user.dto';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
  ) {}

  @Get('/')
  @StandardResponse({
    type: [UserDto],
    isPaginated: true,
    maxLimit: 24,
    defaultLimit 12,
  })
  async findAll(
    @StandardParam() params: StandardParams // <--- inject into handler
  ): Promise<UserDto[]> {
    const [users, count] = await this.usersService.findAll({
      limit: params.pagination.limit,
      offset: params.pagination.offset,
    });
    params.setPaginationInfo({ count: 348 }) // <--- set additional info
    return users;
  }
}

// get /api/users?limit=15&offset=30
// Response:
{
  "success": true,
  "isArray": true,
  "isPaginated": true,
  "pagination: {
    count: 348, // <--- added inside the handler
    limit: 15, // <--- from query
    offset: 30,
    maxLimit: 24, // <--- from decorator options
    defaultLimit: 12,
  }
  "data": [
    Users...
  ]
}

The params object injected with @StandardParam() contains these keys:

🔸   PaginationInfo

🔸   SortingInfo

🔸   FilteringInfo

🔸   Building the search query

When building a query, all AND operations should be separated by a semicolon (;), and all OR operations should be separed by a comma (,). For example:

This query will filter all books available for lending, which were first published in France OR Italy, between 1970 AND 1999, whose author starts with Vittorio OR ends with Alatri:

available==true;country==France,country==Italy;year>=1970;year<=1999;author=^Vittorio,author=$Alatri

The resulting parsed object from this query will be:

{ allOf: [
  { anyOf: [
    { field: 'available', operation: '==', value: true },
  ]},
  { anyOf: [
    { field: 'country', operation: '==', value: 'France' },
    { field: 'country', operation: '==', value: 'Italy' },
  ]},
  { anyOf: [
    { field: 'year', operation: '>=', value: 1970 },
  ]},
  { anyOf: [
    { field: 'year', operation: '<=', value: 1999 },
  ]},
  { anyOf: [
    { field: 'author', operation: '=^', value: 'Vittorio' },
    { field: 'author', operation: '=$', value: 'Alatri' },
  ]},
]}

🟠   Advanced configuration

✅ validateResponse

Allows you to provide a validation function to stop the return of a route if certain conditions are met.

For example: this can abort a request if a route tries to return — instead a DTO — a raw DB document or some other object that may leak information not intended to be exposed.

This function should return false to abort the request.

@Module({
  imports: [
    StandardResponseModule.forRoot({
      validateResponse: (data) => {
        if (isMongooseObject(data)) return false;
        return true;
      },
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

✅ interceptAll

Setting interceptAll to false will invert the default behavior of wrapping all routes by default, and will instead only wrap routes decorated with @StandardResponse().

@Module({
  imports: [
    StandardResponseModule.forRoot({
      interceptAll: false
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

🚀   TODO Milestones

  • Allow setting any custom field in the repsonse object by exposing a method in the StandardParam: setExtra(field, value);

🏭 ⭐️ 🕹️ 💡 💎 🔩 ⚙️ 🧱 🔮 💈 🛍️ 🎁 🪭 ⚜️ ❇️ 🚩 📦 🏷️ 📮 🟠 🟧 🔶 🔸