@deepkit-rest/rest-crud

REST API simplified.


Keywords
backend, crud, crud-api, deepkit, library, nodejs, rest, rest-api, restful
License
Apache-2.0
Install
npm install @deepkit-rest/rest-crud@0.1.0

Documentation

Deepkit REST

REST API simplified

npm i \
  @deepkit-rest/http-extension \
  @deepkit-rest/rest-core \
  @deepkit-rest/rest-crud

Overview

DeepKit REST opens up a whole new declarative and extensive approach for developing REST APIs where

  • request information is available also via the dependency injection system instead of only available through controller method parameters.
  • request is parsed dynamically and progressively during the responding process by different services instead of parsed at once by the framework based on the schemas defined ahead of time
  • response body is completely serialized and formed by ourselves

By adopting this approach, we can now create elegant general abstractions for common logic much more easily, and thus we can implement our API like this:

@rest.resource(Book, "books").guardedBy(AuthGuard)
export class BookResource
  implements
    RestResource<Book>,
    RestPaginationCustomizations,
    RestFilteringCustomizations,
    RestSerializationCustomizations<Book>
{
  readonly serializer = BookSerializer;
  readonly paginator = RestOffsetLimitPaginator;
  readonly filters = [RestGenericFilter, RestGenericSorter];

  constructor(
    private database: Database,
    private requestContext: RequestContext,
    private crud: RestCrudKernel<Book>,
  ) {}

  getDatabase(): Database {
    return this.database;
  }

  getQuery(): Query<Book> {
    const userId = this.requestContext.user.id;
    const userRef = this.database.getReference(User, userId);
    return this.database.query(Book).filter({ owner: userRef });
  }

  @rest.action("GET")
  @http.serialization({ groupsExclude: ["internal"] })
  async list(): Promise<Response> {
    return this.crud.list();
  }

  @rest.action("GET", ":pk")
  @http.serialization({ groupsExclude: ["internal"] })
  async retrieve(): Promise<Response> {
    return this.crud.retrieve();
  }
}

As an inevitable result, some of DeepKit's JIT features will be unavailable when using DeepKit REST. We've implemented a lot of caching to reduce the influence of the lack of these JIT features.

Tutorial

To get started, we'll need to import HttpExtensionModule, RestCoreModule and RestCrudModule:

new App({
  imports: [
    new FrameworkModule(),
    new HttpExtensionModule(),
    new RestCoreModule(),
    new RestCrudModule(),
    // ...
  ],
})
  .loadConfigFromEnv()
  .run();

HttpExtensionModule plays an important role in DeepKit REST by making the request information available through the DI system where the most useful provider HttpRequestParsed in the module offers us the ability to parse the request at any time during a responding process:

class MyService {
  constructor(private request: HttpRequestParsed) {}
  method() {
    interface DynamicQuerySchema {}
    const queries = this.request.getQueries<DynamicQuerySchema>();
  }
}

See the README of @deepkit-rest/http-extension for more information.

Resource

In DeepKit REST, we define Resources instead of HTTP Controllers:

@rest.resource(Book, "base/path/for/book/actions")
class BookResource implements RestResource<Book> {
  constructor(private database: Database) {}

  getDatabase() {
    return this.database;
  }

  getQuery() {
    return this.database.query(Book);
  }
}

The Query object returned from getQuery() will be the base Query for every CRUD Actions, so it's a good place to filter the entities to restrict what the user can view. For example we can allow the user to view only his own Book:

getQuery() {
    const userId = this.somehowGetUserId()
    const userRef = this.database.getRef(User, userId);
    return this.database.query(Book).filter({ owner: userRef });
}

To include our resource in a module, simply declare it in controllers of the module:

class BookModule extends createModule({
  controllers: [BookResource],
}) {}

Actions

Despite of a few internal differences, Resources are just special HTTP Controllers:

  • Resources are in http scope too
  • @http decorators still work in Resources
  • Method parameters are resolved completely the same
  • ...

