Excalibur is a set of functions and classes api plus several modules for Nest.js
.
-
3.1 Swagger
3.2 Guards
3.3 Interceptors
3.4 Headers
3.5 CrudApi
npm i @pimba/excalibur
One of the strongest features of this library is to implement an API-REST quickly. To do this, you must first consider implementing the following classes:
- Entity
- Service
- DTO
- Controller
If you want the entity has an auntoincremental id column, createdAt, updatedAt columns.
import {AbstractEntity} from '@pimba/excalibur/lib';
@Entity('product')
export class ProductEntity extends AbstractEntity {
}
import {AbstractService} from '@pimba/excalibur/lib';
@Injectable()
export class ProductService extends AbstractService<ProductEntity> {
constructor(
@InjectRepository(ProductEntity)
private readonly _productRepository: Repository<ProductEntity>,
) {
super(_productRepository);
}
}
It is optional to extend from BaseDTO
, This class allows to validate that the fields: id
, createdAt
and updatedAt
should not be empty
import {BaseDTO} from '@pimba/excalibur/lib';
export class ProductCreateDto extends BaseDTO{
@IsAlpha()
@IsNotEmpty()
name: string;
@IsNotEmpty()
description: string;
@IsNumber()
@IsNotEmpty()
price: number;
}
import {CrudController, CrudOptions} from '@pimba/excalibur/lib';
const options: CrudOptions = {
dtoConfig: {
createDtoType: ProductCreateDto,
updateDtoType: ProductUpdateDto,
},
}
@Controller('product')
export class ProductController extends CrudController<ProductEntity>(options) {
constructor(private readonly _productService: ProductService) {
super(
_productService,
);
}
}
For a controllerPrefix
given on the Controller
decorator. The following
set of routes will be generated.
HTTP METHOD | PATH | Controller and Service method |
---|---|---|
POST | <controllerPrefix> |
createOne |
POST | <controllerPrefix> /create-many |
createMany |
PUT | /<controllerPrefix>/<id:number> |
updateOne |
GET | /<controllerPrefix>/<id:number> |
findOne |
GET | /<controllerPrefix>?query=<find-query> |
findAll |
DELETE | /<controllerPrefix>/<id:number> |
deleteOne |
For SQL DB you can make a search criteria, that complies with the following scheme:
{
"where": {
// Entity attributes and relations
},
"skip": 0, // Pagination
"take": 10 // Pagination
}
For example:
The product entity
has a relation many to one
with category entity
, so lets make
the following search:
Products that have a price greater than or equal
to 10
or less than
2 and that the name of the product category
can be snacks
, drinks
or that the same name of the category includes "sna"
.
Find-Query
:
{
"where": {
"price": [
{
"$gte": 10.00
},
{
"$lt": 2.00
}
],
"category": {
"$join": "inner",
"name": [
{
"$like": "%25sna%25"
},
{
"$in": ["snacks", "drinks"]
}
]
}
}
}
On
like
operator with the wildcar%
, you should use%25
instead of%
cause some problems with browsers andhttp clients
asPostman
.
Browser or client side
http://localhost:3000/product?query={"where":{"name":{"$like":"%25choco%25"},"category":{}}}
Results:
{
"nextQuery": null,
"data": [
{
"price": "18",
"id": 22,
"createdAt": "2020-07-23T23:51:56.898Z",
"updatedAt": "2020-07-23T23:51:56.898Z",
"name": "chocobreak",
"description": "Voluptate irure eu dolor sit et id nisi dolore ex aliquip.",
"category": {
"id": 8,
"name": "candies"
}
},
{
"price": "16",
"id": 21,
"createdAt": "2020-07-23T23:51:56.897Z",
"updatedAt": "2020-07-23T23:51:56.897Z",
"name": "great chocolate",
"description": "Commodo sit duis id consectetur minim nisi nostrud ex sit ad aute cillum eiusmod.",
"category": {
"id": 8,
"name": "candies"
}
},
{
"price": "2",
"id": 17,
"createdAt": "2020-07-23T23:51:56.891Z",
"updatedAt": "2020-07-23T23:51:56.891Z",
"name": "happy chocolate",
"description": "Duis magna exercitation aute pariatur voluptate velit magna ut.",
"category": {
"id": 8,
"name": "candies"
}
}
],
"total": 3
}
If you are working on backend side you could use the widlcard
%
without problems. Also you could use any wildcard onlike
operator according your data base.
const query = {
where: {
id: { $like: '%chocho%' },
category: {
name: 'candy',
},
supermaket: { // inner join with `supermarket` entity.
id: 25,
address: '',
city: { // inner join with `city` entity.
name: {$like: 'c[^u]'},
state: { // inner join with `state` entity.
id: {$in: [4, 5, 6, 7]},
},
},
},
},
skip: 0,
take: 30, // Pagination
}
const searchResponse: [ProductEntity[], number] = await this.productService.findAll(query);
const filterProducts = searchResponse[0];
const totalFecthed = searchResponse[1]; // All filtered records in the Data Base
For scape characters on
like
operator: use\\
percentCode: {"$like": "%25\\%25%25"}} // Client side
percentCode: {"$like": "%\\%%"}} // Backend side
You can make a query with OR
operator using the keyword "$or"
as "true"
For example: Get products with a price of 7
or name includes "choco"
{
"where": {
"price": {"$eq": 7, "$or": true},
"name": {"$like": "%25choco%25", "$or": true}
}
}
GET /product?query={"where":{.......}}
Operator | keyword | Example |
---|---|---|
Like | $like |
"$like": "%sns%" |
iLike | $ilike (PostgreSQL) |
"$ilike": "%sns%" |
> |
$gt |
"$gt": 20 |
>= |
$gte |
"$gte": 20 |
< |
$lt |
"$lt": 20 |
<= |
$lte |
"$lte": 20 |
= |
$eq |
"$eq": 20 |
!= |
$ne |
"$ne": 20 |
Between | $btw |
"$btw": [A, B] |
In | $in |
"$in": [A, B, ...] |
Not In | $nin |
"$nin": [A, B, ...]" |
Not Between | $nbtw |
"$nbtw": [A, B, ...]" |
if your are using MongoDB, you must use the query operators for mongo, check the documentation
The join relations could be many levels as you want, you need to write the
ManyToOne
, OneToMany
, OneToOne
, relationship name in your Find Query Object
like the previous example.
If the join is of the inner
type it is not necessary to put the keyword "$join": "inner"
, only if you want to use a join of the type "left" ( " $join ":" left"
)
The pagination by default is skip: 0
and take: 10
.
The order by criteria by default with respect the entity id
is DESC
:
{
"where": {
},
"orderBy": {
// Order by criteria
}
}
In order to get records with an specific set of columns, you could make use of $sel
operator:
For example: Get products with a bigger than 7
and only retrieves the name of the filtered products.
{
"where": {
"$sel": ["name"],
"price": {"$gt": 7}
}
}
Also, you could use the $sel
operator on queries with joins.
All columns that are retrieved will always include the id column
For example: the following query retrieves products with name and its supermarket with only name and address.
const query = {
where: {
$sel: ["name"],
category: {
name: 'candy',
},
supermaket: { // Select address and name
$sel: ["name", "address"],
},
},
skip: 0,
take: 30,
}
The AbstractService
class has the following methods in order to perform transactions:
-
findAllWithTransaction
-
findOneWithTransaction
-
createOneWithTransaction
-
createManyWithTransaction
-
updateOneWithTransaction
-
deleteOneWithTransaction
-
deleteManyByIdsWithTransaction
Example:
import {EntityManager, getManager, Repository} from 'typeorm';
import {AbstractService} from '@pimba/excalibur/lib';
import {getManager} from 'typeorm';
import {FindFullQuery} from '@pimba/excalibur/lib';
import {TransactionResponse} from '@pimba/excalibur/lib';
@Injectable()
export class ProductService extends AbstractService<ProductEntity> {
constructor(
@InjectRepository(ProductEntity)
private readonly _productRepository: Repository<ProductEntity>,
) {
super(_productRepository);
}
async deleteByCategory(categoryId: number): Promise<ProductEntity[]> {
return await getManager()
.transaction(
'SERIALIZABLE',
async (entityManager: EntityManager) => {
// Define the find condition
const finQuery: FindFullQuery = {
where: {
category: {
id: categoryId,
}
}
};
const findResponse = await this.findAllWithTransaction(entityManager, finQuery);
// Update the entityManager for the next operation
entityManager = findResponse.entityManager;
const [productsToDelete, totalFetched] = findResponse.response;
// Get only the ids
const ids = productsToDelete.map(product => product.id);
// Get only the deleted rows
const {response} = await this
.deleteManyByIdsWithTransaction(entityManager, ids);
return response;
}
);
}
}
If you want the entity has an ObjectId, updatedAt columns, you need to extends from AbstractMongoEntity
import {AbstractMongoEntity} from '@pimba/excalibur/lib';
@Entity('post')
export class PostEntity extends AbstractMongoEntity{
}
It is optional to extend from BaseMongoDTO
. This class allows to validate that the fields: id
, createdAt
and updatedAt
should not be empty
import {BaseMongoDTO} from '@pimba/excalibur/lib';
export class Post extends BaseMongoDTO{
}
The service class must extends from AbstractMongoService
import {AbstractMongoService} from '@pimba/excalibur/lib';
@Injectable()
export class PostService extends AbstractMongoService<PostEntity> {
constructor(
@InjectRepository(PostEntity, 'mongo_conn')
private postRepository: MongoRepository<PostEntity>,
) {
super(
localizacionRepository,
{ // MongoIndexConfigInterface
fieldOrSpec: { localization: '2dsphere' },
options: {
min: -180,
max: 180,
},
},
);
}
}
import {CrudController, CrudOptions} from '@pimba/excalibur/lib';
const options: CrudOptions = {
useMongo: true,
dtoConfig: {
createDtoType: PostCreateDto,
updateDtoType: PostCreateDto,
},
}
@Controller('post')
export class PostController extends CrudController<PostEntity>(options) {
constructor(private readonly _postService: PostService) {
super(
_postService,
);
}
}
import { Document } from 'mongoose';
import {Prop, Schema, SchemaFactory} from '@nestjs/mongoose';
export type MessageDocument = MessageModel & Document;
@Schema()
export class MessageModel {
@Prop()
content: string;
@Prop()
to: string;
@Prop()
from: string;
}
export const MessageSchema = SchemaFactory.createForClass(MessageModel);
The service class must extends from AbstractMongooseService
import {AbstractMongooseService} from '@pimba/excalibur/lib';
@Injectable()
export class MessageService extends AbstractMongooseService<MessageDocument> {
constructor(
@InjectModel(MessageModel.name)
private readonly _menssageModel: Model<MessageDocument>
) {
super(_menssageModel);
}
}
import {Controller} from '@nestjs/common';
import {CrudMongooseController, MongooseCrudOptions} from '@pimba/excalibur/lib';
const options: MongooseCrudOptions = {
dtoConfig: {
createDtoType: MessageCreateDto,
updateDtoType: MessageCreateDto,
},
enableErrorMessages: true,
};
@Controller('message')
export class MessageController extends CrudMongooseController<MessageDocument>(options) {
constructor(
protected readonly messageService: MessageService,
) {
super(messageService);
}
}
For Document the API-REST paths on swagger, you need to make use of CrudDoc
decorator or CrudApi
decorator.
Example: For every CRUD method you should make a configuration. The follwing example shows a configuration object:
In another file (if you want), make the configuration as a constant.
export const PRODUCT_SWAGGER_CONFIG: CrudApiConfig = {
createOne: { // MethodName
apiBody: {
type: ProductCrearDto
},
headers: [
{
name: 'X-MyHeader',
description: 'Custom header',
},
],
responses: [
{
type: ProductCreateDto,
status: HttpStatus.CREATED,
description: 'Created Product'
},
{
status: HttpStatus.BAD_REQUEST,
description: 'Data not valid',
}
]
},
updateOne: {
apiBody: {
type: ProductUpdateDto,
},
responses: [
{
type: ProductCreateDto,
status: HttpStatus.OK,
description: 'Updated product'
}
]
},
findAll: {
headers: [
{
name: 'X-MyHeader',
description: 'Custom header',
},
],
responses: [
{
type: ProductFindResponse,
status: HttpStatus.OK,
description: 'Fetched Products'
}
]
}
}
import {CrudDoc} from '@pimba/excalibur/lib';
@CrudDoc(
PRODUCT_SWAGGER_CONFIG,
)
@Controller('product')
export class ProductController extends CrudController<PostEntity>(options){
}
For Guards for every Crud Method
you need to make use of CrudGuards
or CrudApi
decorator.
Example:
import {CrudGuards} from '@pimba/excalibur/lib';
@CrudGuards(
{
findAll: [ProductoFindAllGuard,]
updateOne: [ProductUpdaeOneGuard],
...othersCrudMethod
}
)
@Controller('product')
export class ProductController extends CrudController<PostEntity>(options) {
}
For Interceptors for every Crud Method
you need to make use of CrudInterceptors
or CrudApi
decorator.
Example:
import {CrudInterceptors} from '@pimba/excalibur/lib';
@CrudInterceptors(
{
findAll: [ProductFindallInterceptor,]
...othersCrudMethod
}
)
@Controller('product')
export class ProductController extends CrudController<PostEntity>(options) {
}
For Headers on Crud Methods
you need to make use of CrudHeaders
or CrudApi
decorator.
Example:
import {CrudHeaders} from '@pimba/excalibur/lib';
@CrudHeaders(
{
findAll: {
name: 'Custom Header',
value: ''
},
...othersCrudMethod
}
)
@Controller('product')
export class ProductController extends CrudController<PostEntity>(options) {
}
The CrudApi
is a general decorator to put the configuration of swagger, guards, interceptors and headers for every
Crud Method.
Example:
import {CrudApi} from '@pimba/excalibur/lib';
@CrudApi(
{
findAll: {
guards: [ProductFindAllGuard,],
interceptors: [ProductFindallInterceptor],
documentation: PRODUCT_SWAGGER_CONFIG.findAll,
header: {
name: 'Custom Header',
value: ''
},
},
createOne: {
documentation: PRODUCT_SWAGGER_CONFIG.createOne,
},
updateOne: {
documentation: PRODUCT_SWAGGER_CONFIG.updateOne,
}
},
)
@Controller('product')
export class ProductController extends CrudController<PostEntity>(options) {
}
Import the module with your bucket name.
import { GoogleCloudStorageModule } from '@pimba/excalibur/lib';
@Module({
imports: [
GoogleCloudStorageModule
.register({bucketDefaultName: '<bucket-name>'}),
],
})
export class SomeModule {
}
Don't forget to export your google-cloud credentials before start the server.
Inject the google-cloud-service in your controller
import { GoogleCloudStorageService } from '@pimba/excalibur/lib';
@Controller('some')
export class SomeController {
constructor(
private readonly _googleCloudStorageService: GoogleCloudStorageService,
) {
}
}
Use the service to store a file
@Post('upload-picture')
@UseInterceptors(
FileInterceptor('picture'),
)
async uploadPicture(
@UploadedFile() pictureFile: UploadedFileMetadata,
){
try {
return await this._googleCloudStorageService.upload(pictureFile);
}catch (error) {
throw new InternalServerErrorException('Error on Upload');
}
}
You can use the GoogleCloudStorageFileInterceptor
to store a file
using a specific folder/prefix name.
import { GoogleCloudStorageFileInterceptor } from '@pimba/excalibur/lib';
@Post('upload-picture')
@UseInterceptors(
GoogleCloudStorageFileInterceptor(
'picture',
undefined,
{
prefix: 'pictures'
}
)
)
async uploadPicture(
@UploadedFile() pictureFile
){
return pictureFile;
}
Import module: GoogleCloudVisionApiModule
:
import { GoogleCloudVisionApiModule } from '@pimba/excalibur/lib';
@Module({
imports: [
GoogleCloudVisionApiModule,
],
})
export class SomeModule {
}
Don't forget to export your google-cloud credentials before start the server.
Inject the GoogleCloudVisionApiService
in your controller
import { GoogleCloudVisionApiService } from '@pimba/excalibur/lib';
@Controller('some')
export class SomeController {
constructor(
private readonly _googleCloudVisionApiService: GoogleCloudVisionApiService,
) {
}
@Get('inspect-image')
@UseInterceptors(
FileInterceptor('image'),
)
async inspectImage(
@UploadedFile() imageFile,
) {
// Fecth the file and get it's buffer.
const imageBuffer = imageFile.buffer;
// Invoke the respective service methods
const text = await this._googleCloudVisionApiService.detectText(imageBuffer);
const faces = await this._googleCloudVisionApiService.detectFaces(imageBuffer);
const explictContent = await this._googleCloudVisionApiService.detectExplicitContent(imageBuffer);
const objects = await this._googleCloudVisionApiService.detectMultipleObjects(imageBuffer);
const properties = await this._googleCloudVisionApiService.detectProperties(imageBuffer);
return {
text,
faces,
explictContent,
objects,
properties,
};
}
}
Method Name | Description | Parameters |
---|---|---|
detectLabels | Detects labels that are in the image | image-url or buffer |
detectFaces | Detects faces that are in the image | image-url or buffer |
detectProperties | Gets the more representative properties from the image such as the most relevant colors | image-url or buffer |
detectLandMarks | Detects places such as names of buildings, monuments, among other things. | image-url or buffer |
detectLogos | Detects all logos that are in the image | image-url or buffer |
detectExplicitContent | Detect some type of explicit content in the image such as violence, racism, etc. | image-url or buffer |
detectMultipleObjects | Detects all objects that are in the image with their respective ubication polygon coordinates | image-url or buffer |
detectText | Detects all text contained in the image | image-url or buffer |
detectHandwrittenText | Detects get handwritten text in an image | image-url or buffer |
Import the module with your projectID.
import { FirebaseModule } from '@pimba/excalibur/lib';
@Module({
imports: [
FirebaseModule.register(
{
projectId: '<your-projectId>',
credential: admin.credential.applicationDefault(),
},
),
],
})
export class SomeModule {
}
If you want use
admin.credential.applicationDefault()
just don't forget to export your Firebase credentials before start the server.
Inject the firebase-service in your controller
import { FirebaseAdminAuthService } from '@pimba/excalibur/lib';
@Controller('some')
export class SomeController {
constructor(
private readonly _firebaseService: FirebaseAdminAuthService
) {
}
}
Use the service:
@Post('register-user')
async registerUser(
@Body() user: {
email: string,
name: string,
password: string,
}
) {
return await this._firebaseService.createUser(
{
disabled: false,
email: user.email,
displayName: user.email,
emailVerified: true,
password: user.password,
}
);
}
The library uses nodemailer to provide a module for sending emails.
Import the module with the transports options:
import {EmailModule} from '@pimba/excalibur/lib';
@Module(
{
imports: [
EmailModule
.register(
{
transport: {
host: 'smtp.some-host.email',
port: 587, // smtp port
secure: false, // true for 465, false for other ports,
auth: {
user: '<your-username-or-email>',
pass: '<your-password>',
},
}
}
)
]
}
)
export class SomeModule {
}
If your want to know more about nodemailer please check its documentation
In order to send emails, your need to inject the service:
@Controller('some-controller')
export class SomeController {
constructor(
private readonly emailService: EmailService,
) {
}
@Get('email')
async sendEmail() {
await this.emailService
.sendMail(
{
from: '<sender>',
to: ['<receiver-1>', '<receiver-2>', '<receiver-3>'], // receiver/receivers
subject: 'Hello',
text: 'Hello World!!',
}
);
return 'OK';
}
}
With the database module you can configure multiple connections and massively insert data for testing or production.
A connection can be defined through a constant or through some other configuration module:
const MYSQL_CONNECTION_CONFIG: TypeOrmModuleOptions = {
type: 'mysql',
host: 'localhost',
port: 30501,
username: 'username',
password: '1234',
database: 'test',
name: 'default',
synchronize: true,
retryDelay: 40000,
retryAttempts: 3,
connectTimeout: 40000,
keepConnectionAlive: true,
dropSchema: true,
charset: 'utf8mb4',
timezone: 'local',
entities: [
...entities,
],
}
Just import the DataBaseModule
, it can handle multiple connections, just type
the name of the database as the key with its respective connection settings as the value.
import {DataBaseModule, DataBaseService} from '@pimba/excalibur/lib';
import {
OTHER_MYSQL_CONNECTION_CONFIG,
MONGODB_CONNECTION_CONFIG,
MYSQL_CONNECTION_CONFIG
} from './config';
@Module({
imports: [
DataBaseModule.forRoot(
{
conections: {
mysql: MYSQL_CONNECTION_CONFIG,
mongodb: MONGODB_CONNECTION_CONFIG,
otherMysql: OTHER_MYSQL_CONNECTION_CONFIG
},
productionFlag: false,
}
),
...MODULES,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
}
To insert bulk data either for development or production, the module can be used to set the way the data will be created.
import {Module} from '@nestjs/common';
import {DataBaseModule} from '@pimba/excalibur/lib';
@Module({
imports: [
DataBaseModule
.forBulkData(
{
dtoClassValidation: UserCreateDTO,
pathDev: '/src/modules/users/bulks/development/users.json',
pathProd: '/dist/modules/users/bulks/production/users.json',
aliasName: 'users',
creationOrder: 1,
entity: UserEntity,
},
),
TypeOrmModule.forFeature([UserEntity]),
],
})
export class UsersModule {
}
- dtoClassValidation: DTO Class for validation
- pathDev: Path of the file with the data for development
- pathProd: Path of the file with the data for production
- aliasName: Alias for the entity (show on logs).
- creationOrder: Order in which the data will be created, this is necessary if the data depends on other data (foreing key). The order can be repeated in other modules.
- entity: Entity Class.
- connection: Database connection name.
You can use
js
files insteadjson
files.
It is a fact that json files are not taken into account when building the project with the typescript transpiler. However, you can use multiple npm packages to handle this like cpy.
To create start massive insertion just use the DataBaseService
on the AppModule
In this example, the massive insertion is handle on onModuleInit
method:
export class AppModule implements OnModuleInit {
constructor(
private readonly _dataBaseService: DataBaseService,
) {
}
onModuleInit(): any {
this.createData();
}
async createBulkData() {
await this._dataBaseService.insertData();
// Show the insertion logs on console
this._dataBaseService.showSummary();
}
}
╔═══════════════════════════════════════════════════════╗
║ default ║
╠═══════════════════════════════════════════════════════╣
║ Order Entity Created Status ║
╠═══════════════════════════════════════════════════════╣
║ 1 Categories 12 OK ║
╠═══════════════════════════════════════════════════════╣
║ 1 Users 90 OK ║
╠═══════════════════════════════════════════════════════╣
║ 2 roles 6 OK ║
╠═══════════════════════════════════════════════════════╣
║ 4 products 0 FAIL ║
╚═══════════════════════════════════════════════════════╝
╔═══════════════════════════════════════════════════════╗
║ mongo_conn ║
╠═══════════════════════════════════════════════════════╣
║ Order Entity Created Status ║
╠═══════════════════════════════════════════════════════╣
║ 1 geo_locations 37 OK ║
╚═══════════════════════════════════════════════════════╝
Errors:
Errors:
╔═══════════════════════════════════════════════════════╗
products
╠═══════════════════════════════════════════════════════╣
validationError
"{\"name\":\"apple\",\"description\":\"Mollit sint proident irure eiusmod mollit occaecat.\",\"category\":6,\"price\":\"10.47\"}"
An instance of ProductoCrearDto has failed the validation:
- property description has failed the following constraints: isAlpha
validationError
The modules for google-cloud-storage and firebase were based on the Aginix Technologies libraries