@sap/cds
Version:
SAP Cloud Application Programming Model - CDS for Node.js
171 lines (146 loc) • 6.28 kB
JavaScript
const { pipeline } = require('node:stream/promises')
const cds = require('../../index'), { inspect, decodeURI } = cds.utils
const express = require('express')
const SELECT = require('../../ql/SELECT')
const PROD = process.env.NODE_ENV === 'production'
const LOG = cds.log ('hcql')
class HCQLAdapter extends require('./http') {
get router() {
const bpo = this.body_parser_options
const router = super.router
.get ('/\\$csn', this.schema.bind(this))
.use (express.json(bpo)) //> for application/json -> cqn
.use (express.text(bpo)) //> for text/plain -> cql -> cqn
.use (this.log_request)
PROD || router.use (['/:entity/:id','/:entity'], this.shortcuts.bind(this))
router.use ('/:event', this.action.bind(this))
router.use (this.query.bind(this))
return router
}
/**
* Handler for logging incoming requests.
*/
log_request (req,_,next) {
LOG.info (req.method,
decodeURI (req.baseUrl + req.path),
typeof req.body === 'string' ? req.body : inspect (req.body, { depth: 4 })
); next()
}
/**
* Return the CSN as schema in response to /<srv>/$csn requests
* @type {express.RequestHandler}
*/
schema (_, res) {
let csn = cds.minify (this.service.model, { service: this.service.name })
return res.json (csn)
}
/**
* Handler for REST-style convenience shortcuts like GET /Books/201
* @type {express.RequestHandler}
*/
shortcuts (req, res, next) {
let { entity, id } = req.params, tail, body = req.body
if (entity in this.service.actions) return this.action (req, res, next)
switch (req.method) {
case 'GET': {
if (entity.includes(' ')) [,entity,tail] = /^(\w+)( .*)?/.exec(entity)
else if (id?.includes(' ')) [,id,tail] = /^(\w+)( .*)/.exec(id)
else if (typeof body === 'string') [tail,body] = [body,undefined]
req.body = tail?.length ? id ? SELECT (`from ${entity} [ID=${id}] ${tail}`)
: SELECT (`from ${entity} ${tail}`)
: SELECT.from (entity, id)
if (typeof body === 'object') Object.assign (req.body.SELECT, body)
if (typeof body === 'string') {
let { from, ...clauses } = SELECT(`from x ${body}`).SELECT // eslint-disable-line no-unused-vars
Object.assign (req.body.SELECT, clauses)
}
break
}
case 'POST': req.body = INSERT.into (entity) .entries ({...req.query,...req.body}); break
case 'PUT': req.body = UPSERT.into (entity) .entries ({...req.query,...req.body}); break
case 'PATCH': req.body = UPDATE (entity, id) .with ({...req.query,...req.body}); break
case 'DELETE': req.body = DELETE.from (entity, id); break
}
return next()
}
/**
* Handler for custom actions and functions.
* @type {express.RequestHandler}
*/
action (req, res, next) {
const { event } = req.params
if (!(event in this.service.actions)) return next() //> CRUD /:entity/:id
if (!(req.method in {GET:1,POST:2})) return next(501) //> only GET and POST allowed
const data = { ...req.query, ...req.body }
return this.exec ({ event, data }, req) .catch (next)
}
/**
* Handler for all CRUD requests.
* @type {express.RequestHandler}
*/
query (req, res, next) {
// Obtain query from request body, which may be a CQN object or a CQL string
const q = cds.ql (req.body) || cds.error (400, 'Invalid query', { query: req.body })
// Validate target entity against given model
if (this.service.definition) {
const t = cds.infer.target (q, this.service)
if (!t || t._unresolved) throw cds.error (400, 'Cannot determine target entity of query.', { query:q })
}
// Apply request headers
const $ = q.SELECT
if ($) {
if (req.get('Accept-Language')) $.localized = true
if (req.get('X-Total-Count')) $.count = true
}
// Turn SELECT where $search(...) predicates into SELECT.search = [...] as expected for CQNs
if ($?.where?.[0].func === '$search') {
$.search = $.where[0].args
if ($.where.length === 1) delete $.where
else $.where.splice (0, $.where[1] === '>' ? 4 : 2)
}
// Deviate to streaming if query has .stream and client accepts octet-stream
if (q.stream && req.headers.accept?.includes ('application/octet-stream'))
// return this.service.read(q) .then (stream => {
return this.service.run (()=> q.bind (this.service) .stream() .then (stream => {
res.set ('content-type', 'application/octet-stream')
return pipeline (stream, res)
})) .catch (next)
// Regular handling for non-streaming queries
return this.exec (q,req) .catch (next)
}
/**
* Central method to dispatch requests to the service and replying outcomes back to the client.
* @param {cds.ql.Query} query the query to be executed
* @param {express.Request} req the HTTP request object
* @param {express.Response} res the HTTP response object
* @returns {Promise<any>} results from service dispatch, which will be sent as JSON response body
*/
async exec (query, req, res = req.res) {
const request = new cds.Request (query.event ? { ...query, req, res } : { query, req, res })
const data = await this.service.dispatch (request)
if (res.headersSent) return
if (data == null && !request.messages) return res.sendStatus(204)
if (request.event === 'CREATE') res.status(201)
if (data.$count !== undefined) res.set ('X-Total-Count', data.$count)
res.json (this.response4 ({ data, messages: request.messages }))
return data
}
/**
* Overrides base method to format responses according to HCQL expectations,
* i.e. wrapping results in a `data` property and normalizing errors into
* an `errors` array.
*/
response4 (outcomes) {
let { data, error, errors, messages } = super.response4 (outcomes)
let response = {}
if (data != null) { response.data = data
if (data.affected !== undefined) response.affected = data.affected
if (data.$count !== undefined) response.$count = data.$count
}
if (error) errors ??= error.details || [ error ]
if (errors) response.errors = errors
if (messages) response.messages = messages
return response
}
}
module.exports = HCQLAdapter