Skip to content

Commit

Permalink
refactor(api): extract common logic into api-common package (#267)
Browse files Browse the repository at this point in the history
* extract api building blocks into api-common

* update yml

* lint

* style: resolve style guide violations

* fix

* revert lint

---------

Co-authored-by: oXtxNt9U <[email protected]>
  • Loading branch information
oXtxNt9U and oXtxNt9U authored Oct 6, 2023
1 parent bca9bfd commit 4e7ee92
Show file tree
Hide file tree
Showing 29 changed files with 510 additions and 346 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ jobs:
run: pnpm run build
- name: Test api
run: cd packages/api && pnpm run test
- name: Test api-common
run: cd packages/api-common && pnpm run test
- name: Test api-development
run: cd packages/api-development && pnpm run test
- name: Test bootstrap
Expand Down
7 changes: 7 additions & 0 deletions packages/api-common/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Path-based git attributes
# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html

# Ignore all test and documentation with "export-ignore".
/.gitattributes export-ignore
/.gitignore export-ignore
/README.md export-ignore
46 changes: 46 additions & 0 deletions packages/api-common/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@mainsail/api-common",
"version": "0.0.1",
"description": "Common API building blocks for Mainsail",
"license": "MIT",
"contributors": [],
"main": "distribution/index.js",
"types": "distribution/index.d.ts",
"files": [
"/distribution"
],
"scripts": {
"build": "pnpm run clean && tsc",
"build:watch": "pnpm run clean && tsc -w",
"clean": "del distribution",
"test": "uvu -r tsm source .test.ts",
"test:coverage": "c8 pnpm run test",
"test:coverage:html": "c8 -r html --all pnpm run test",
"test:file": "uvu -r tsm source"
},
"dependencies": {
"@hapi/boom": "9.1.4",
"@hapi/hapi": "20.1.5",
"@hapi/hoek": "9.2.0",
"@mainsail/api-database": "workspace:*",
"@mainsail/container": "workspace:*",
"@mainsail/contracts": "workspace:*",
"@mainsail/kernel": "workspace:*",
"@mainsail/utils": "workspace:*",
"joi": "17.9.2",
"nanomatch": "1.2.13",
"rate-limiter-flexible": "1.3.2",
"semver": "6.3.0"
},
"devDependencies": {
"@types/hapi__boom": "7.4.1",
"@types/hapi__hapi": "21.0.0",
"@types/hapi__joi": "17.1.7",
"@types/ip": "1.1.0",
"@types/semver": "6.2.3",
"uvu": "^0.5.6"
},
"engines": {
"node": ">=20.x"
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
export * as Resources from "./resources";

export type Sorting = {
property: string;
direction: "asc" | "desc";
Expand Down
120 changes: 120 additions & 0 deletions packages/api-common/source/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Boom from "@hapi/boom";
import Hapi from "@hapi/hapi";
import { inject, injectable, tagged } from "@mainsail/container";
import { Contracts, Identifiers } from "@mainsail/contracts";
import { Providers } from "@mainsail/kernel";

import { Options, Pagination, Resource, ResultsPage, Sorting } from "./contracts";
import { SchemaObject } from "./schemas";

@injectable()
export abstract class AbstractController {
@inject(Identifiers.Application)
protected readonly app!: Contracts.Kernel.Application;

@inject(Identifiers.PluginConfiguration)
@tagged("plugin", "api-http")
protected readonly apiConfiguration!: Providers.PluginConfiguration;

protected getQueryPagination(query: Hapi.RequestQuery): Pagination {
return {
limit: query.limit,
offset: (query.page - 1) * query.limit || 0,
};
}

protected getQueryCriteria(query: Hapi.RequestQuery, schemaObject: SchemaObject): unknown {
const schemaObjectKeys = Object.keys(schemaObject);
const criteria = {};
for (const [key, value] of Object.entries(query)) {
if (schemaObjectKeys.includes(key)) {
criteria[key] = value;
}
}
return criteria;
}

protected getListingPage(request: Hapi.Request): Pagination {
const pagination = {
limit: request.query.limit || 100,
offset: (request.query.page - 1) * request.query.limit || 0,
};

if (request.query.offset) {
pagination.offset = request.query.offset;
}

return pagination;
}

protected getListingOrder(request: Hapi.Request): Sorting {
if (!request.query.orderBy) {
return [];
}

const orderBy = Array.isArray(request.query.orderBy) ? request.query.orderBy : request.query.orderBy.split(",");

return orderBy.map((s: string) => ({
direction: s.split(":")[1] === "desc" ? "desc" : "asc",
property: s.split(":")[0],
}));
}

protected getListingOptions(): Options {
const estimateTotalCount = this.apiConfiguration.getOptional<boolean>("options.estimateTotalCount", true);

return {
estimateTotalCount,
};
}

protected async respondWithResource(data, transformer, transform = true): Promise<any> {
if (!data) {
return Boom.notFound();
}

return { data: await this.toResource(data, transformer, transform) };
}

protected async respondWithCollection(data, transformer, transform = true): Promise<object> {
return {
data: await this.toCollection(data, transformer, transform),
};
}

protected async toResource<T, R extends Resource>(
item: T,
transformer: new () => R,
transform = true,
): Promise<ReturnType<R["raw"]> | ReturnType<R["transform"]>> {
const resource = this.app.resolve<R>(transformer);

if (transform) {
return resource.transform(item) as ReturnType<R["transform"]>;
} else {
return resource.raw(item) as ReturnType<R["raw"]>;
}
}

protected async toCollection<T, R extends Resource>(
items: T[],
transformer: new () => R,
transform = true,
): Promise<ReturnType<R["raw"]>[] | ReturnType<R["transform"]>[]> {
return Promise.all(items.map(async (item) => await this.toResource(item, transformer, transform)));
}

protected async toPagination<T, R extends Resource>(
resultsPage: ResultsPage<T>,
transformer: new () => R,
transform = true,
): Promise<ResultsPage<ReturnType<R["raw"]>> | ResultsPage<ReturnType<R["transform"]>>> {
const items = await this.toCollection(resultsPage.results, transformer, transform);

return { ...resultsPage, results: items };
}

protected getEmptyPage(): ResultsPage<any> {
return { meta: { totalCountIsEstimate: false }, results: [], totalCount: 0 };
}
}
4 changes: 4 additions & 0 deletions packages/api-common/source/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * as Contracts from "./contracts";
export * from "./controller";
export * as Schemas from "./schemas";
export * from "./server";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Joi from "joi";
import { describe, Sandbox } from "../../../test-framework";
import { describe } from "../../test-framework";
import * as schemas from "./schemas";

describe<{}>("Schemas", ({ it, assert }) => {
Expand Down
92 changes: 92 additions & 0 deletions packages/api-common/source/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Search } from "@mainsail/api-database";
import Joi from "joi";

const isSchema = (value: Joi.Schema | SchemaObject): value is Joi.Schema => Joi.isSchema(value);

// Criteria

export type SchemaObject = {
[x: string]: Joi.Schema | SchemaObject;
};

export const createCriteriaSchema = (schemaObject: SchemaObject): Joi.ObjectSchema => {
const schema = {};

for (const [key, value] of Object.entries(schemaObject)) {
schema[key] = Joi.array()
.single()
.items(isSchema(value) ? value : createCriteriaSchema(value));
}

return Joi.object(schema);
};

export const createRangeCriteriaSchema = (item: Joi.Schema): Joi.Schema =>
Joi.alternatives(item, Joi.object({ from: item, to: item }).or("from", "to"));

// Sorting

export const createSortingSchema = (
schemaObject: SchemaObject,
wildcardPaths: string[] = [],
transform = true,
): Joi.ObjectSchema => {

Check warning on line 33 in packages/api-common/source/schemas.ts

View workflow job for this annotation

GitHub Actions / source (20.x)

Refactor this function to reduce its Cognitive Complexity from 34 to the 15 allowed
const getObjectPaths = (object: SchemaObject): string[] =>
Object.entries(object).flatMap(([key, value]) =>
isSchema(value) ? key : getObjectPaths(value).map((p) => `${key}.${p}`),
);

const exactPaths = getObjectPaths(schemaObject);

const orderBy = Joi.custom((value, helpers) => {
if (value === "") {
return [];
}

const sorting: Search.Sorting = [];

const sortingCriteria: string[] = Array.isArray(value) ? value : [value];

for (const criteria of sortingCriteria) {
for (const item of criteria.split(",")) {
const pair = item.split(":");
const property = String(pair[0]);
const direction = pair.length === 1 ? "asc" : pair[1];

if (!exactPaths.includes(property) && !wildcardPaths.find((wp) => property.startsWith(`${wp}.`))) {

Check warning on line 56 in packages/api-common/source/schemas.ts

View workflow job for this annotation

GitHub Actions / source (20.x)

Prefer `.some(…)` over `.find(…)`
return helpers.message({
custom: `Unknown orderBy property '${property}'`,
});
}

if (direction !== "asc" && direction !== "desc") {
return helpers.message({
custom: `Unexpected orderBy direction '${direction}' for property '${property}'`,
});
}

if (transform) {
sorting.push({ direction: direction, property });
}
}
}

if (!transform) {
return value;
}

return sorting;
});

if (transform) {
return Joi.object({ orderBy: orderBy.default([]) });
} else {
return Joi.object({ orderBy });
}
};

export const pagination = Joi.object({
limit: Joi.number().integer().min(1).default(100).max(Joi.ref("$configuration.plugins.pagination.limit")),
offset: Joi.number().integer().min(0),
page: Joi.number().integer().positive().default(1),
});
Loading

0 comments on commit 4e7ee92

Please sign in to comment.