diff options
Diffstat (limited to 'src/app/db/service/base')
| -rw-r--r-- | src/app/db/service/base/helpers.js | 240 | ||||
| -rw-r--r-- | src/app/db/service/base/index.js | 137 | ||||
| -rw-r--r-- | src/app/db/service/base/many.js | 205 | ||||
| -rw-r--r-- | src/app/db/service/base/methods.js | 217 | ||||
| -rw-r--r-- | src/app/db/service/base/response.js | 118 |
5 files changed, 917 insertions, 0 deletions
diff --git a/src/app/db/service/base/helpers.js b/src/app/db/service/base/helpers.js new file mode 100644 index 0000000..9ab1119 --- /dev/null +++ b/src/app/db/service/base/helpers.js @@ -0,0 +1,240 @@ +/** + * Service construction helpers + * @module app/db/service/base/helpers + */ + +import { + checkPermission, + checkParentPermission, +} from "app/services/permission/helpers"; +import { sendResponse } from "app/db/service/base/response"; + +/** + * Returns a helper function used by an iterator when creating custom routes. + * @param {Service} service the affected service + * @param {Service} methods the CRUD methods associated with the service + * @returns {Function} the method called on the iterable + */ +export function createCustomRoute(service, methods) { + const { parent } = service.options; + /** + * Create a custom route. If the route contains `/:id`, the record will be fetched before calling the handlers. + * @param {string} method the method name + * @param {string} route the route to mount on + * @param {Array} handlers a list of middleware + * @param {Object} permissions a permissions object + */ + return function addCustomRouteHandler({ + method, + route, + handlers, + permissions, + }) { + service.router[(method || "get").toLowerCase()]( + route, + (parent ? [checkParentPermission(parent)] : []) + .concat(permissions ? [checkPermission(permissions)] : []) + .concat(route.match(/^\/:id/) ? methods.show(service) : []) + .concat(handlers) + .concat([sendResponse]) + ); + }; +} + +/** + * Returns a helper function used by an iterator when creating CRUD routes. + * @param {Service} service the affected service + * @param {Object} methods a set of methods to use + * @returns {Function} the method called on the iterable + */ +export function createCrudRoute(service, methods) { + const { parent, permissions, hooks, logging } = service.options; + const loggingHook = logging ? [logging] : []; + /** + * Create a CRUD route + * @param {string} type the type of route to create + */ + return function addEnabledRoute(type) { + const beforeHooks = hooks?.before[type] || []; + const afterHooks = hooks?.after[type] || []; + let verb, route, handlers; + switch (type) { + case "index": + verb = "get"; + route = "/"; + handlers = [ + checkPermission(permissions, "read"), + ...beforeHooks, + methods.index(service), + ]; + break; + case "show": + verb = "get"; + route = "/:id"; + handlers = [ + checkPermission(permissions, "read"), + ...beforeHooks, + methods.show(service), + ]; + break; + case "create": + verb = "post"; + route = "/"; + handlers = [ + checkPermission(permissions, "create"), + ...beforeHooks, + methods.create(service), + ...loggingHook, + ]; + break; + case "update": + verb = "put"; + route = "/:id"; + handlers = [ + checkPermission(permissions, "read"), + ...(hooks?.before?.show || []), + methods.show(service), + ...beforeHooks, + checkPermission(permissions, "update"), + methods.update(service), + ...loggingHook, + ]; + break; + case "updateMany": + verb = "put"; + route = "/"; + handlers = [ + checkPermission(permissions, "read"), + methods.showMany(service), + ...beforeHooks, + checkPermission(permissions, "update"), + methods.updateMany(service), + ...loggingHook, + ]; + break; + case "sort": + verb = "post"; + route = "/sort"; + handlers = [ + checkPermission(permissions, "read"), + ...beforeHooks, + checkPermission(permissions, "update"), + methods.sort(service), + // ...loggingHook, + ]; + break; + case "destroy": + verb = "delete"; + route = "/:id"; + handlers = [ + checkPermission(permissions, "read"), + methods.show(service), + ...beforeHooks, + checkPermission(permissions, "destroy"), + methods.destroy(service), + ...loggingHook, + ]; + break; + case "destroyMany": + verb = "delete"; + route = "/"; + handlers = [ + checkPermission(permissions, "read"), + ...beforeHooks, + // above, i'm using show to see if the records exist before deleting them + // but if deleting many, this is probably being done on the pivot table, + // and where the check is accomplished implicitly by the destroy middleware. + // if delete permission is ALLOW_FOR_OWNER, we still have to make the check. + // methods.showMany(service), + checkPermission(permissions, "destroy"), + methods.destroyMany(service), + ...loggingHook, + ]; + break; + default: + console.error(`Unknown route: ${type}`); + return; + } + let routeHandlers = [] + .concat( + parent + ? [formatParentParameters(service), checkParentPermission(parent)] + : [] + ) + .concat(handlers) + .concat(afterHooks) + .concat(type === "destroy" ? [onDestroyCleanupResponse] : []) + .concat([sendResponse]); + service.router[verb]( + route, + routeHandlers.filter((handler) => !!handler) + ); + }; +} + +/** + * Helper function to convert request.params to integers where appropriate + */ +export function formatParentParameters(service) { + const parentIdAttributes = service.idAttributes.slice(1); + return function formatParentParametersMiddleware(request, response, next) { + const hasInvalidParams = parentIdAttributes.some((parentIdAttribute) => { + const value = parseInt(request.params[parentIdAttribute]); + if (!value) { + return true; + } + request.params[parentIdAttribute] = value; + return false; + }); + if (hasInvalidParams) { + return next(new Error("malformed parent ID")); + } + next(); + }; +} + +/** + * Helper function to clean up local data. We make the deleted object available to middleware, but not to the client. + * @param {express.request} request the request object + * @param {express.response} response the response object + * @param {function} next function to proceed to the next middleware + */ +export function onDestroyCleanupResponse(request, response, next) { + delete response.locals.data; + delete response.locals.child; + next(); +} + +/** + * For each returned object, fetch the count of a particular relation + * @param {string} relation name of relation + * @param {string} valueName (optional) name of the value to set on the output object, defaults to `{relation}_count`` + * @return {Function} middleware function (`after` hook) + */ +export function getRelationCount(relation, as, where) { + return async function getRelationCountMiddleware(request, response, next) { + if (!response.locals.data) { + return next(); + } + const countField = relation + "_count"; + const items = + "length" in response.locals.data + ? response.locals.data + : [response.locals.data]; + const getCountPromises = items.map(async (item) => { + if (!item || !item.related || countField in item) return; + const itemRelation = item.related(relation); + if (where) { + itemRelation.where(where); + } + const count = await itemRelation.count(); + item.set(as || countField, count); + }); + try { + await Promise.all(getCountPromises); + } catch (error) { + console.error(error); + } + next(); + }; +} diff --git a/src/app/db/service/base/index.js b/src/app/db/service/base/index.js new file mode 100644 index 0000000..188d86a --- /dev/null +++ b/src/app/db/service/base/index.js @@ -0,0 +1,137 @@ +/** + * Abstract API service + * @module app/db/service/base/index + */ + +import { Router } from "express"; +import { + checkAccessToken, + checkUserIsActive, +} from "app/services/authentication/helpers"; +import { indexQueryBuilder, loadColumns } from "app/db/helpers"; + +import * as serviceMethods from "app/db/service/base/methods"; +import { handleError } from "app/db/service/base/response"; +import { + createCustomRoute, + createCrudRoute, +} from "app/db/service/base/helpers"; + +/** + * By default, no API routes are enabled. + */ +// const enableAll = ["index", "show", "create", "update", "destroy"]; +const noRoutesEnabled = []; + +/** + * Create an API service. + * @param {Object} options the options dictionary + * @param {bookshelf} options.bookshelf the Bookshelf instance created by the server + * @param {Service} options.parent the Service that this inherits from + * @param {Model|string} options.Model the name of a Bookshelf model, or the model itself + * @param {string} options.name optional, the name of this resource. Used for logging + * @param {string[]} options.enabled[] specific CRUD APIs to enable + * @param {Object} options.hooks extra middleware functions for each endpoint + * @param {Object} options.hooks.before middleware called before a DB call + * @param {function[]} options.hooks.before.index[] middleware called before index queries + * @param {function[]} options.hooks.before.show[] middleware called before show queries + * @param {function[]} options.hooks.before.create[] middleware called before create queries + * @param {function[]} options.hooks.before.update[] middleware called before update queries + * @param {function[]} options.hooks.before.destroy[] middleware called before destroy queries + * @param {function} options.logging middleware hook which runs after create/update/destroy queries, to record events + * @param {Object} options.hooks.after middleware called after a DB call, similar to `before` + * @param {boolean} options.authenticate set to false to disable authentication for this service + * @param {Object} options.permissions per-API permissions + * @param {Object} options.permissions.read permissions applied when reading records + * @param {number[]} options.permissions.read.roles[] allow access with matching user role + * @param {boolean} options.permissions.read.owner allow access with matching `user_id` + * @param {Object} options.permissions.create permissions applied when creating records + * @param {Object} options.permissions.update permissions applied when editing records + * @param {Object} options.permissions.destroy permissions applied when destroying records + * @param {Object[]} options.routes[] additional list of non-CRUD routes + * @param {string} options.routes[].method method to use, get/post/put/delete + * @param {route} options.routes[].route endpoint to use, e.g. `/login` + * @param {function[]} options.routes[].handlers[] list of middleware functions + * @param {privateFields} options.privateFields list of fields which should not be editable through the API + * @param {Object} methods optional method lookup, for alternative CRUD middleware + * @return {Service} the service object + */ +export default async function Service(options, methods = serviceMethods) { + const service = { type: "base", options, children: {} }; + + const { bookshelf, parent, Model, enabled, routes, authenticate } = options; + + /** Locate the Model specified in the configuration */ + service.Model = + Model && typeof Model === "string" ? bookshelf.model(Model) : options.Model; + + /** Use the model to identify the service's resource name */ + service.resource = options.name || service.Model?.prototype.tableName; + if (!service.resource) { + throw new Error("No name or model defined for resource"); + } + + /** Get the ID attributes of this model, and any parent models */ + if (service.Model) { + if (parent) { + service.idAttributes = [service.Model.prototype.idAttribute].concat( + parent.idAttributes + ); + } else { + service.idAttributes = [service.Model.prototype.idAttribute]; + } + } + + /** Load the column names for this service's Model */ + service.columns = + service.Model && (await loadColumns(bookshelf, service.Model)); + + /** Specify the `index` queryBuilder */ + service.queryBuilder = options.queryBuilder || indexQueryBuilder; + + // console.log("* Creating", service.resource, "service"); + + /** Instantiate the Express router. If this is a sub-router, merge the parent's params. */ + service.router = Router({ mergeParams: !!parent }); + + /** Associate the parent resource with this resource */ + if (parent) { + service.parent = parent; + } + + /** Most APIs need access to the database, so supply this on the request object */ + service.router.use((request, response, next) => { + request.service = service; + request.bookshelf = bookshelf; + next(); + }); + + /** Most APIs authenticate, but this can be disabled (for the authentication service) */ + if (authenticate !== false) { + service.router.use(checkAccessToken(options.authenticationOptions || {})); + service.router.use(checkUserIsActive); + } + + /** Add custom routes first */ + if (routes) { + routes.forEach(createCustomRoute(service, methods)); + } + + /** Add all enabled CRUD routes, if this service has a Model. */ + const enabledRoutes = (service.Model && enabled) || noRoutesEnabled; + enabledRoutes.forEach(createCrudRoute(service, methods)); + + /** Add error-handling middleware */ + service.router.use(handleError); + + /** Method to attach a child service to this service's router + * @param {string} route base route of this service + * @param {Service} childService instance of the child service + */ + service.use = (route, childService) => { + service.router.use(`${route}`, childService.router); + service.children[route] = childService; + }; + + return service; +} diff --git a/src/app/db/service/base/many.js b/src/app/db/service/base/many.js new file mode 100644 index 0000000..1791694 --- /dev/null +++ b/src/app/db/service/base/many.js @@ -0,0 +1,205 @@ +/** + * Service API methods that affect multiple records + * @module app/db/service/base/methods + */ + +import * as db from "app/db/query"; +import { reduceValidColumns } from "app/db/helpers"; +import { PERMISSIONS } from "app/constants"; +import debugModule from "debug"; + +/** + * Debug logger + */ +const debug = debugModule("shoebox:service"); + +/** + * API to query for multiple records by ID + */ +export function showMany(service) { + const { Model, parent, resource } = service; + const { childRelation } = service.options; + const idAttribute = service.idAttributes[0]; + return async function showManyMiddleware(request, response, next) { + const { user, permission, body } = request; + const ids = body.map((item) => item[idAttribute]).filter((id) => !!id); + let data; + try { + if (parent) { + // Fetch the immediate parent of the pivot table based on the name of the parent resource. + // This instance is added to the `request.parents` object when performing the permissions check. + const parentInstance = request.parents[parent.resource]; + data = await parentInstance + .related(childRelation) + .query((builder) => builder.whereIn(idAttribute, ids)); + } else { + data = await db.showIDs({ + Model, + ids, + }); + } + } catch (error) { + debug(`${resource} Show error`); + debug(error); + return next(error); + } + if (!data) { + response.locals = { data: [] }; + next(); + } else if ( + permission === PERMISSIONS.ALLOW_FOR_OWNER && + data.some((item) => item.get("user_id") !== user.user_id) + ) { + next(new Error("PermissionsError")); + } else { + response.locals = { data }; + next(); + } + }; +} + +/** + * API to update multiple records + */ +export function updateMany(service) { + return async function updateManyMiddleware(request, response, next) { + const data = await handleUpdateManyWithTransaction( + service, + request, + response + ); + response.locals.data = data; + next(); + }; +} + +/** + * Update multiple records using a transaction + */ +export async function handleUpdateManyWithTransaction( + service, + request, + response +) { + const { Model, idAttributes, columns } = service; + const { bookshelf, privateFields } = service.options; + const idAttribute = idAttributes[0]; + return await bookshelf.transaction((transaction) => { + const { data: instances } = response.locals; + const { params, user, permission, body } = request; + const instanceLookup = instances.reduce((lookup, instance) => { + lookup[instance.id] = instance; + return lookup; + }, {}); + const promises = body.map((item) => { + const itemId = item[idAttribute]; + if (!itemId) { + return handleCreate({ + Model, + body: item, + idAttributes, + params, + columns, + privateFields, + transaction, + }); + } else if (itemId in instanceLookup) { + return handleUpdate({ + instance: instanceLookup[itemId], + body: item, + user, + permission, + columns, + privateFields, + transaction, + }); + } else { + throw new Error("item id not found on this record"); + } + }); + return Promise.all(promises); + }); +} + +/** + * API to destroy multiple records + */ +export function destroyMany(service) { + const { Model, idAttributes } = service; + const [idAttribute, ...parentIdAttributes] = idAttributes; + return async function destroyManyMiddleware(request, response, next) { + const idsToDelete = request.body[idAttribute]; + let instances; + try { + instances = await db.showIDs({ Model, ids: idsToDelete }); + instances.forEach((instance) => { + parentIdAttributes.forEach((parentIdAttribute) => { + if ( + instance.get(parentIdAttribute) !== + request.params[parentIdAttribute] + ) { + throw new Error("parent mismatch"); + } + }); + }); + await Promise.all(instances.map((instance) => instance.destroy())); + } catch (error) { + debug(`${service.resource} destroy many error`); + debug(error); + return next(new Error(error)); + } + response.locals.data = instances; + response.locals.success = true; + response.locals.id = idsToDelete; + next(); + }; +} + +/** + * Insert a single record + */ +export function handleCreate({ + idAttributes, + params, + columns, + body, + Model, + privateFields, + transaction, +}) { + body = reduceValidColumns(body, columns, privateFields); + if (idAttributes) { + idAttributes.forEach((idAttribute) => { + if (idAttribute in params && idAttribute in columns) { + body[idAttribute] = parseInt(params[idAttribute]); + } + }); + } + return db.create({ Model: Model, data: body, transaction }); +} + +/** + * Update a single record + */ +export function handleUpdate({ + instance, + body, + user, + permission, + columns, + privateFields, + transaction, +}) { + if ( + permission === PERMISSIONS.ALLOW_FOR_OWNER && + instance.get("user_id") !== user.user_id + ) { + throw new Error("PermissionsError"); + } + body = reduceValidColumns(body, columns, privateFields); + return db.update({ + instance, + data: body, + transaction, + }); +} diff --git a/src/app/db/service/base/methods.js b/src/app/db/service/base/methods.js new file mode 100644 index 0000000..be84d0c --- /dev/null +++ b/src/app/db/service/base/methods.js @@ -0,0 +1,217 @@ +/** + * Service API methods + * @module app/db/service/base/methods + */ + +import * as db from "app/db/query"; +import { reduceValidColumns } from "app/db/helpers"; +import { PERMISSIONS } from "app/constants"; +import debugModule from "debug"; + +/** + * Export methods for dealing with multiple records + */ +export { showMany, updateMany, destroyMany } from "app/db/service/base/many"; + +/** + * Debug logger + */ +const debug = debugModule("shoebox:service"); + +/** + * API to filter and paginate lists of resources + */ +export function index(service) { + const { columns } = service; + return async function indexMiddleware(request, response, next) { + const { query: userQuery, params, permission, user } = request; + const { paginate } = service.options; + const queryBuilder = service.queryBuilder; + const query = { + ...userQuery, + ...params, + }; + const withRelated = userQuery.related ? userQuery.related.split(",") : []; + if (permission === PERMISSIONS.ALLOW_FOR_OWNER) { + query.user_id = user.user_id; + } + let data, pagination; + let promises = [ + db.index({ + Model: service.Model, + query, + paginate, + user, + columns, + queryBuilder, + withRelated, + }), + ]; + if (paginate) { + promises.push( + db.count({ + Model: service.Model, + query, + paginate, + user, + columns, + queryBuilder, + }) + ); + } else { + promises.push(new Promise((resolve) => resolve({}))); + } + try { + [data, pagination] = await Promise.all(promises); + } catch (error) { + debug(`${service.resource} Index error`); + debug(error); + console.error(error); + return next(error); + } + response.locals = { + data, + pagination, + query, + }; + next(); + }; +} + +/** + * API to query for a single record by ID + */ +export function show(service) { + return async function showMiddleware(request, response, next) { + const { user, permission, query } = request; + const withRelated = query.related ? query.related.split(",") : []; + let data; + try { + data = await db.show({ + Model: service.Model, + objectID: parseInt(request.params.id), + withRelated, + }); + } catch (error) { + debug(`${service.resource} Show error`); + debug(error); + return next(error); + } + if (!data) { + response.locals = {}; + next(); + } else if ( + permission === PERMISSIONS.ALLOW_FOR_OWNER && + data.get("user_id") !== user.user_id + ) { + next(new Error("PermissionsError")); + } else { + response.locals = { data }; + next(); + } + }; +} + +/** + * API to insert a new record + */ +export function create(service) { + return async function createMiddleware(request, response, next) { + const body = reduceValidColumns( + request.body, + service.columns, + service.options.privateFields + ); + if (service.options.parent) { + service.idAttributes.forEach((idAttribute) => { + if (idAttribute in request.params && idAttribute in service.columns) { + body[idAttribute] = request.params[idAttribute]; + } + }); + } + let data; + try { + data = await db.create({ Model: service.Model, data: body }); + } catch (error) { + debug(`${service.resource} create error`); + console.error(error); + return next(error); + } + response.locals = { data }; + next(); + }; +} + +/** + * API to update a single record + */ +export function update(service) { + return async function updateMiddleware(request, response, next) { + const { data: instance } = response.locals; + const { user, permission } = request; + if ( + permission === PERMISSIONS.ALLOW_FOR_OWNER && + instance.get("user_id") !== user.user_id + ) { + return next(new Error("PermissionsError")); + } + const body = reduceValidColumns( + request.body, + service.columns, + service.options.privateFields + ); + let data; + try { + data = await db.update({ + instance, + data: body, + }); + } catch (error) { + debug(`${service.resource} update error`); + debug(error); + next(new Error(error)); + } + response.locals = { data }; + next(); + }; +} + +/** + * API to sort records + */ +export function sort(service) { + return async function sortMiddleware(request, response, next) { + const { ids } = request.body; + const items = await db.showIDs({ + Model: service.Model, + ids, + }); + for (let item of items) { + item.set("sort_order", ids.indexOf(item.id)); + } + await Promise.all(items.map((item) => item.save())); + response.locals.success = true; + next(); + }; +} + +/** + * API to destroy a single record + */ +export function destroy(service) { + return async function destroyMiddleware(request, response, next) { + try { + await db.destroy({ + Model: service.Model, + objectID: request.params.id, + }); + } catch (error) { + debug(`${service.resource} destroy error`); + console.error(error); + return next(new Error(error)); + } + response.locals.success = true; + response.locals.id = request.params.id; + next(); + }; +} diff --git a/src/app/db/service/base/response.js b/src/app/db/service/base/response.js new file mode 100644 index 0000000..a4bac60 --- /dev/null +++ b/src/app/db/service/base/response.js @@ -0,0 +1,118 @@ +/** + * Service API responses + * @module app/db/service/base/response + */ + +import { zipCSVs, stringifyCSV, sanitizeCSV } from "app/utils/file_utils"; +import debugModule from "debug"; + +/** + * Debug logger + */ +const debug = debugModule("shoebox:service"); + +/** + * Middleware to return the response as JSON or CSV + */ +export async function sendResponse(request, response) { + if (response.locals.csv) { + if (response.locals.csv.files) { + await sendCSVZipResponse(response); + } else { + await sendCSVResponse(response); + } + } else { + await sendJSONResponse(response); + } +} + +/** + * Respond with a ZIP file containing multiple CSVs + * @param {Response} response the response object + */ +async function sendCSVZipResponse(response) { + const zipData = await zipCSVs(response.locals.csv.files); + response.set("Content-Type", "application/zip"); + response.set( + "Content-Disposition", + `attachment; filename=${response.locals.csv.filename}` + ); + response.set("Content-Length", zipData.length); + response.set("Access-Control-Expose-Headers", "Content-Disposition"); + response.write(zipData, "binary"); + response.end(null, "binary"); +} + +/** + * Respond with a single CSV + * @param {Response} response the response object + */ +async function sendCSVResponse(response) { + const csvData = await stringifyCSV(sanitizeCSV(response.locals.csv.data)); + response.set("Content-Type", "text/csv; charset=utf-8"); + response.set( + "Content-Disposition", + `attachment; filename=${response.locals.csv.filename}` + ); + response.set("Content-Length", csvData.length); + response.set("Access-Control-Expose-Headers", "Content-Disposition"); + response.send(csvData); +} + +/** + * Respond with JSON + * @param {Response} response the response object + */ +async function sendJSONResponse(response) { + response.json(response.locals); +} + +/** + * Error-handling middleware + * @param {Error} error an error object + * @param {express.request} request the request object + * @param {express.response} response the response object + * @param {function} next function to proceed to the next middleware + */ +export function handleError(error, request, response, next) { + const { errors } = request.service.options; + debug("Error", error.name, error.message); + let message; + + if (errors) { + Object.keys(errors).some((key) => { + if (error.message.match(key)) { + message = errors[key]; + return true; + } + return false; + }); + if (message) { + response.status(403).send({ code: 403, error: message }); + return; + } + } + + if (error.name === "UnauthorizedError") { + debug("Unauthorized"); + response.status(401).send({ code: 401, error: error.message }); + } else if (error.message === "EmptyResponse") { + debug("Not found"); + response.status(404).send({ code: 404, error: error.message }); + } else if (error.message === "UserNotActive") { + debug("User not active"); + response.status(401).send({ code: 401, error: error.message }); + } else if (error.message === "PermissionsError") { + debug("Insufficient permissions"); + response.status(401).send({ code: 401, error: error.message }); + } else { + debug("Unexpected error"); + // debug(error); + response.status(403).send({ + code: 403, + error: error.name || error.message, + message: error.message, + }); + next(); + } +} |
