ngx-crudx
TypeScript icon, indicating that this package has built-in type declarations

1.4.0 • Public • Published

Leveraging Repository pattern + Entity in Angular 😎

crudx

npm version lerna Commitizen friendly

NOTE: This DOC is under WIP

ngx-crudx is a tool which help us to build highly scalable angular apps. Developers find Entity and Repository pattern familiar with ORM (eg. TypeORM), but certain mechanism was missing on the frontend architecture. In order to follow proper DRY principles, its better to use single Repository for each Entity to perform REST operations (findAll, findOne, createOne etc.) using various configurations.

ngx-crudx is highly influenced by TypeORM & NestJS TypeORM wrapper. 🙌

Table of Contents

Features

  • Single codebase, yet different Repository for entity. Hence, DRY followed. 😀
  • Annotate Entity model with @Entity decorator to add extra metadata.
  • Add support for Custom Repository.
  • Support for multiple micro-services (URL bindings) as multiple connections.
  • Ability to transform (Adapter) body and/or response payload on the fly with easy configuration.
  • Engineered an interceptor for query params (both at entity level and as well as individual route level).
  • Produced code is performant, flexible, clean and maintainable.
  • Follows all possible best practices.

Installation

via npm:

npm install ngx-crudx

or yarn:

yarn add ngx-crudx

Import the NgCrudxModule

Sync Options

For monolith architecture, a single API server url is needed.

import { HttpClientModule } from "@angular/common/http";
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { NgCrudxModule } from "ngx-crudx";

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    NgCrudxModule.forRoot({
      basePath: "http://localhost:3000",
      name: "DEFAULT", // Optional and defaults to DEFAULT
    }),
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

For micro-services architecture, multiple API server url can be configured.

import { HttpClientModule } from "@angular/common/http";
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { NgCrudxModule } from "ngx-crudx";

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    NgCrudxModule.forRoot([
      {
        basePath: "http://localhost:3001/auth-service",
        name: "AUTH_SERVICE", // Required
      },
      {
        basePath: "http://localhost:3002/user-service",
        name: "USER_SERVICE", // Required
      },
    ]),
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Async Options

For async root options, factory strategy can be used to provide configuration at runtime. The factory method must always return Promise<NgCrudxOptions>;

import { HttpClientModule } from "@angular/common/http";
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { NgCrudxModule } from "ngx-crudx";

import { EnvService } from "./services";

@NgModule({
  imports: [
    BrowserModule,
    HttpClientModule,
    NgCrudxModule.forRoot({
      useFactory: async (envService: EnvService) => {
        const envConfigs = await envService.getConfigs();
        // or if observable
        // const envConfigs = await envService.getConfigs().toPromise();
        return Promise.resolve({
          basePath: `${envConfigs.apiUrl}`,
          name: "DEFAULT",
        });
      },
      deps: [EnvService],
    }),
  ],
  bootstrap: [AppComponent],
})
export class AppModule {}

Step-by-Step Guide

What are you expecting from ngx-crudx? First of all, you are expecting it will create repository service for you and find / insert / update / delete your entity without the pain of having to write lots of hardly maintainable services. This guide will show you how to set up ngx-crudx from scratch and make it do what you are expecting.

Create a Model

Working with a library starts from creating model. How do you tell library to create a repository service? The answer is - through the models. Your models in your app are key to repository service.

For example, you have a User model:

export class User {
  id: string;
  name: string;
  username: string;
  email: string;
  address: Address;
  phone: string;
  website: string;
  company: Company;
}

Create an Entity

Entity is your model decorated by an @Entity decorator. You work with entities everywhere with crudx. You can load/insert/update/remove and perform other operations with them.

Let's make our User model as an entity:

@Entity({
  path: "users",
})
export class User {
  id: string;
  name: string;
  username: string;
  email: string;
  address: Address;
  phone: string;
  website: string;
  company: Company;
}

Now, this metadata is being annotated to the User entity and we'll be able to work with it anywhere in our app.

But wait, what would we get with this annotation and what this metadata stands for 🤨? Well, this metadata will act as configuration for the Repository service.

Create a Repository

Well, we have annotate our model with @Entity decorator which will add the metadata to the model. But how would be deduce the Repository service? Well, crudx provide a mechanism where repository service for each entity will be generated by DI of Angular, and with some pretty nice hooks.

Here is the example, on how to register the entity with the crudx Module and deduce a repository service for it.