Therefore, you should be able to use decorators like @http.GET() to define Actions, but you should NEVER do, because it will probably make the Action a regular HTTP Action but not a REST Action, and most of our features not available to regular HTTP Actions.

Instead, an Action should be defined using @rest.action():

@rest.action("GET", ":id")

As long as @rest.action() is applied, we can use the @http decorator for additional metadata:

@rest.action("GET", ":id")
@http.group("my-group").data("key", "value").response(404)

Inferred Path

For simple resources, we can omit the second parameter of @rest.resource() to infer the resource's path from the entity's collection name:

@entity.name("book").collection("books")
class Book {
  // ...
}
@rest.resource(Book) // inferred path: "books"
class BookResource implements RestResource<Book> {
  // ...
}

Path Prefix

We can specify the path prefix for all our resources via the module config of RestCoreModule

{
  imports: [new RestCoreModule({ prefix: "api" })];
}

Nested Resources

There is a notable difference in route generation between Resources and HTTP Controllers - routes generated from Resources will never have a baseUrl. Therefore, we can declare path parameter in the Resource path, and thus we can have nested Resources:

@rest.resource(Book, "users/:userId/books")
class UserBookResource implements RestResource<Book> {
  constructor(private request: HttpRequestParsed, private database: Database) {}
  // ...
  getQuery() {
    const { userId } = this.request.getPathParams<{ userId: string }>();
    const userRef = this.database.getRef(User, userId);
    return this.database.query(Book).filter({ owner: userRef });
  }
  // ...
}

CRUD Kernel

In order to respond to a CRUD Action, we'll need to invoke multiple REST components in a fixed order(e.g. for list actions: query -> filter -> paginator -> sorter -> serializer -> response):

For convenience, workflows of most common CRUD Actions have been wrapped and are available for you via the CRUD Kernel. The public methods of CRUD Kernel never take any parameters, all the information are obtained from the DI system. Usually, all we need to do is just to invoke the CRUD Kernel and return the result:

constructor(private crud: RestCrudKernel) {}
@rest.action("GET")
async list(): Promise<Response> {
  return this.crud.list();
}

The responsibility of CRUD Kernel is only to call the REST components in order. Its behavior completely depends on the REST components in use. We can specify which REST component to use by implementing customization interfaces:

@rest.resource(Book, "books")
class BookResource implements RestResource<Book>, RestPaginationCustomizations {
  readonly paginator = RestPageNumberPaginator;
  constructor(private crud: RestCrudKernel) {}
  // ...
  @rest.action("GET")
  async list(): Promise<Response> {
    return this.crud.list();
  }
}
Action Example URL Available Customizations
List GET /books Serialization, Pagination, Filtering
Create POST /books Serialization
Retrieve GET /books/1 Serialization, Retrieving
Update PATCH /books/1 Serialization, Retrieving
Delete DELETE /books/1 Retrieving

We can extend the built-in CRUD Kernel for more functionalities:

class AppCrudKernel extends RestCrudKernel {
  async createMany(): Promise<Response> {
    // ...
  }
}

Entity Pagination

An Entity Paginator plays an important role in List Actions. It's responsible for both applying pagination to the Query object and forming the response body.

The paginator to use can be specified by implementing RestPaginationCustomizations:

class BookResource implements RestResource<Book>, RestPaginationCustomizations {
  readonly paginator = RestPageNumberPaginator;
  // ...
}
interface RestPaginationCustomizations {
  paginator?: ClassType<RestEntityPaginator>;
}

RestNoopPaginator

RestNoopPaginator is the default paginator the CRUD Kernel uses, which doesn't do any processing to the Query and thus will return as many entities as available, and form the body like:

{
  total: ...,
  items: [...],
}

RestOffsetLimitPaginator

By default, RestOffsetLimitPaginator paginates the List result based on the limit and offset query params and form the body the same: { total: ..., items: [...] }.

Some configuration properties are available for us to customize its behavior by overriding the value:

