@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
208 lines (176 loc) • 7.57 kB
JavaScript
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