axe-api 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Release Notes
2
2
 
3
+ ## [0.22.0 (2023-01-29)](https://github.com/axe-api/axe-api/compare/0.22.0...0.21.0)
4
+
5
+ ### Features
6
+
7
+ - Added Soft-Deleting feature [#41](https://github.com/axe-api/axe-api/issues/41)
8
+
9
+ ### Fixed
10
+
11
+ - Fixed model relation route URLs [#141](https://github.com/axe-api/axe-api/issues/141)
12
+
3
13
  ## [0.21.0 (2022-12-28)](https://github.com/axe-api/axe-api/compare/0.21.0...0.20.4)
4
14
 
5
15
  ### Features
@@ -113,13 +113,12 @@ class RouterBuilder {
113
113
  return;
114
114
  }
115
115
  // We should different parameter name for child routes
116
- const primaryKey = this.getPrimaryKeyName(model);
117
116
  const subRelations = model.relations.filter((item) => item.type === Enums_1.Relationships.HAS_MANY);
118
117
  for (const relation of subRelations) {
119
118
  const child = model.children.find((item) => item.name === relation.model);
120
119
  // It should be recursive
121
120
  if (child) {
122
- yield this.createRouteByModel(child, modelList, `${urlPrefix}${resource}/:${primaryKey}/`, model, relation);
121
+ yield this.createRouteByModel(child, modelList, `${urlPrefix}${resource}/:${(0, change_case_1.camelCase)(relation.foreignKey)}/`, model, relation);
123
122
  }
124
123
  }
125
124
  });
@@ -140,6 +139,10 @@ class RouterBuilder {
140
139
  app.delete(url, middlewares, handler);
141
140
  docs.push(Enums_1.HttpMethods.DELETE, url, model);
142
141
  break;
142
+ case Enums_1.HandlerTypes.FORCE_DELETE:
143
+ app.delete(url, middlewares, handler);
144
+ docs.push(Enums_1.HttpMethods.DELETE, url, model);
145
+ break;
143
146
  case Enums_1.HandlerTypes.INSERT:
144
147
  app.post(url, middlewares, handler);
145
148
  docs.push(Enums_1.HttpMethods.POST, url, model);
@@ -24,6 +24,7 @@ export declare enum HandlerTypes {
24
24
  SHOW = "show",
25
25
  UPDATE = "update",
26
26
  DELETE = "destroy",
27
+ FORCE_DELETE = "force_delete",
27
28
  PATCH = "patch",
28
29
  ALL = "all"
29
30
  }
@@ -33,6 +34,8 @@ export declare enum HookFunctionTypes {
33
34
  onBeforeUpdate = "onBeforeUpdate",
34
35
  onBeforeDeleteQuery = "onBeforeDeleteQuery",
35
36
  onBeforeDelete = "onBeforeDelete",
37
+ onBeforeForceDeleteQuery = "onBeforeForceDeleteQuery",
38
+ onBeforeForceDelete = "onBeforeForceDelete",
36
39
  onBeforePaginate = "onBeforePaginate",
37
40
  onBeforeAll = "onBeforeAll",
38
41
  onBeforeShow = "onBeforeShow",
@@ -41,6 +44,8 @@ export declare enum HookFunctionTypes {
41
44
  onAfterUpdate = "onAfterUpdate",
42
45
  onAfterDeleteQuery = "onAfterDeleteQuery",
43
46
  onAfterDelete = "onAfterDelete",
47
+ onAfterForceDeleteQuery = "onAfterForceDeleteQuery",
48
+ onAfterForceDelete = "onAfterForceDelete",
44
49
  onAfterPaginate = "onAfterPaginate",
45
50
  onAfterAll = "onAfterAll",
46
51
  onAfterShow = "onAfterShow"
@@ -30,6 +30,7 @@ var HandlerTypes;
30
30
  HandlerTypes["SHOW"] = "show";
31
31
  HandlerTypes["UPDATE"] = "update";
32
32
  HandlerTypes["DELETE"] = "destroy";
33
+ HandlerTypes["FORCE_DELETE"] = "force_delete";
33
34
  HandlerTypes["PATCH"] = "patch";
34
35
  HandlerTypes["ALL"] = "all";
35
36
  })(HandlerTypes = exports.HandlerTypes || (exports.HandlerTypes = {}));
@@ -40,6 +41,8 @@ var HookFunctionTypes;
40
41
  HookFunctionTypes["onBeforeUpdate"] = "onBeforeUpdate";
41
42
  HookFunctionTypes["onBeforeDeleteQuery"] = "onBeforeDeleteQuery";
42
43
  HookFunctionTypes["onBeforeDelete"] = "onBeforeDelete";
44
+ HookFunctionTypes["onBeforeForceDeleteQuery"] = "onBeforeForceDeleteQuery";
45
+ HookFunctionTypes["onBeforeForceDelete"] = "onBeforeForceDelete";
43
46
  HookFunctionTypes["onBeforePaginate"] = "onBeforePaginate";
44
47
  HookFunctionTypes["onBeforeAll"] = "onBeforeAll";
45
48
  HookFunctionTypes["onBeforeShow"] = "onBeforeShow";
@@ -48,6 +51,8 @@ var HookFunctionTypes;
48
51
  HookFunctionTypes["onAfterUpdate"] = "onAfterUpdate";
49
52
  HookFunctionTypes["onAfterDeleteQuery"] = "onAfterDeleteQuery";
50
53
  HookFunctionTypes["onAfterDelete"] = "onAfterDelete";
54
+ HookFunctionTypes["onAfterForceDeleteQuery"] = "onAfterForceDeleteQuery";
55
+ HookFunctionTypes["onAfterForceDelete"] = "onAfterForceDelete";
51
56
  HookFunctionTypes["onAfterPaginate"] = "onAfterPaginate";
52
57
  HookFunctionTypes["onAfterAll"] = "onAfterAll";
53
58
  HookFunctionTypes["onAfterShow"] = "onAfterShow";
@@ -20,6 +20,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
20
20
  const conditions = queryParser.get(req.query);
21
21
  // Creating a new database query
22
22
  const query = database.from(model.instance.table);
23
+ // If there is a deletedAtColumn, it means that this table support soft-delete
24
+ (0, Helpers_1.addSoftDeleteQuery)(model, conditions, query);
23
25
  // Users should be able to select some fields to show.
24
26
  queryParser.applyFields(query, conditions.fields);
25
27
  // Binding parent id if there is.
@@ -20,6 +20,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
20
20
  const query = database
21
21
  .from(model.instance.table)
22
22
  .where(model.instance.primaryKey, req.params[model.instance.primaryKey]);
23
+ // If there is a deletedAtColumn, it means that this table support soft-delete
24
+ (0, Helpers_1.addSoftDeleteQuery)(model, null, query);
23
25
  // If there is a relation, we should bind it
24
26
  (0, Helpers_1.addForeignKeyQuery)(req, query, relation, parentModel);
25
27
  yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeDeleteQuery, Object.assign(Object.assign({}, pack), { query }));