class AppPaginator extends RestOffsetLimitPaginator {
  // these are the default values
  override limitDefault = 30;
  override limitMax = 50;
  override limitParam = "limit";
  override offsetMax = 1000;
  override offsetParam = "offset";
}

RestPageNumberPaginator

RestPageNumberPaginator performs pagination based on the page and size query params by default and also form a { total: ..., items: [...] } object as the response body.

Customizations are also available by overriding configuration properties:

class AppPaginator extends RestPageNumberPaginator {
  // these are the default values
  override pageNumberMax = 20;
  override pageNumberParam = "page";
  override pageSizeDefault = 30;
  override pageSizeMax = 50;
  override pageSizeParam = "size";
}

Entity Serialization

As entities must be serialized to form the response, serialization is a necessary part of every CRUD Actions, which is handled by Entity Serializers.

The serializer to use can be specified by implementing RestSerializationCustomizations:

class BookResource
  implements RestResource<Book>, RestSerializationCustomizations<Book>
{
  readonly serializer = BookSerializer;
  // ...
}

While the "serialization" and the "deserialization" for Entity Serializers are a little different from the ones in other scenarios:

export interface RestEntitySerializer<Entity> {
  /**
   * Transform the entity into a JSON serializable plain object to form the
   * response body.
   * @param entity
   */
  serialize(entity: Entity): Promise<unknown>;
  /**
   * Create a new entity instance based on the payload data which came
   * from the request body.
   * @param payload
   */
  deserializeCreation(payload: Record<string, unknown>): Promise<Entity>;
  /**
   * Update an existing entity instance based on the payload data which came
   * from the request body.
   * @param payload
   */
  deserializeUpdate(
    entity: Entity,
    payload: Record<string, unknown>,
  ): Promise<Entity>;
}

RestGenericSerializer

RestGenericSerializer is the default Entity Serializer, with the ability to handle the serialization and deserialization of any entities.

Serialization

In serialization, RestGenericSerializer directly leverages DeepKit's built-in serialization feature.

We can extend the class and override some properties to customize the base serialization config:

class AppSerializer<Entity> extends RestGenericSerializer<Entity> {
  override serializer = serializer;
  override serializationOptions = { groupsExclude: ["hidden"] };
  override serializationNaming = myNamingStrategy;
}

RestGenericSerializer is also compatible with the @http.serialization() and @http.serializer() decorators, which means we can customize the serialization behavior just as how we do in regular HTTP Controllers:

@rest.action("GET")
@http.serialization({ groupsExclude: ["hidden"] })
action() {}

Serialization config specified using the decorator will be merged into the base serialization config specified via the class properties.

Deserialization for Creation

In deserialization for creations, RestGenericSerializer will first purify(deserialize and validate) the payload against a schema generated from the entity schema based on the fields decorated with InCreation using DeepKit's deserialization feature:

For Reference fields, DeepKit deserialization will automatically transform primary keys into entity references. But BackReference fields are not supported.

Let's say entity Book is defined like this:

class Book {
  id: UUID & PrimaryKey = uuid();
  name: string & MaxLength<50> & InCreation;
}

The payload will be purified against a schema like this:

interface GeneratedSchema {
  name: string & MaxLength<50>;
}

Generated schemas will be cached and reused.

Then, RestGenericSerializer will instantiate a new entity instance, and assign the data from the purified payload to the entity instance:

const book = await serializer.deserializeCreation({ name: "name" });
book instanceof Book; // true
book.name === "name"; // true

By default RestGenericSerializer use DeepKit deserialization to instantiate new entities. We can customize how new entities are instantiated by overriding its createEntity() method where the purified payload will be passed as a parameter:

class BookSerializer extends RestGenericSerializer<Book> {
  protected override createEntity(data: Partial<Book>) {
    return new Book(data.title, data.author);
  }
}

We can also modify the data object to assign values to fields like owner when overriding createEntity():

protected override createEntity(data: Partial<Book>) {
  const userId = this.requestContext.userId;
  const user = this.database.getRef(User, userId);
  data.owner = user;
  return super.createEntity(data);
}

Deserialization for Update

