-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor(api): extract common logic into
api-common
package (#267)
* 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
Showing
29 changed files
with
510 additions
and
346 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
2 changes: 0 additions & 2 deletions
2
packages/api-http/source/types/index.ts → ...ages/api-common/source/contracts/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
2 changes: 1 addition & 1 deletion
2
...s/api-http/source/schemas/schemas.test.ts → packages/api-common/source/schemas.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 => { | ||
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}.`))) { | ||
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), | ||
}); |
Oops, something went wrong.