@@ -31,7 +33,15 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
31
33
  item }));
32
34
  yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeDelete, Object.assign(Object.assign({}, pack), { query,
33
35
  item }));
34
- yield query.delete();
36
+ // If there is a deletedAtColumn, it means that this table support soft-delete
37
+ if (model.instance.deletedAtColumn) {
38
+ yield query.update({
39
+ [model.instance.deletedAtColumn]: new Date(),
40
+ });
41
+ }
42
+ else {
43
+ yield query.delete();
44
+ }
35
45
  yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onAfterDelete, Object.assign(Object.assign({}, pack), { item }));
36
46
  return res.json();
37
47
  });
@@ -0,0 +1,3 @@
1
+ import { IRequestPack } from "../Interfaces";
2
+ declare const _default: (pack: IRequestPack) => Promise<import("express").Response<any, Record<string, any>>>;
3
+ export default _default;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __importDefault = (this && this.__importDefault) || function (mod) {
12
+ return (mod && mod.__esModule) ? mod : { "default": mod };
13
+ };
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ const Helpers_1 = require("./Helpers");
16
+ const Enums_1 = require("../Enums");
17
+ const ApiError_1 = __importDefault(require("../Exceptions/ApiError"));
18
+ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
19
+ const { model, req, res, database, relation, parentModel } = pack;
20
+ const query = database
21
+ .from(model.instance.table)
22
+ .where(model.instance.primaryKey, req.params[model.instance.primaryKey]);
23
+ // If there is a deletedAtColumn, it means that this table support soft-delete
24
+ if (model.instance.deletedAtColumn === null) {
25
+ throw new ApiError_1.default("You can use force delete only soft-delete supported models.");
26
+ }
27
+ // If there is a relation, we should bind it
28
+ (0, Helpers_1.addForeignKeyQuery)(req, query, relation, parentModel);
29
+ yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeForceDeleteQuery, Object.assign(Object.assign({}, pack), { query }));
30
+ const item = yield query.first();
31
+ if (!item) {
32
+ throw new ApiError_1.default(`The item is not found on ${model.name}.`);
33
+ }
34
+ yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onAfterForceDeleteQuery, Object.assign(Object.assign({}, pack), { query,
35
+ item }));
36
+ yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeForceDelete, Object.assign(Object.assign({}, pack), { query,
37
+ item }));
38
+ yield query.delete();
39
+ yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onAfterForceDelete, Object.assign(Object.assign({}, pack), { item }));
40
+ return res.json();
41
+ });
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const AllHandler_1 = __importDefault(require("./AllHandler"));
7
7
  const DestroyHandler_1 = __importDefault(require("./DestroyHandler"));
