@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
358 lines (322 loc) • 15 kB
JavaScript
const https = require('node:https')
const axios = require('axios')
const asJson = require("../data/as-json")
const cds = require('../../../cds')
const cf = require('../../../util/cf')
const { write, exists } = cds.utils
const { filterStringAsRegex } = require('../../add')
const { _readSpringBootConfig } = require('../../projectReader')
/**
* @typedef {{name: string, entities:[], actions:[], urlPaths: string[], isJava:boolean}} ServiceData
* @typedef {Map<string, ServiceData>|undefined} ServiceMap
*/
module.exports = class HttpTemplate extends require('../../plugin') {
#nodeHost = "http://localhost:4004"
#javaHost = "http://localhost:8080"
#defaultOutputDir = "test/http"
static help() {
return 'add .http files for modeled services'
}
options() {
return {
'filter': {
type: 'string',
short: 'f',
help: `Filter for services or entities or actions matching the given pattern. If it contains meta
characters like '^' or '*', it is treated as a regular expression,
otherwise as an include pattern, i.e /.*pattern.*/i`
},
'for-app': {
type: 'string',
short: 'a',
help: `Specify the name of the app to generate requests for.
If not specified, localhost and default auth will be used.`
},
'out': {
type: 'string',
short: 'o',
help: `The output directory.
By default, an \`http\` dir is created in either \`test/\`, \`tests/\`, \`__tests__/\`, or at the root level.`
},
'dry': {
type: 'boolean',
help: 'Print the generated requests to the console instead of writing them to a file.'
}
}
}
async run() {
const csn = await cds.load(cds.env.roots).then(cds.minify)
cds.linked(csn)
const serviceInfo = cds.compile.to.serviceinfo(csn)
let services = this.#collectServiceInfo(csn, serviceInfo)
if (!services) return
const payload = await this.#generatePayload(csn, services)
await this.#generateRequests(services, payload, csn)
}
/**
* @returns {ServiceMap}
*/
#collectServiceInfo(csn, serviceInfo) {
const services = new Map()
const filter = cds.cli.options['filter']
csn.services.map((s) => {
const sInfo = serviceInfo.find(info => info.name === s.name)
const urlPaths = sInfo?.endpoints?.map(e => e.path) ?? [sInfo?.urlPath]
if (!urlPaths) return
let entities = []
let actions = []
const containedTargets = new Set()
for (const c of csn.childrenOf(s.name)) {
if (c.kind !== 'entity' || !c.elements) continue
for (const e of Object.values(c.elements)) {
if (e?.type !== 'cds.Composition' || !e.target) continue
const containment = Array.isArray(e.on) && e.on.some(t => t?.ref?.[0] === '$self')
if (!containment) continue
const target = csn.definitions?.[e.target] || csn.childrenOf(s.name).find(x => x.name === e.target)
const struct_key = Object.values(target?.keys || {}).some(k => k?.is_struct)
const struct_up = target?.elements?.up_?.is_struct === true
if (struct_key || struct_up) containedTargets.add(e.target)
}
}
const exposed = new Set((sInfo?.entities || []).map(e => e?.name || e).filter(Boolean))
for (const c of csn.childrenOf(s.name)) {
if (c.name.match(filterStringAsRegex(filter))) {
if (c.name.includes('.texts')) continue
if (c.kind === 'entity') {
// Skip composition targets – they are not exposed as entity sets
if (containedTargets.has(c.name)) continue
if (exposed.size && !exposed.has(c.name)) continue
const autoexposed = c['@cds.autoexposed'] || c['@cds.autoexpose']
const valuelist = c['@cds.odata.valuelist'] === true
if (!exposed.size && autoexposed && !valuelist) continue
entities.push({
name: c.name,
readOnly: (c["@readonly"] || autoexposed || c['@odata.singleton']) ?? false,
isDraft: c['@odata.draft.enabled'] ?? false,
keys: c.keys
})
} else if (c.kind === 'action') {
actions.push(c)
}
}
}
if (entities.length === 0 && actions.length === 0) return
services.set(s.name, { name: s.name, entities, urlPaths, actions, isJava: sInfo.runtime === 'Java' })
})
if (services.size === 0) {
console.error(`No services found for filter ${filter}`)
process.exitCode = 1
return
}
return services
}
/**
* @param {ServiceMap} services
*/
async #generatePayload(csn, services) {
const payload = {}
for (const [, service] of services) {
for (const e of service.entities) {
payload[e.name] = []
}
for (const a of service.actions) {
payload[a.name] = []
}
}
// csn.all('entity').reduce((all, e) => { all[e.name] = []; return all }, {})
await asJson(payload, csn, 1, {referenceData: []})
for (const p in payload) {
delete payload[p][0].texts
delete payload[p][0].localized
}
return payload
}
/**
* @param {ServiceMap} services
*/
async #generateRequests(services, payload, csn) {
let varsDefined = false // when writing requests for multiple services to stdout, define vars only once
for (const [serviceName, service] of services) {
let [hostname, username, password] = await this.#getHostnameAndAuth(service, csn)
let requests = []
if (!cds.cli.options.dry || !varsDefined) {
const auth = username ? `@username=${username}\n@password=${password}\n` : ''
requests.push(`@server=${hostname}\n${auth}`)
}
varsDefined = true
const headerAuth = username ? `Authorization: Basic {{username}}:{{password}}` : ''
const headers = headerAuth ? `Content-Type: application/json\n${headerAuth}` : `Content-Type: application/json`
service.urlPaths.forEach((urlPath) => {
if (service.urlPaths.length > 1) {
requests.push(`\n# Service endpoint: ${urlPath}`)
}
service.entities.forEach((e) => {
const entityNameSimple = e.name.slice(serviceName.length + 1)
const nameSlug = slug(entityNameSimple)
const url = `{{server}}/${urlPath}${entityNameSimple}`
const requestTitle = `\n### ${entityNameSimple}`
requests.push(`${requestTitle}\n# @name ${nameSlug}_GET\nGET ${url}\n${headerAuth}\n`)
if (e.readOnly) return
const entityData = payload[`${e.name}`] ? payload[`${e.name}`][0] : null
if (!entityData) return
if (e.isDraft) {
const key = Object.keys(e.keys)[0] // draft keys are always single
delete entityData[key] // draft keys should always by auto generated (either UUID or through custom logic), so remove them from the payload
const postBody = JSON.stringify(entityData, null, 2)
generateDraftRequests(requests, requestTitle, url, headers, headerAuth, postBody, key, service)
}
else {
const postBody = JSON.stringify(entityData, null, 2)
let id
const compositeKey = Object.keys(e.keys||{}).length > 1
if (compositeKey) {
id = [...e.keys].map(k => k.type === 'cds.String' ? `${k.name}='${entityData[k.name]}'` : `${k.name}=${entityData[k.name]}`).join(',')
} else {
id = entityData[Object.keys(e.keys||{})[0]]
}
compositeKey ? id = `(${id})` : id = `/${id}`
requests.push(`${requestTitle}\n# @name ${nameSlug}_POST\nPOST ${url}\n${headers}\n\n${postBody}\n`)
requests.push(`${requestTitle}\n# @name ${nameSlug}_PATCH\nPATCH ${url}${id}\n${headers}\n\n${postBody}\n`)
requests.push(`${requestTitle}\n# @name ${nameSlug}_DELETE\nDELETE ${url}${id}\n${headers}\n`)
}
})
service.actions.forEach((a) => {
const simpleNameAction = a.name.slice(serviceName.length + 1)
const url = `{{server}}/${urlPath}${simpleNameAction}`
let actionPayload = {}
if (a.params) {
for (const pName of Object.keys(a.params)) {
actionPayload[pName] = payload[a.name]?.[0]?.[pName] ?? null
}
}
requests.push(`\n### ${simpleNameAction}\n# @name ${simpleNameAction}_POST\nPOST ${url}\n${headers}\n\n${JSON.stringify(actionPayload, null, 2)}\n`)
})
})
this.#write(requests, serviceName)
}
function generateDraftRequests(requests, title, url, headers, headerAuth, postBody, key, service) {
const nameDraftsCreate = slug(title+'_Draft_POST')
requests.push(`${title} Drafts GET\n# @name ${slug(title+'_Drafts_GET')}\nGET ${url}?$filter=(IsActiveEntity eq false)\n${headerAuth}\n`) // unencoded URL for better readability (client can handle this)
requests.push(`${title} Draft POST\n# @name ${nameDraftsCreate}\nPOST ${url}\n${headers}\n\n${postBody}\n`)
requests.push(`\n### Result from POST request above\n@draftID={{${nameDraftsCreate}.response.body.$.${key}}}\n`)
requests.push(`${title} Draft PATCH\n# @name ${slug(title+'_Draft_Patch')}\nPATCH ${url}(${key}={{draftID}},IsActiveEntity=false)\n${headers}\n\n${postBody}\n`)
requests.push(`${title} Draft Prepare\n# @name ${slug(title+'_Draft_Prepare')}\nPOST ${url}(${key}={{draftID}},IsActiveEntity=false)/${service.name}.draftPrepare\n${headers}\n\n{}\n`)
requests.push(`${title} Draft Activate\n# @name ${slug(title+'_Draft_Activate')}\nPOST ${url}(${key}={{draftID}},IsActiveEntity=false)/${service.name}.draftActivate\n${headers}\n\n{}\n`)
}
function slug(str) {
return str.trim().replace(/[#\\. ]/g, '')
}
}
async #getHostnameAndAuth(service, csn) {
if (!cds.cli.options['for-app']) {
const host = service.isJava ? this.#javaHost : this.#nodeHost
const users = Object.values(await HttpTemplate.mockUsersForService(service.name, service.isJava, csn))
if (users.length) {
return [host, users[0].name, users[0].password || '']
}
return [host]
}
if (!this.approuterAuth) {
const app = cds.cli.options['for-app']
// Later: differentiate between CF and Kyma
const env = await cf.env(app)
if (!cds.cli.options.dry) console.error('Fetching credentials from Cloud Foundry app \'' + app + '\'...')
const hostname = `https://${env.application_env_json.VCAP_APPLICATION.application_uris[0]}`
const { xsuaa, identity } = env.system_env_json.VCAP_SERVICES ?? {}
const credentials = (xsuaa?.[0] ?? identity?.[0])?.credentials ?? {}
const { url, clientid, clientsecret, certurl, certificate, key } = credentials
if (!url) throw new Error('No authentication service found in the environment of ' + app)
const auth = certificate ? { maxRedirects: 0, httpsAgent: new https.Agent({ cert: certificate, key }) }
: { auth: { username: clientid, password: clientsecret } }
const suffix = xsuaa ? 'oauth/token' : 'oauth2/token'
const authUrl = `${certurl ?? url}/${suffix}`
const data = `grant_type=client_credentials&client_id=${encodeURI(clientid)}`
const config = { method: 'POST', timeout: 5000, data, ...auth }
const { access_token } = (await axios(authUrl, config)).data
const approuterAuth = `x-approuter-authorization: bearer ${access_token}`
return [hostname, approuterAuth]
}
}
#write(requests, serviceName) {
const http = requests.join('\n')
if (cds.cli.options.dry) {
console.log(http)
} else {
const term = require('../../../util/term')
const outputPath = this.#determineOutputPath(serviceName)
if (exists(outputPath)) {
if (cds.cli.options.force) {
console.log(term.dim(` overwriting ${outputPath}`))
} else {
console.log(term.dim(` skipping ${outputPath}`))
return
}
} else {
console.log(term.dim(` creating ${outputPath}`))
}
write(http).to(outputPath)
}
}
#determineOutputPath(serviceName) {
let outputDir
const { out } = cds.cli.options
if (out) {
outputDir = out.endsWith('/') ? out.slice(0, -1) : out
} else if (exists('test') || exists('tests') || exists('__tests__')) {
outputDir = exists('test') ? 'test/http' : (exists('tests') ? 'tests/http' : '__tests__/http')
} else {
outputDir = this.#defaultOutputDir
}
return `${outputDir}/${serviceName}.http`
}
}
/**
* Reads mock users from the cds.env or the application.yml,
* returning only those that match to the declared roles of the given service.
* @param {String} serviceName - the name of the service to check
* @param {Boolean} isJava - whether the service is a Java service
* @returns {Promise<Record<string, {password: string, roles: string[]}>>} a map of users
*/
module.exports.mockUsersForService = async function(serviceName, isJava, csn) {
const declaredRoles = new Set()
Object.values(csn.definitions)
.filter(d => d.name.startsWith(serviceName))
.map(d => d['@requires']).filter(Boolean)
.forEach(req => {
declaredRoles.add(...(Array.isArray(req) ? req : [req]))
})
const matchingUsers = {}
const addUserIfMatching = (roles, name, password) => {
if (!declaredRoles.size || declaredRoles.has('authenticated-user') || roles?.some(r => declaredRoles.has(r))) {
matchingUsers[name] = { name, password, roles }
}
}
if (isJava) {
const javaDefaultUsers = [
{ name: 'authenticated', roles: ['authenticated-user'] },
{ name: 'internal', roles: ['internal-user'] },
{ name: 'system', roles: ['system-user'] },
]
const javaUsers = []
this.javaConfig ??= await _readSpringBootConfig()
javaUsers.push(
...this.javaConfig
.map(cfg => cfg.cds?.security?.mock?.users) // {name: { password, roles }}
.filter(Boolean)
.flatMap(usrList => Object.entries(usrList).map(([name, {password, roles}]) => ({ name, password, roles: roles??[] })))
, ...javaDefaultUsers // add default users at end for lower priority
)
javaUsers.forEach(({name, password='', roles}) => addUserIfMatching(roles, name, password))
}
// Node
else if (cds.env.requires.auth?.users) {
const nodeUsers = Object.entries(cds.env.requires.auth.users)
nodeUsers.forEach(([name, {password, roles}]) => addUserIfMatching(roles, name, password))
}
// add a dummy user as a hint if no user matches the scopes
if (declaredRoles.size && !Object.keys(matchingUsers).length) {
const name = `no_user_found_for_roles_${Array.from(declaredRoles).join('_')}`
matchingUsers[name] = { name: name, password: '', roles: [] }
}
return matchingUsers
}