express-base-controller
Version:
a nodejs api controller
432 lines (386 loc) • 11.5 kB
text/typescript
import {
ObjectId,
} from 'bson';
import {
NextFunction,
Request,
Response,
} from 'express';
import {
Document,
PopulateOptions,
Types,
} from 'mongoose';
import ApiController from './api.controller';
import {
isString,
toNumber,
} from './helpers';
import {
ApiDocument,
ApiModel,
IApiModel,
IApiRequest,
} from './types';
import {
ApiSortQuery,
IApiParsedQuery,
} from './types/IApiQuery';
export type ServerResponsePromise = Promise<void | Response<any, Record<string, any>>>;
const isValidId = Types.ObjectId.isValid;
abstract class BaseController<T extends (ApiDocument)> extends ApiController<T> {
protected filters: string[];
constructor(model: ApiModel<T>) {
super(model);
this.filters = [ 'type', 'deleted' ];
}
public async index(req: IApiRequest, _res: Response, next: NextFunction): Promise<void> {
let query: IApiParsedQuery = {
...req.query,
_q: '',
deleted: false,
limit: 100,
offset: 0,
q: {},
select: {},
sort: null,
total: {},
};
const processedQuery = this.processQuery(req.query, query);
try {
if (typeof this.model.parseQuery === 'function') {
query = this.model.parseQuery(processedQuery);
} else {
query = this.parseQuery(processedQuery);
}
} catch (error) {
return next(error);
}
query.populate = query.populate ? query.populate : [];
req.modelQuery = {
total: query.q,
offset: query.offset,
limit: query.limit,
};
return this.model.find(query.q)
.limit(toNumber(query.limit))
.skip(toNumber(query.offset))
.sort(query.sort)
.select(query.select)
.populate(query.populate)
.exec()
.then((models: T[]) => {
req.data = models;
return next();
})
.catch((err) => next(err));
}
public async read(
req: IApiRequest,
res: Response,
_next: NextFunction,
): ServerResponsePromise {
if (this.hasModel<Document & IApiModel>(req.model)) {
const model = req.model.toObject();
return res.jsonp(model);
} else {
return this.respondModelMissingError(res);
}
}
public async create(
req: IApiRequest,
res: Response,
next: NextFunction,
): ServerResponsePromise {
delete req.body._id;
delete req.body.timestamps;
// eslint-disable-next-line @typescript-eslint/naming-convention
const Model = this.model;
const entity = {
...req.body,
timestamps: {
created: {
by: req.user?.username || 'missing',
},
},
};
const model: T = new Model(entity);
return model.save()
.then((resModel) => (res.status(201).json(resModel.toObject())))
.catch((err) => this.respondValidationError(err, res, next));
}
public async update(
req: IApiRequest,
res: Response,
next: NextFunction,
): ServerResponsePromise {
if (req.body._id === null) {
delete req.body._id;
}
delete req.body.timestamps;
if (this.hasModel(req.model)) {
const model: any = req.model;
Object.keys(req.body).forEach((key) => {
model[key] = req.body[key];
});
model.timestamps.updated.by = req.user?.username || 'missing';
return model.save()
.then((resModel: T) => res.status(200).json(resModel.toObject()))
.catch((err: any) => this.respondValidationError(err, res, next));
} else {
return this.respondModelMissingError(res);
}
}
public async softDelete(
req: IApiRequest,
res: Response,
_next: NextFunction,
): ServerResponsePromise {
if (this.hasModel(req.model)) {
const model = req.model;
model.mark.deleted = true;
model.timestamps.updated.by = req.user?.username || 'missing';
return model.save()
.then((resModel) => res.status(200).jsonp(resModel.toObject()))
.catch((err) => this.respondDeletionError(res, err));
} else {
return Promise.resolve(this.respondModelMissingError(res));
}
}
public async delete(
req: IApiRequest,
res: Response,
_next: NextFunction,
): ServerResponsePromise {
if (this.hasModel(req.model)) {
const model = req.model;
return this.model.deleteOne({ _id: model._id })
.then(() => res.status(200).jsonp(model.toObject()))
.catch((err: unknown) => {
if (err instanceof Error) {
return this.respondDeletionError(res, err);
} else {
return this.respondDeletionError(res, new Error('Unknown error occurred while deleting'));
}
});
} else {
return this.respondModelMissingError(res);
}
}
public async findById(
req: IApiRequest,
res: Response,
next: NextFunction,
id: string | number | ObjectId,
_urlParam?: any,
populate?: PopulateOptions[],
): ServerResponsePromise {
if (isValidId(id)) {
if (typeof populate === 'undefined') {
populate = [];
}
return this.model
.findById<ApiDocument>(id)
// this for sure is not the right way. Inject type into method?
// .findOne({}).populate<{ child: Child }>('child').orFail().then(doc => {
// .populate<Pick<PopulatedParent, 'child'>>('child').orFail().then(doc
.populate<any>(populate)
.exec()
.then((model) => {
if (model === null) {
return this.respondNotFound(id, res, this.model.modelName);
} else if (this.hasModel<ApiDocument>(model)){
req.model = model;
return next();
} else {
return this.respondModelMissingError(res);
}
})
.catch((err) => this.respondServerError(res, err));
} else {
return this.respondInvalidId(res);
}
}
public async stats(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise {
return this.model.countDocuments()
.then((result) => {
if (typeof req.stats !== 'object') {
req.stats = {};
}
req.stats[this.model.collection.name] = result;
return next();
})
.catch((err) => this.respondServerError(res, err));
}
public statsResponse(req: IApiRequest, res: Response, _next: NextFunction): Response {
if (typeof req.stats !== 'object') {
req.stats = {};
}
return res.status(200).json(req.stats);
}
public async statistics(req: IApiRequest, res: Response, next: NextFunction): ServerResponsePromise {
if (typeof this.model.statistics === 'function') {
const query = req.dateRange || {};
return this.model.statistics(query).then((result) => {
if (typeof req.stats !== 'object') {
req.stats = {};
}
req.stats[this.model.collection.name] = result;
return next();
})
.catch((err) => this.respondServerError(res, err));
} else {
return this.stats(req, res, next);
}
}
public parseDateRange(
req: IApiRequest,
_res: Response,
next: NextFunction,
_id: string,
_urlParam: string,
): void {
// FIXME: this function is called twice for /year/month ....
const year = parseInt(req.params.year, 10);
let month = parseInt(req.params.month, 10);
let toMonth = 12;
if (!isNaN(year)) {
if (isNaN(month)) {
month = 0;
} else {
month = Math.max(Math.min(month, 12), 1);
toMonth = --month + 1;
}
let from: Date = new Date();
from = new Date(from.setFullYear(year, month, 1));
from = new Date(from.setHours(0, 0, 0, 0));
let to = new Date(from.valueOf());
to = new Date(to.setFullYear(year, toMonth, 1));
if (typeof req.stats !== 'object') {
req.stats = {};
}
req.stats.range = {
from: from,
to: to,
};
req.dateRange = { $and: [{ date: { $gte: from } }, { date: { $lt: to } }] };
}
return next();
}
public processQuery(
query: Request['query'],
defaultQuery: Readonly<IApiParsedQuery>,
): IApiParsedQuery {
const modelQuery: IApiParsedQuery = {
...query,
_q: query.q?.toString() || '',
offset: 0,
deleted: false,
limit: 100,
q: {},
select: {},
sort: null,
total: {},
};
if (typeof query.offset === 'string') {
modelQuery.offset = this.parsePagination(query.offset, defaultQuery.offset);
}
if (typeof query.limit === 'string') {
modelQuery.limit = this.parsePagination(query.limit, defaultQuery.limit);
}
if (typeof query.sort === 'string') {
modelQuery.sort = this.parseSort(query.sort);
}
if (typeof query.select === 'string') {
modelQuery.select = query.select.split(' ').reduce((acc, cur) => ({
...acc,
[cur]: true,
}), {} as Record<string, boolean>);
}
if (typeof query.filter === 'string') {
modelQuery.filter = this.parseFilter(query.filter);
}
if (typeof query.deleted === 'string') {
modelQuery.deleted = query.deleted === 'true';
}
return modelQuery;
}
public parseSort(sort: string | null = null): ApiSortQuery | null {
if (sort) {
const parsedSort: ApiSortQuery = {};
let _sort: Record<string, string | number> = {};
try {
_sort = JSON.parse(sort);
} catch (error: unknown) {
/* istanbul ignore next */
if (error instanceof SyntaxError) {
_sort = sort.split(' ')
.filter(s => /^\w+$/.test(s))
.reduce((acc: any, cur) => {
acc[cur] = 1;
return acc;
}, {});
} else {
throw error;
}
}
Object.entries(_sort).forEach(([ key, value ]) => {
const order = isString(value) ? parseInt(value, 10) : value;
parsedSort[key] = isNaN(order) ? 1 : Math.min(Math.max(order, -1), 1) as -1 | 1;
});
if (Object.keys(parsedSort).length === 0) {
parsedSort['date'] = -1;
}
return parsedSort;
} else {
return null;
}
}
public parseFilter(filterQuery: string | null = null): Record<string, string> {
let filter: Record<string, string> = {};
try {
filter = filterQuery ? JSON.parse(filterQuery.replace(/\'/g, '"')) : {};
} catch (e) {
filter = {};
}
const allowedFilters: Record<string, string> = {};
this.filters.forEach(f => {
if (typeof filter[f] !== 'undefined' && filter[f] !== null) {
allowedFilters[f] = filter[f].toString();
}
});
return allowedFilters;
}
public parsePagination(value: string, defaultValue: string | number): number {
const _value = toNumber(value);
const _default = toNumber(defaultValue);
return isNaN(_value) ? _default : _value;
}
public parseQuery(query: Partial<IApiParsedQuery>): IApiParsedQuery {
return {
_q: query._q || '',
q: {},
offset: query.offset || 0,
limit: query.limit || 100,
sort: query.sort
? {
...query.sort,
}
: null,
filter: {
...query.filter,
},
populate: query.populate
? [ ...query.populate ]
: [],
deleted: !!query.deleted,
select: query.select
? {
...query.select,
}
: {},
total: {},
};
}
}
export default BaseController;