8
+ const ForceDestroyHandler_1 = __importDefault(require("./ForceDestroyHandler"));
8
9
  const PaginateHandler_1 = __importDefault(require("./PaginateHandler"));
9
10
  const PatchHandler_1 = __importDefault(require("./PatchHandler"));
10
11
  const UpdateHandler_1 = __importDefault(require("./UpdateHandler"));
@@ -18,6 +19,8 @@ class HandlerFactory {
18
19
  return AllHandler_1.default;
19
20
  case Enums_1.HandlerTypes.DELETE:
20
21
  return DestroyHandler_1.default;
22
+ case Enums_1.HandlerTypes.FORCE_DELETE:
23
+ return ForceDestroyHandler_1.default;
21
24
  case Enums_1.HandlerTypes.INSERT:
22
25
  return StoreHandler_1.default;
23
26
  case Enums_1.HandlerTypes.PAGINATE:
@@ -1,5 +1,5 @@
1
1
  import { Request } from "express";
2
- import { IModelService, IRelation, IHookParameter } from "../Interfaces";
2
+ import { IModelService, IRelation, IHookParameter, IQuery } from "../Interfaces";
3
3
  import { Knex } from "knex";
4
4
  import { IWith } from "../Interfaces";
5
5
  import { HandlerTypes, HookFunctionTypes, TimestampColumns } from "../Enums";
@@ -11,4 +11,5 @@ export declare const getParentColumn: (relation: IRelation | null) => string | n
11
11
  export declare const addForeignKeyQuery: (request: Request, query: Knex.QueryBuilder, relation: IRelation | null, parentModel: IModelService | null) => void;
12
12
  export declare const serializeData: (itemArray: any[] | any, modelSerializer: (data: any, request: Request) => void, handler: HandlerTypes, request: Request) => Promise<any[]>;
13
13
  export declare const filterHiddenFields: (itemArray: any[], hiddens: string[] | null) => void;
14
+ export declare const addSoftDeleteQuery: (model: IModelService, conditions: IQuery | null, query: Knex.QueryBuilder) => void;
14
15
  export declare const getRelatedData: (data: any[], withArray: IWith[], model: IModelService, modelList: ModelListService, database: Knex | Knex.Transaction, handler: HandlerTypes, request: Request) => Promise<void>;
@@ -12,7 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  return (mod && mod.__esModule) ? mod : { "default": mod };
13
13
  };
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
- exports.getRelatedData = exports.filterHiddenFields = exports.serializeData = exports.addForeignKeyQuery = exports.getParentColumn = exports.callHooks = exports.getMergedFormData = exports.bindTimestampValues = void 0;
15
+ exports.getRelatedData = exports.addSoftDeleteQuery = exports.filterHiddenFields = exports.serializeData = exports.addForeignKeyQuery = exports.getParentColumn = exports.callHooks = exports.getMergedFormData = exports.bindTimestampValues = void 0;
16
16
  const change_case_1 = require("change-case");
17
17
  const Enums_1 = require("../Enums");
18
18
  const ApiError_1 = __importDefault(require("../Exceptions/ApiError"));
@@ -138,6 +138,14 @@ const filterHiddenFields = (itemArray, hiddens) => {
138
138
  });
139
139
  };
140
140
  exports.filterHiddenFields = filterHiddenFields;
