ngx-crudx
v1.4.0
Published
A library for leveraging Repository pattern & Entity in angular apps at scale.
Downloads
3
Maintainers
Readme
Leveraging Repository pattern + Entity in Angular 😎
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
- Installation
- Import the NgCrudxModule
- Step-by-Step Guide
- @Entity API's
- Repository API
- Custom Repository
- HttpsRequestOptions API's
- Known Issues
- Contributions
- License
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 theDI context
when the Entity/Model annotated with the@RepoEntity/@Entity
decorator is processed.
Kindly don't register Adapter/Transform services in the Module'sprovider
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 byRepository
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 returnHttpParams
from it. But if you want to override the defaultqs
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>