Just like how it behaves in deserialization for creations, RestGenericSerializer will purify the request payload against a generated schema, but now the schema is generated based on fields decorated with InUpdate:

class Book {
  name: ... & InUpdate;
  // ...
}

And then the data will be assigned to an existing entity instead of a new one:

book = await serializer.deserializeUpdate(book, { name: "updated" });
book.name === "updated"; // true

To customize how entities are updated, we can override the updateEntity() method, which is a good place to assign values to fields like updatedAt:

class BookSerializer extends RestGenericSerializer<Book> {
  protected override updateEntity(entity: Book, data: Partial<Book>) {
    data.updatedAt = new Date();
    return super.updateEntity(entity, data);
  }
}

Entity Filtering

In List Actions, the Query object will be processed by Entity Filters before passing to the Entity Paginator. It's the best place to modify the query result dynamically based on the request.

By default there are no Entity Filters in use. We can specify multiple Entity Filters, and they will be invoked one by one in order:

class BookResource implements RestResource<Book>, RestFilteringCustomizations {
  readonly filters = [RestGenericFilter, RestGenericSorter];
  // ...
}

RestGenericFilter

RestGenericFilter allows the client to filter entities through the filter query param (by default) for specified fields:

?filter[owner][$eq]=1&filter[name][$in][]=name1&filter[name][$in][]=name2

Supported filter operators are: ["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$in", "$nin"].

Decorate a field with Filterable to enable filtering:

class Book {
  id: ... & Filterable;
}

BackReference fields are not yet supported.

The query parameter name can be specified via the param configuration property:

class AppFilter extends RestGenericFilter {
  override param = "filter"; // default value
  // ...
}

RestGenericSorter

RestGenericSorter is a special Entity Filter which enables client-controlled sorting against specified fields through the order query param (by default):

?order[id]=asc&order[name]=desc

Decorate a field with Orderable to enable sorting:

class Book {
  id: ... & Orderable;
}

Configuration properties are similar to RestGenericFilter:

class AppSorter extends RestGenericSorter {
  override param = "order"; // default value
  // ...
}

Entity Retrieving

For every entity-specific Actions(e.g. Retrieve, Update, Delete), it's necessary to retrieve the target entity for further operations, which is mostly handled by an Entity Retriever.

The Entity Retriever to use can be specified by implementing RestRetrievingCustomizations:

class BookResource implements RestResource<Book>, RestRetrievingCustomizations {
  readonly retriever = RestSingleFieldRetriever;
  // ...
}

RestSingleFieldRetriever

RestSingleFieldRetriever is the default Entity Retriever, which retrieves the entity based on the :pk path parameter and the entity's primary key(by default):

@rest.action("GET", ":pk")
async retrieve(): Promise<Response> {
  return this.crud.retrieve();
}

Its behavior can be customized by implementing RestSingleFieldRetrieverCustomizations and specify the retrievesOn property:

The retrievesOn property have a simple form and a long form:

@rest.resource(Book).lookup("anything")
class BookResource
  implements
    RestResource<Book>,
    RestRetrievingCustomizations,
    RestSingleFieldRetrieverCustomizations<Book>
{
  readonly retriever = RestSingleFieldRetriever;
  readonly retrievesOn = "id"; // simple form
  readonly retrievesOn = "identity->username"; // long form
  // ...
}
  • In the example of the simple form, the value of the path parameter :id will be used to retrieve the entity on its id field.
  • In the example of the long form, the value of the path parameter :identity will be used to retrieve the entity on its username field.

CRUD Action Context

When not using the CRUD Kernel, DeepKit REST can still bring you huge convenience via CRUD Action Context, which is a provider in http scope allowing us to easily access a lot of contextual information:

constructor(private crudContext: RestCrudActionContext) {}
@rest.action("PUT", ":pk/")
customAction() {
  const entity = await this.crudContext.getEntity();
  const resource = this.crudContext.getResource();
};

We can get the target entity in entity-specific Actions using getEntity(), which internally invokes the current Entity Retriever and throws an HttpNotFoundError when entity not found.