import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { NgCrudxModule } from "ngx-crudx";

import { UserRoutingModule } from "./user-routing.module";
import { User } from "./user.model";

@NgModule({
  imports: [
    CommonModule,
    UserRoutingModule,
    NgCrudxModule.forFeature([User]), // -> here we pass the model to the crudx module
  ],
})
export class UserModule {}

Note: If the model is not annotated with the @Entity decorator, then the crudx Module will throw an error stating:

The entity passed named User is missing the @Entity() decorator. Make sure you have decorated the entity class with it.

Using the Repository

Once the Entity is passed to the crudx Module, then we can Inject the Repository service as below:

import { Component, Inject, OnInit } from "@angular/core";
import { Repository, RepoToken } from "ngx-crudx";
import { User } from "../user.model";

@Component({
  selector: "app-user",
  templateUrl: "./user.component.html",
  styleUrls: ["./user.component.scss"],
})
export class UserComponent {
  constructor(
    @Inject(RepoToken(User)) private readonly userRepo: Repository<User>,
  ) {}
}

@Entity API's

Here are the detailed explanation for the Entity API's

path

This property represents the path for entity on the backend API layer.

e.g. 'users', 'posts', 'comments'

The path passed to the forRoot method on ngx-crudx will be base-path and the path property represents the logical location. eg. http://localhost:3000/users

name?

type - string

The name of the connection name to which the current path is being appended to. Defaults to DEFAULT.

transform?

The name applies as a Adapter layer. This will help in transforming the body (if any) before requesting and payload after receiving response. The value must be an instance of class or actual class* which implements Transform.

Note: Class based Adapter/Transform services is experimental at the moment. Lexical scoping issue might persist when referencing the model (which is annotated with the @RepoEntity) in the adapter service itself. In order to deal with lexical scoping, NgxCrudx will register the adapter/transform service itself into the DI context when the Entity/Model annotated with the @RepoEntity/@Entity decorator is processed.
Kindly don't register Adapter/Transform services in the Module's provider array which are used by Entity/Model.

export type Transform<T = unknown | any, R = T> = {
  /**
   * Transform the Entity (T) type to arbitrary (which backend API expects) type R
   * @description Callback invoked when transforming body to
   * certain type which the backend API expects (R).
   */
  transformFromEntity: (data: T) => R;
  /**
   * Transform the payload received to Entity (T) type.
   * @description Callback invoked when transforming response
   * payload to type `Entity`.
   */
  transformToEntity: (resp: R) => T;
};

allowTransformDI?

type - boolean
default - true

The value for the transform can be either object based (instance) or class based. If the value is service, then the DI registration for the value of the transform will happen from inside the crudx. Refer more here.

Since this service is registered in DI context and will be provided as singleton but this mechanism is not always required because the adapter/transform implementation is generally stateless. A new instance (POJO) on every request/response roundtrip will suffice.

{
  ...
  transform: UserAdapter, // this will not register the service into DI context
  allowTransformDI: false,
  ...
}

The benefit of this strategy is the memory allocation, which will be freed upon completion of request/response roundtrip instead of simply residing in memory until the module is manually destroyed.

See more:

qs?

Callback function which mutate/returns a new HttpParams object. The value of the param is the value passed as params to HttpRequestOptions.

(params: AnyObject) => HttpParams | undefined;

routes?

Object which consist of set of individual routes based configuration. For every Repository method, there is RouteOption type which is defined below.

type RouteOptions = {
  /**
   * The path for the individual route
   */
  path?: string;
  
  /**
   * Route specific model adapter/transformer.
   * @description Always **_override_** the
   * default adapter defined in repo options.
   */
  transform?: ITransform;
  
  /**
   * Callback/QueryBuilder for mutating the query params passed via
   * Repository method
   * @description If used as callback, then default mode is `extend`,
   * else builder params depends upon type of mode respectively.
   * @returns `HttpParams`
   */
  qs?:
    | RepoQueryBuilder
    | RepoQueryBuilder<"override">
    | ((params: HttpParams | AnyObject) => HttpParams);
  
  /**
   * Predicate value to **allow/restrict** `transform` value
   * registration with `Injector`.
   * @default true
   */
  allowTransformDI?: boolean;
};
  • path?
    This will override the path formed by Repository helper mechanism. This is useful in case your path doesn't match the criteria mentioned in the Repository API section.

  • transform?
    If you want to override the default adapter/transformer of the model, then we can setup the adapter at the individual route level too. NOTE: This will always override the default transformer (if defined).

  • qs?
    If you want extend the functionality of query params for individual route, pass a callback and return HttpParams from it. But if you want to override the default qs behavior (if defined), then pass the object type like below:

