UNPKG

@sap/cds

Version:

SAP Cloud Application Programming Model - CDS for Node.js

208 lines (176 loc) 7.57 kB
const cds = require('../../index'), {i18n} = cds const express = require('express') class HttpAdapter { /** * Constructs and returns a new express.Router * @param {object} srv - The service instance * @param {object} [o] - Options for the adapter * @returns {express.Router} The constructed router */ constructor (srv,o={}) { this.kind = o.kind || this.constructor.name.replace (/Adapter$/,'').toLowerCase() this.service = srv this.options = o const router = this.router // main handlers are added here in subclasses' getter of this.router. router.use (this.error.bind(this)) // add error handler _after_ subclasses constructed their router. } /** * The adapter's express.Router factory. Subclasses override this to add specific handlers. * @returns {express.Router} */ get router() { const router = super.router = new express.Router if (this.authorize) // Add authorization handler _before_ subclasses add their handlers. router.use (this.authorize.bind(this)) //> check roles if @required return router } /** * Returns options for body parsers, e.g., to set a custom limit via * @cds.server.body_parser.limit annotation. * @returns {object} Options for body parsers */ get body_parser_options() { let options = cds.env.server.body_parser let limit = this.service.definition?.['@cds.server.body_parser.limit'] if (limit) options = { ...options, limit } return super.body_parser_options = options } /** * Returns a handler to log all incoming requests * @returns {express.RequestHandler} */ get log_request() { const LOG = this.logger, {decodeURI} = cds.utils return super.log_request = function log_request (req,_,next) { LOG.info (req.method, decodeURI (req.baseUrl + req.path), Object.keys(req.query).length ? { ...req.query } : '' ); next() } } get logger() { return super.logger = cds.log(this.kind) } /** * Returns a handler to check roles from `@requires` or `@restrict` annotations. * @returns {express.RequestHandler|null} */ get authorize() { const d = this.service?.definition; if (!d) return null const rr = d['@requires'] || d['@restrict']?.map(r => r.to).flat().filter(r => r) const roles = rr?.length ? Array.isArray(rr) ? rr : [rr] : ( process.env.NODE_ENV === 'production' && cds.env.requires.auth?.restrict_all_services !== false && ['authenticated-user'] ) return super.authorize = roles && function check_roles (req, res, next) { const user = cds.context.user if (roles.some(role => user.has(role))) return next() //> ok else if (user._is_anonymous) return next(401) //> request login else cds.error (403, `User '${user.id}' is lacking required roles: [${roles}]`) } } /** * Error handling middleware, which normalizes errors before passing * them to the next/final error handling middleware(s). * @type {import('express').ErrorRequestHandler} */ error (err, req, res, next) { if (err === 401 || err.status == 401 || err.code == 401) return next(err) if (typeof err === 'number') err = { status: err } else err = this.normalized (err) return next (err) } /** * Normalizes errors thrown from service layer, ensuring that properties * `code` and `status` are properly inferred if not set specifically, * as well as `message` looked up from i18n bundles in default language. * @returns {{ status?: number, code?: string }} passed in error object with normalized properties */ normalized (err) { // determine status const status = err.status ??= err.statusCode || CODE_TO_STATUS[err.code] if (!status) { if (!err.sqlState && err.code >= 400 && err.code < 600) err.status = Number(err.code) else if (err.details?.map) { // infer from suberrors' statuses, if any let nested = [...new Set( err.details.map (e => e.status || e.statusCode || Number(e.code) ).filter(s => s))] if (nested.length === 1) err.status = nested[0] // if only one unique status exists, we use that else if (nested.some(s => s >= 500)) err.status = 500 // if at least one 5xx exists, we use 500 else err.status = 400 // else we use 400, as nested errors are usually failed input validations } else err.status = 500 // as we don't know what happened at all } // determine code and message... for (let e of err.details?.map ? [ err, ...err.details ] : [ err ]) { const key = unwrapped(e.message) || e.code || e.status const msg = i18n.messages.at (key, i18n.default_language, e.args) if (msg) { if (!e.code || e.code == e.status) e.code = String(key) e._i18n = e.code == key ? SAME_AS_CODE : key e.message = msg } } return err } /** * Localizes error messages in normalized errors and suberrors. * Expects that {@link normalized}() was already called on the error, * so that message keys are stored in `_i18n` property, or in `code`. */ localized (err, locale = cds.context?.locale || i18n.default_language) { for (let e of err.details?.map ? [ err, ...err.details ] : [ err ]) if (e._i18n) e.message = i18n.messages.at (e._i18n === SAME_AS_CODE ? e.code : e._i18n, locale, e.args) || e.message return err } /** * Returns a new error object with only relevant and safe properties to avoid * revealing internal or sensitive information to clients. */ cleansed (err) { // eslint-disable-next-line no-unused-vars const { message, code, status, statusCode, details, _i18n, ...etc } = err const cleansed = { message, ...etc, __proto__: err } if (code) cleansed.code = code if (details) cleansed.details = details.map?.(e => this.cleansed(e)) ?? details return cleansed } /** * Avoids revealing any error details to attackers in production for 5xx errors. * Custom code can prevent sanitization by setting `err.$sanitize = false`. * @returns A sanitized error object, or undefined if no sanitization happened. */ sanitized (err) { if (err.$sanitize === false) delete err.$sanitize else if (err.status >= 500 && PROD()) return { message: i18n.messages.at(err.status) || 'Internal Server Error', code: String(err.status), } } /** * Construct a proper response body for the given `outcomes` returned from service. * Errors will be {@link sanitized}, {@link localized}, and {@link cleansed}. * @param {{ data?:any, error?:object }} outcomes */ response4 (outcomes) { const { error } = outcomes // sanitize, or localize and cleanse errors in responses, if any if (error) outcomes.error = this.sanitized (error) || this.localized (error) && this.cleansed (error) // keep stack trace in error responses if explicitly configured if (error?.stack && cds.env.server.keep_error_stack) outcomes.error.stack = error.stack return outcomes } } const CODE_TO_STATUS = { ENTITY_ALREADY_EXISTS: 400, FK_CONSTRAINT_VIOLATION: 400, UNIQUE_CONSTRAINT_VIOLATION: 400 } const PROD = ()=> process.env.NODE_ENV === 'production' || process.env.CDS_ENV === 'prod' const SAME_AS_CODE = 1 // NOTE: We'd prefer to use a Symbol here, but that would prevent proper serialization/deserialization of errors, e.g., in case of Draft Messages const unwrapped = msg => msg?.startsWith?.('{i18n>') ? msg.slice(6,-1) : msg module.exports = HttpAdapter