/* eslint-disable @typescript-eslint/ban-types,@typescript-eslint/no-explicit-any,max-params */ import * as Boom from "@hapi/boom"; import * as Hapi from "@hapi/hapi"; import { CrudRepository } from "../records/crud-repository"; import { createResponse } from "../helpers/response"; import { PgRecordInfo, UnsavedR, SavedR, KeyType, } from "../records/record-info"; /** * * A generic controller that handles exposes a [[CrudRepository]] as HTTP * endpoints with full POST, PUT, GET, DELETE semantics. * * The controller yanks the instance of the crud repository out of the request at runtime. * This assumes you're following the pattern exposed with the hapi-pg-promise plugin. * * @typeParam ID The type of the id column * @typeParam T The type of the record */ export abstract class AbstractCrudController< TUnsavedR, TSavedR extends TUnsavedR & IdKeyT, IdKeyT extends object > { /** * @param repoName the key at which the repository for the record can be accessed (that is, request.db[repoName]) * @param paramsIdField the placeholder used in the Hapi route for the id of the record * @param dbDecoration the decorated function on the request to use (defaults to request.db()) */ abstract repoName: string; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore abstract paramsIdField = "id"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore abstract dbDecoration = "db"; abstract recordType: PgRecordInfo; repo(request: Hapi.Request): CrudRepository { // @ts-expect-error const db = request[this.dbDecoration]; if (!db) throw Boom.badImplementation( `CrudController for table ${this.recordType.tableName} could not find request decoration '${this.dbDecoration}'` ); const repo = db()[this.repoName]; if (!repo) throw Boom.badImplementation( `CrudController for table ${this.recordType.tableName} could not find repository for '${this.dbDecoration}().${this.repoName}'` ); return repo; } /** * Creates a new record */ public create = async ( request: Hapi.Request, toolkit: Hapi.ResponseToolkit ): Promise => { try { // would love to know how to get rid of this double cast hack const payload: TSavedR = (request.payload); const data: TSavedR = await this.repo(request).insert(payload); return toolkit.response( createResponse(request, { value: data, }) ); } catch (error: any) { return toolkit.response( createResponse(request, { boom: Boom.badImplementation(error), }) ); } }; /** * Updates a record by ID. This method can accept partial updates. */ public updateById = async ( request: Hapi.Request, toolkit: Hapi.ResponseToolkit ): Promise => { try { const payload: Partial = request.payload; const id: IdKeyT = request.params[this.paramsIdField]; const updatedRow: TSavedR = await this.repo(request).updateById( id, payload ); if (!updatedRow) { return toolkit.response( createResponse(request, { boom: Boom.notFound(), }) ); } return toolkit.response( createResponse(request, { value: updatedRow, }) ); } catch (error: any) { return toolkit.response( createResponse(request, { boom: Boom.badImplementation(error), }) ); } }; /** * Return a record given its id. */ public getById = async ( request: Hapi.Request, toolkit: Hapi.ResponseToolkit ): Promise => { try { const id: IdKeyT = request.params[this.paramsIdField]; // @ts-expect-error const row: TSavedR = await this.repo(request).findById(id); if (!row) { return toolkit.response( createResponse(request, { boom: Boom.notFound(), }) ); } return toolkit.response( createResponse(request, { value: row, }) ); } catch (error: any) { return toolkit.response( createResponse(request, { boom: Boom.badImplementation(error), }) ); } }; /** * Return all records. */ public getAll = async ( request: Hapi.Request, toolkit: Hapi.ResponseToolkit ): Promise => { try { const rows: TSavedR[] = await this.repo(request).findAll(); return toolkit.response( createResponse(request, { value: rows, }) ); } catch (error: any) { return toolkit.response( createResponse(request, { boom: Boom.badImplementation(error), }) ); } }; /** * Delete a record given its id. */ public deleteById = async ( request: Hapi.Request, toolkit: Hapi.ResponseToolkit ): Promise => { try { const id: IdKeyT = request.params[this.paramsIdField]; const count = await this.repo(request).removeById(id); if (count === 0) { return createResponse(request, { boom: Boom.notFound() }); } return toolkit.response( createResponse(request, { value: { id }, }) ); } catch (error: any) { return toolkit.response( createResponse(request, { boom: Boom.badImplementation(error), }) ); } }; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function unboundCrudController( aRecordType: TRecordInfo ) { return class CrudController extends AbstractCrudController< UnsavedR, SavedR, KeyType > { public readonly repoName: string; public readonly paramsIdField; public readonly dbDecoration; public readonly recordType = aRecordType; constructor(repoName: string, paramsIdField = "id", dbDecoration = "db") { super(); this.repoName = repoName; this.paramsIdField = paramsIdField; this.dbDecoration = dbDecoration; } }; } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function CrudControllerBase(recordType: Rec) { return unboundCrudController(recordType); } export const crudRoutesFor = ( name: string, path: string, controller: AbstractCrudController, idParam: string, validate: Record ): Hapi.ServerRoute[] => [ { method: "POST", path: `${path}`, options: { handler: controller.create, validate: validate.create, description: `Method that creates a new ${name}.`, tags: ["api", name], }, }, { method: "PUT", path: `${path}/{${idParam}}`, options: { handler: controller.updateById, validate: validate.updateById, description: `Method that updates a ${name} by its id.`, tags: ["api", name], }, }, { method: "GET", path: `${path}/{${idParam}}`, options: { handler: controller.getById, validate: validate.getById, description: `Method that gets a ${name} by its id.`, tags: ["api", name], }, }, { method: "GET", path: `${path}`, options: { handler: controller.getAll, description: `Method that gets all ${name}s.`, tags: ["api", name], }, }, { method: "DELETE", path: `${path}/{${idParam}}`, options: { handler: controller.deleteById, validate: validate.deleteById, description: `Method that deletes a ${name} by its id.`, tags: ["api", name], }, }, ];