{
  mode: 'extend' | 'override',
  builder<P = B>(params: P): HttpParams | undefined;
}

Repository API

Certain methods definitions are present on the Repository class. These API are self-explanatory.

findAll

GET /users
GET /posts

findAll<R = T>(opts?: HttpRequestOptions): Observable<R extends T ? R[] : R>;

findOne

GET /users/:userId
GET /users/:userId/posts/:postId

findOne<R = T>(id: string | number, opts?: HttpRequestOptions): Observable;
findOne<R = T>(opts: HttpRequestOptions): Observable;

createOne

POST /users
POST /users/:userId/posts

createOne<R = T>(payload: AnyObject, opts?: HttpRequestOptions): Observable;

updateOne

PATCH /users/:userId
PATCH /users/:userId/posts/:postId

updateOne<R = T>(id: string | number, body: Partial, opts?: HttpRequestOptions): Observable<Partial>;
updateOne<R = T>(body: Partial,opts: HttpRequestOptions): Observable<Partial>;

replaceOne

PUT /users/:userId
PUT /users/userId/posts/:postId

replaceOne<R = T>(id: string | number, body: R, opts?: HttpRequestOptions): Observable<Partial>;
replaceOne<R = T>( body: R, opts: HttpRequestOptions ): Observable<Partial>;

deleteOne

DELETE /users/:userId
DELETE /users/:userId/posts/:postId

deleteOne<R = any>(id: string | number, opts?: HttpRequestOptions ): Observable;
deleteOne<R = any>(opts: HttpRequestOptions): Observable;

Custom Repository

There is a scenario where basic CRUD isn't just enough. You may need to add more operations to the Repository. Since every Entity have its own desired way to communicate, not all methods can be generic-fied. So to add your own custom behavior to the repository, there is Custom Repository for the rescue.

Here is how to define a Custom Repository:

import { RepositoryMixin } from "ngx-crudx";

import { User } from "../user.model";

export class UserRepository extends RepositoryMixin(User) {
  constructor() {
    super();
  }

  findByName(name: string) {
    // business logic here.
  }
}

Now, instead of passing the Entity to the ngx-crudx Module, pass the repository class to the Module for DI to generate instance.

import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { NgCrudxModule } from "ngx-crudx";

import { UserRepository } from "./repositories";
import { UserRoutingModule } from "./user-routing.module";

@NgModule({
  imports: [
    CommonModule,
    UserRoutingModule,
    NgCrudxModule.forFeature([UserRepository]), // -> here we pass the custom repository to the module
  ],
})
export class UserModule {}

Extra Routes

The need of custom repositories depends upon extra operations other than basic CRUD. Hence, to incorporate such endpoints which cannot be generic-fied and need different endpoints and other features of crudx to work in conjunction, there is a request method which exposes basic params to create a new request using various options (HttpRequestOptions).

import { RepositoryMixin } from "ngx-crudx";

import { User } from "../user.model";
import { CountTransform } from "../user-count.transform.ts";

export class UserRepository extends RepositoryMixin(User) {
  constructor() {
    super();
  }

  totalCount() {
    return super.request("get", "users/count", { transform: CountTransform });
  }
}

Signature for request method is as follow:

request<R = any>( method: HttpMethod, path: HttpRequestOptions["path"], opts?: HttpRequestBaseOptions & Pick<HttpRequestOptions, "transform" | "pathParams"> ): Observable;

HttpsRequestOptions API's

Many of the properties provided by the HttpClient's RequestOptions share the same signature. The ones, which share the same signature are listed below:

interface HttpRequestBaseOptions {
  body?: any;
  headers?:
    | HttpHeaders
    | {
        [header: string]: string | string[];
      };
  context?: HttpContext;
  observe?: "body";
  params?:
    | HttpParams
    | {
        [param: string]:
          | string
          | number
          | boolean
          | ReadonlyArray<string | number | boolean>;
      };
  reportProgress?: boolean;
  responseType?: "json";
  withCredentials?: boolean;
}

export type HttpMethod =
  | "get"
  | "post"
  | "patch"
  | "put"
  | "delete"
  | "options"
  | "head"
  | "connect"
  | "trace";