141
+ const addSoftDeleteQuery = (model, conditions, query) => {
142
+ // TODO: Trashed feature will be implemented later
143
+ // (conditions === null || conditions?.trashed === false)
144
+ if (model.instance.deletedAtColumn) {
145
+ query.whereNull(model.instance.deletedAtColumn);
146
+ }
147
+ };
148
+ exports.addSoftDeleteQuery = addSoftDeleteQuery;
141
149
  const getRelatedData = (data, withArray, model, modelList, database, handler, request) => __awaiter(void 0, void 0, void 0, function* () {
142
150
  if (withArray.length === 0) {
143
151
  return;
@@ -192,10 +200,13 @@ const getRelatedData = (data, withArray, model, modelList, database, handler, re
192
200
  selectColumns.push(...requiredForeignKeys);
193
201
  }
194
202
  selectColumns = uniqueByMap(selectColumns);
203
+ const foreignModelQuery = database(foreignModel.instance.table).select(selectColumns);
204
+ // If the model is supported soft-delete we should check the data.
205
+ if (foreignModel.instance.deletedAtColumn) {
206
+ foreignModelQuery.whereNull(foreignModel.instance.deletedAtColumn);
207
+ }
195
208
  // Fetching related records by foreignKey and primary key values.
196
- let relatedRecords = yield database(foreignModel.instance.table)
197
- .select(selectColumns)
198
- .whereIn(searchFieldKey, parentPrimaryKeyValues);
209
+ let relatedRecords = yield foreignModelQuery.whereIn(searchFieldKey, parentPrimaryKeyValues);
199
210
  // We should serialize related data if there is any serialization function
200
211
  relatedRecords = yield (0, exports.serializeData)(relatedRecords, foreignModel.instance.serialize, handler, request);
201
212
  // We should hide hidden fields if there is any
@@ -20,6 +20,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
20
20
  const conditions = queryParser.get(req.query);
21
21
  // Creating a new database query
22
22
  const query = database.from(model.instance.table);
23
+ // If there is a deletedAtColumn, it means that this table support soft-delete
24
+ (0, Helpers_1.addSoftDeleteQuery)(model, conditions, query);
23
25
  // Users should be able to select some fields to show.
24
26
  queryParser.applyFields(query, conditions.fields);
25
27
  // Binding parent id if there is.
@@ -21,6 +21,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
21
21
  const query = database.from(model.instance.table);
22
22
  // If there is a relation, we should bind it
23
23
  (0, Helpers_1.addForeignKeyQuery)(req, query, relation, parentModel);
24
+ // If there is a deletedAtColumn, it means that this table support soft-delete
25
+ (0, Helpers_1.addSoftDeleteQuery)(model, null, query);
24
26
  yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeUpdateQuery, Object.assign(Object.assign({}, pack), { query }));
25
27
  let item = yield query
26
28
  .where(model.instance.primaryKey, req.params[model.instance.primaryKey])
@@ -24,6 +24,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
24
24
  const conditions = queryParser.get(req.query);
25
25
  // Fetching item
26
26
  const query = database.from(model.instance.table);
27
+ // If there is a deletedAtColumn, it means that this table support soft-delete
28
+ (0, Helpers_1.addSoftDeleteQuery)(model, conditions, query);
27
29
  // Users should be able to select some fields to show.
28
30
  queryParser.applyFields(query, conditions.fields);
29
31
  // If there is a relation, we should bind it
@@ -21,6 +21,8 @@ exports.default = (pack) => __awaiter(void 0, void 0, void 0, function* () {
21
21
  const query = database.from(model.instance.table);
22
22
  // If there is a relation, we should bind it
23
23
  (0, Helpers_1.addForeignKeyQuery)(req, query, relation, parentModel);
24
+ // If there is a deletedAtColumn, it means that this table support soft-delete
25
+ (0, Helpers_1.addSoftDeleteQuery)(model, null, query);
24
26
  yield (0, Helpers_1.callHooks)(model, Enums_1.HookFunctionTypes.onBeforeUpdateQuery, Object.assign(Object.assign({}, pack), { query }));
25
27
  let item = yield query
26
28
  .where(model.instance.primaryKey, req.params[model.instance.primaryKey])
@@ -120,6 +120,7 @@ export interface IRawQuery {
120
120
  sort: string | null;
121
121
  fields: string | null;
122
122
  with: string | null;
123
+ trashed: string | null;
123
124
  }
124
125
  export interface ISortField {
125
126
  name: string;
@@ -138,6 +139,7 @@ export interface IQuery {
138
139
  sort: ISortField[];
139
140
  fields: string[];
140
141
  with: IWith[];
142
+ trashed: boolean;
141
143
  }
142
144
  export interface IWhere {
143
145
  prefix: string | null;
@@ -11,6 +11,7 @@ declare class Model {
11
11
  get hiddens(): string[];
12
12
  get createdAtColumn(): string | null;
13
13
  get updatedAtColumn(): string | null;
14
+ get deletedAtColumn(): string | null;
14
15
  get transaction(): boolean | IHandlerBasedTransactionConfig | IHandlerBasedTransactionConfig[] | null;
15
16
  get ignore(): boolean;
16
17
  getFillableFields(methodType: HttpMethods): string[];
@@ -35,6 +35,9 @@ class Model {
35
35
  get updatedAtColumn() {
36
36
  return "updated_at";
37
37
  }
38
+ get deletedAtColumn() {
39
+ return null;
40
+ }
38
41
  get transaction() {
39
42
  return null;
40
43
  }
@@ -175,6 +175,9 @@ class QueryService {
175
175
  sort: this.parseSortingOptions(sections.sort),
176
176
  q: this.parseCondition(sections.q),
177
177
  with: withQueryResolver.resolve((sections === null || sections === void 0 ? void 0 : sections.with) || ""),
178
+ trashed: (sections === null || sections === void 0 ? void 0 : sections.trashed)
179
+ ? sections.trashed.trim() === "true" || sections.trashed.trim() === "1"
180
+ : false,
178
181
  };
179
182
  this.addRelationColumns(query.with);
180
183
  return query;
@@ -57,6 +57,9 @@ class SchemaValidatorService {
57
57
  if (model.instance.updatedAtColumn) {
58
58
  columns.push(model.instance.updatedAtColumn);
59
59
  }
60
+ if (model.instance.deletedAtColumn) {
61
+ columns.push(model.instance.deletedAtColumn);
62
+ }
60
63
  return columns;
61
64
  };
62
65
  this.checkHasManyRelation = (modelList, model, relation) => {
@@ -20,4 +20,5 @@ export declare const API_ROUTE_TEMPLATES: {
20
20
  update: (prefix: string, parentUrl: string, resource: string, primaryKey: string) => string;
21
21
  patch: (prefix: string, parentUrl: string, resource: string, primaryKey: string) => string;
22
22
  destroy: (prefix: string, parentUrl: string, resource: string, primaryKey: string) => string;
23
+ force_delete: (prefix: string, parentUrl: string, resource: string, primaryKey: string) => string;
23
24
  };
@@ -46,6 +46,7 @@ exports.DEFAULT_METHODS_OF_MODELS = [
46
46
  "hiddens",
47
47
  "createdAtColumn",
48
48
  "updatedAtColumn",
49
+ "deletedAtColumn",
49
50
  "transaction",
50
51
  "ignore",
51
52
  "getFillableFields",
@@ -59,4 +60,5 @@ exports.API_ROUTE_TEMPLATES = {
59
60
  [Enums_1.HandlerTypes.UPDATE]: (prefix, parentUrl, resource, primaryKey) => `/${prefix}/${parentUrl}${resource}/:${primaryKey}`,
60
61
  [Enums_1.HandlerTypes.PATCH]: (prefix, parentUrl, resource, primaryKey) => `/${prefix}/${parentUrl}${resource}/:${primaryKey}`,
61
62
  [Enums_1.HandlerTypes.DELETE]: (prefix, parentUrl, resource, primaryKey) => `/${prefix}/${parentUrl}${resource}/:${primaryKey}`,
63
+ [Enums_1.HandlerTypes.FORCE_DELETE]: (prefix, parentUrl, resource, primaryKey) => `/${prefix}/${parentUrl}${resource}/:${primaryKey}/force`,
62
64
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "axe-api",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "AXE API is a simple tool which has been created based on Express and Knex.js to create Rest APIs quickly.",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
package/readme.md CHANGED
@@ -145,30 +145,6 @@ If you can see that response, it means that your project is running properly.
145
145
 
146
146
  Axe API has great documentation. Please [check it out in here](https://axe-api.com/).
147
147
 
148
- ## How To Run Integration Tests
149
-
150
- > You have to have **Docker** and **Docker Compose** on your local development environment to run integration tests.
151
-
152
- Execute the following commands to prepare the integration app
153
-
154
- ```sh
155
- cd tests/integrations && npm install && npm ci && npm run build --if-present
156
- ```
157
-
158
- Execute the following commands to prepare the database;
159
-
160
- ```sh
161
- docker-compose -f "./tests/integrations/docker-compose.mysql8.yml" up -d --build
162
- ```
163
-
164
- > To down the database, you can use the following command; `docker-compose -f "./tests/integrations/docker-compose.mysql8.yml" up -d --build`
165
-
166
- You can execute the following command to execute tests;
167
-
168
- ```sh
169
- npm run test:integration:mysql8
170
- ```
171
-
172
148
  ## License
173
149
 
174
150
  [MIT License](LICENSE)