UNPKG

@sap/cds-dk

Version:

Command line client and development toolkit for the SAP Cloud Application Programming Model

335 lines (300 loc) 13.6 kB
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 = [] for (const c of csn.childrenOf(s.name)) { if (c.name.match(filterStringAsRegex(filter))) { if (c.name.includes('.texts')) continue c.kind === 'entity' ? entities.push(this.#addEntityInfo(c)) : c.kind === 'action' ? actions.push(c) : null } } 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 } #addEntityInfo(e) { return { name: e.name, readOnly: (e["@readonly"] || e["@cds.autoexpose"] || e['@odata.singleton']) ?? false, isDraft: e['@odata.draft.enabled'] ?? false, keys: e.keys } } /** * @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) } 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) { 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)/AdminService.draftPrepare\n${headers}\n\n{}\n`) requests.push(`${title} Draft Activate\n# @name ${slug(title+'_Draft_Activate')}\nPOST ${url}(${key}={{draftID}},IsActiveEntity=false)/AdminService.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 this.#mockUsersForService(service, 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] } } /** * 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 {ServiceData} service * @returns {Promise<Record<string, {password: string, roles: string[]}>>} a map of users */ async #mockUsersForService(service, csn) { const declaredRoles = new Set() Object.values(csn.definitions) .filter(d => d.name.startsWith(service.name)) .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 (service.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 && !matchingUsers.length) { const name = `no_user_found_for_roles_${Array.from(declaredRoles).join('_')}` matchingUsers[name] = { name: name, password: '', roles: [] } } return matchingUsers } #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` } }