Here are the ones which are supported by library:

export type HttpRequestOptions<QueryParamType = AnyObject> = Omit<
  HttpRequestBaseOptions,
  "params"
> & {
  /**
   * Query params
   */
  params?: QueryParamType;
  /**
   * Path params
   */
  pathParams?: Record<string, string>;
  /**
   * @summary Class Model or it's instance that will help in modifying the
   * payload **in (getting response) and out (sending request).**
   * Providing `"none"` as value will skip the transformation at all levels.
   */
  transform?: "none" | RepoEntityOptions["transform"];
};

params?

This is the object which takes key-value pair. The type is defined by the Repository class via Generics.

export class Repository<T = unknown, QueryParamType = AnyObject> {}

The second generic type is supported for frameworks like @nestjsx/crud-request for better type interpolation.

pathParams?

This is the key-value pair object which replaces the path params from the path property in the @Entity decorator.

Here is the example.

@Entity({
  path: 'users/:userId/photos',
})
export class Photo {
  ...
}

The userId will be replaced at runtime via pathParam property.

@Component({
  selector: "app-photo",
  templateUrl: "./photo.component.html",
  styleUrls: ["./photo.component.scss"],
})
export class PhotoComponent implements OnInit {
  constructor(
    @Inject(RepoToken(Photo)) private readonly photoRepo: Repository<Photo>,
  ) {}

  ngOnInit() {
    this.photoRepo
      .findAll({
        pathParams: {
          userId: "123",
        },
      })
      .subscribe((resp) => {
        // do something with resp
      });
  }
}

transform?

The ability to transform the request/response payload can be done at runtime. There may be such cases where transformations are no longer needed for a particular endpoint but it's transformation adapter is configured at decorator (@Entity) level. Hence, this property will help us to control the default behavior.

@Component({
  selector: "app-photo",
  templateUrl: "./photo.component.html",
  styleUrls: ["./photo.component.scss"],
})
export class PhotoComponent implements OnInit {
  constructor(
    @Inject(RepoToken(Photo)) private readonly photoRepo: Repository<Photo>,
  ) {}

  ngOnInit() {
    this.photoRepo
      .findAll({
        transform: "none", // <-- this will simply disable the transformation logic
      })
      .subscribe((resp) => {
        // do something with resp
      });
  }
}

Known Issues

Class based Adapter/Transformer services is experimental at the moment. Lexical scoping issue might persist when referencing the model (which is annotated with the @RepoEntity) in the adapter service itself. In order to deal with lexical scoping, NgxCrudx will register the adapter/transformer service itself into the DI context when the Entity/Model annotated with the @RepoEntity/@Entity decorator is processed.

Kindly don't register Adapter/Transformer services in the Module's provider array which are used by Entity/Model.

// user.entity.ts
@RepoEntity({
  path: "user",
  routes: {
    createOne: {
      transform: UserAdapter // This class will be registered into DI context from within crudx
    }
  }
})
class User {
  ...
}

// user.adapter.ts
import {User} from './entities';

@Injectable()
export class UserAdapter implements Transform<User> {
  transformFromEntity(data: User) {
    return classToPlain(data);
  }

  transformToEntity(resp: AnyObject) {
    return plainToClass(User, resp);
  }
}

// user.module.ts
@NgModule({
  imports: [CommonModuleNgCrudxModule.forFeature([User])],
  declarations: [UserComponent],
  providers: [UserAdapter], // ❌ don't register adapter/transform service.
})
export class UserModule {}

Contributions

If you like this library or found any bug/typo and want to contribute, PR's are most welcomed.

For major changes, please open an issue first to discuss what you would like to change.

Our commit messages are formatted according to Conventional Commits, hence this repository has commitizen support enabled. Commitizen can help you generate your commit messages automatically.

And to use it, simply call git commit. The tool will help you generate a commit message that follows the below guidelines.

Commit Message Format

Each commit message consists of a header, a body and a footer. The header has a special format that includes a type, an optional scope and a subject:

<type>(<scope>): <subject>
<BLANK LINE>
<body>
<BLANK LINE>
<footer>

License

MIT

Package Sidebar

Install

npm i ngx-crudx

Weekly Downloads

0

Version

1.4.0

License

MIT

Unpacked Size

340 kB

Total Files

59

Last publish

Collaborators

  • akshay.mahajan