We can call the getXxx() methods of CRUD Action Context as many times as we want without worrying the performance, because there is a caching system implemented for CRUD Action Context to cache and reuse results.

CRUD Action Context will be available once httpWorkflow.onRoute event is finished. Thus we can also use it in Event Listeners.

Guard

Guards are responsible for determining whether a request should be passed to further handlers such as a Resource or an HTTP Controller. We can throw an HTTP Error in a Guard to directly respond to the request and thus prevent further handling:

Unlike other concepts, Guards are available for both Resources and regular HTTP Controllers.

interface RestGuard {
  guard(): Promise<void>;
}

Let's take a look at a simple example of a Guard responsible to forbid access to unpublished Book entities:

@rest.guard("published-only")
class BookPublishedOnlyGuard implements RestGuard {
  constructor(private context: RestCrudActionContext) {}

  async guard(): Promise<void> {
    const book = await this.context.getEntity();
    if (!book.published) throw new HttpAccessDeniedError();
  }
}

We must decorate a Guard with the @rest.guard() decorator to bind a group name for the Guard, which will be used to bind the Guard to specific Actions. In this case, the bound group name is "publish-only", which means all Actions in this module with group name "published-only" will be protected by the BookPublishedOnlyGuard:

@rest.resource(Book, "books")
class BookResource implements RestResource<Book> {
  // ...
  @rest.action("GET", ":pk")
  @http.group("published-only")
  async retrieve(): Promise<Response> {
    // ...
  }
}

We can apply the @http.group() decorator to the Resource to prepend some group to all its Actions, which can be quite useful when we want all the Actions in the Resource to be protected by the Guard:

@rest.resource(Book, "books")
@http.group("published-only")
class BookResource implements RestResource<Book> {
  // ...
  @rest.action("GET", ":pk")
  async retrieve(): Promise<Response> {
    // ...
  }
}

Usually all Guards should be provided in the http scope, because we have to inject some http scoped providers to get the contextual information, e.g. HttpRequest, RestCrudActionContext and HttpRequestParsed. In this case, the Guard is making use of RestCrudActionContext, so it must be provided in the http scope:

{
  provides: [{ provide: BookPublishedOnlyGuard, scope: "http" }];
}

If a Guard is not exported, only Actions in modules where the Guard can be injected are able be protected by this Guard. We'll need to put it in exports to share it among other modules:

{
  provides: [{ provide: BookPublishedOnlyGuard, scope: "http" }];
  exports: [BookPublishedOnlyGuard],
}

The order of Guards is determined by the order of groups. If we want the requests to go through an authorization Guard first, we'll need to position the corresponding group before any other Guard-bound groups.

@http.group('auth-required', "owned-only")
action() {}

Applying @http.group() to a Resource will prepend groups to Actions, so Guards bound to Actions using @http.group() at the Resource level will be invoked earlier than the ones at the Action level. Therefore we can avoid repeating the group if we want all the Actions in a Resource to be handled by the authentication Guard first:

@http.group("auth-required")
class MyResource implements RestResource<MyEntity> {
  // ...
  @http.group("owned-only")
  action() {}
}

Details in HTTP Workflow

  • Guards are invoked at the onAuth state with 200 order
  • The state of the HTTP Workflow will jump from onAuth to onAccessDenied when an HTTP Error is thrown from Guards

Resource Inheritance

As the application grows, we may find that there are a lot of repeated patterns like the paginator declaration and completely same getDatabase() code. To keep DRY, we can create an abstract AppResource as the base Resource:

export abstract class AppResource<Entity>
  implements
    RestResource<Entity>,
    RestPaginationCustomizations,
    RestFilteringCustomizations
{
  paginator = RestOffsetLimitPaginator;
  filters = [RestGenericFilter, RestGenericSorter];

  protected database: Inject<Database>;

  getDatabase(): Database {
    return this.database;
  }

  abstract getQuery(): Query<Entity>;
}

Special Thanks