UNPKG

@sap/cli-core

Version:

Command-Line Interface (CLI) Core Module

315 lines (314 loc) 11.9 kB
import { kebabCase, get } from "lodash-es"; import { CLI_NAME, OPTION_OUTPUT, OPTION_NO_PRETTY, X_CSRF_TOKEN, } from "../../constants.js"; import { get as loggerGet } from "../../logger/index.js"; import { parseVersion } from "../../utils/utils.js"; import { get as getConfig } from "../../config/index.js"; import { createForceHandler, createInputHandler } from "../handler/index.js"; import { ResultHandlerFactory } from "../../result/ResultHandlerFactory.js"; const getLogger = () => loggerGet("commands.openAPI.utils"); export const updatePath = (doc, path) => { const version = parseVersion(doc.info.version); if (version.major < 2023) { return `/dwaas-core${path}`; } return path; }; export const isPathParameter = (segment) => /^{.*}$/.test(segment); function getParameterName(parameter) { return `--${kebabCase(parameter.slice(1, -1))}`; } export const writeParameter = (parameter, value) => `${getParameterName(parameter)} ${value}`; export const getSegments = (value) => { const segments = value.split("/"); return segments.filter((segment) => { const s = segment.trim(); return s !== "" && s !== "marketplace"; }); }; const handleParameter = (params, pathSegment, locationSegment) => { if (isPathParameter(pathSegment)) { return params ? `${params} ${writeParameter(pathSegment, locationSegment)}` : writeParameter(pathSegment, locationSegment); } return params; }; const getPathItem = (doc, operation, path) => { const pathItem = doc.paths[path]; if (pathItem.get) { return pathItem; } throw new Error(`path ${operation["x-read-path"]} does not support GET operation`); }; function handleOptions(options, option, value) { if (isPathParameter(option)) { return { ...options, [getParameterName(option)]: value }; } return options; } const outputReadCommand = (doc, operation, response, path) => { const locationSegments = getSegments(response.headers.location); const pathSegments = getSegments(path); let params = ""; let options = {}; for (let i = 0; i < pathSegments.length; i++) { params = handleParameter(params, pathSegments[i], locationSegments[i]); options = handleOptions(options, pathSegments[i], locationSegments[i]); } const pathItem = getPathItem(doc, operation, path); ResultHandlerFactory.get().setResult({ command: pathItem.get.operationId, options, }); const { output } = getLogger(); output(`Use ${getConfig()[CLI_NAME]} ${pathItem.get.operationId} ${params} to retrieve the entity you just created`); }; const readPathHandlerFunction = (doc, operation, path) => async (response) => { const { debug } = getLogger(); if (!response.headers.location) { debug("response contains no location header"); } else { outputReadCommand(doc, operation, response, path); } }; const dummyFunction = async () => { // This is intentional }; export const handleReadPathHandler = (doc, operation) => { const path = operation["x-read-path"]; if (path) { return readPathHandlerFunction(doc, operation, path); } return dummyFunction; }; export const getDescriptionForCommand = (command, operation) => command.description || operation.description || operation.summary || ""; const getReferenceFromDocument = (reference, document) => { const { trace } = getLogger(); const segments = reference.$ref.split("/"); if (segments.length < 4 || // #(1)/components(2)/(parameters|schemas|responses|...)(3)/<name>(4) segments[0] !== "#" || segments[1] !== "components") { throw new Error(`invalid reference ${reference.$ref}`); } const path = segments .slice(1) .reduce((prev, curr) => (prev !== "" ? `${prev}.${curr}` : curr), ""); trace("reading reference %s, path %s", reference.$ref, path); return get(document, path); }; export const getSchema = (obj, doc) => { if (obj.$ref) { return getReferenceFromDocument(obj, doc); } return obj; }; const getKeysFromSchema = (schema, doc, key = "", required = false) => { const { trace } = getLogger(); trace(`retrieving keys for key ${key} from schema ${JSON.stringify(schema)}`); if (schema.$ref) { // eslint-disable-next-line no-param-reassign schema = getSchema(schema, doc); } if (schema.type === "object" && schema.properties) { let keys = []; Object.keys(schema.properties).forEach((property) => { keys = keys.concat(getKeysFromSchema(schema.properties[property], doc, key ? `${key}.${property}` : property, schema.required?.includes(property))); }); return keys; } return [{ key, schema, required }]; }; const flattenType = (type, doc) => { if (type.oneOf) { let result = []; type.oneOf.forEach((t) => { result = result.concat(flattenType(t, doc)); }); return result; } if (type.$ref) { const schema = getSchema(type, doc); return flattenType({ ...schema, allowEmptyValue: type.allowEmptyValue, required: type.required, }, doc); } if (type.type === "object") { throw new Error("type: Object not supported"); } return [type]; }; export const addOptionToCommand = (option, options) => { const { trace } = getLogger(); options.push(option); trace("added option %s", JSON.stringify(option)); }; const checkTypes = (types) => { for (const t of types) { if (types.length > 1 && t.enum?.length === 1) { throw new Error("constants not allowed for multiple types"); } } }; const initParams = (types) => { const params = { booleanAvailable: false, choices: [] }; for (const t of types) { if (t.type === "boolean") { params.booleanAvailable = true; } else { t.enum?.forEach((e) => params.choices.push(e.toString())); } params.default = t.default; } return params; }; const handleOptionName = (name, params, description) => { let optionName = kebabCase(name); let def; let newDescription = description; if (params.default !== undefined) { if (params.booleanAvailable && params.default === true) { // see npmjs.com/package/commander#other-option-types-negatable-boolean-and-booleanvalue optionName = `no-${optionName}`; if (description) { newDescription = `do not ${description}`; } } else { def = params.default.toString(); } } return { optionName, def, description: newDescription, }; }; const buildOption = (commandName, longName, types, params, description, def) => { const option = { longName, required: !types[0].allowEmptyValue && types[0].required, description: description || "", default: def === "false" ? undefined : def, args: params.booleanAvailable && types.length === 1 ? undefined : [ { name: longName, optional: params.booleanAvailable && types.length > 1, }, ], choices: params.choices.length > 0 ? params.choices : undefined, }; if (option.required) { const desc = option.description ? ` (${option.description})` : ""; option.prompts = { message: `Provide a value for option ${option.longName}${desc}:`, type: params.choices.length > 0 ? "select" : "text", }; } return option; }; const handleOption = (commandName, name, params, types, options, description) => { const { optionName, def, description: newDescription, } = handleOptionName(name, params, description); const option = buildOption(commandName, optionName, types, params, newDescription, def); addOptionToCommand(option, options); }; const requiresUserInput = (name) => name !== X_CSRF_TOKEN; export const buildOptionFromType = (commandName, doc, parameterIn, name, type, parameterMappings, options, description) => { try { const types = flattenType(type, doc); checkTypes(types); if (types[0].enum?.length === 1) { parameterMappings.push({ in: parameterIn, name, source: { type: "value", value: types[0].enum[0] }, }); } else { const params = initParams(types); if (requiresUserInput(name)) { handleOption(commandName, name, params, types, options, description); } parameterMappings.push({ in: parameterIn, name, source: { type: "option", name: kebabCase(name), dataType: params.booleanAvailable ? "boolean" : "string", // we don't care whether it's a string or number, it simply must not be boolean in this case }, }); } } catch (err) { if (!type.required) { const { trace } = getLogger(); trace(`option ${name} silently ignored since not required`, err.stack); } else { throw err; } } }; export const handleRequestBody = (operation, handler, doc, parameterMappings, command) => { if (operation["x-requestbody-fileonly"]) { handler.push(createInputHandler()); } else if (operation.requestBody) { const schema = getSchema(Object.values(operation.requestBody.content)[0].schema, doc); const keys = getKeysFromSchema(schema, doc); keys.forEach((key) => { if (key.schema.oneOf) { throw new Error("invalid request body parameter resolution, oneOf not supported"); } buildOptionFromType(command.command, doc, "body", key.key, { ...key.schema, required: key.required, }, parameterMappings, command.options, key.schema.description); }); } }; export const handleForceOption = (operation, handler) => { if (operation["x-user-to-confirm"]) { handler.push(createForceHandler(operation["x-user-to-confirm"])); } }; export const handleResponses = (operation, command) => { if (operation.responses?.[200]) { addOptionToCommand(OPTION_OUTPUT, command.options); } addOptionToCommand(OPTION_NO_PRETTY, command.options); }; export const handleParameters = (operation, doc, parameterMappings, command, topLevelParameters = []) => { const { error } = getLogger(); (operation.parameters || []).concat(topLevelParameters).forEach((p) => { try { const parameter = getSchema(p, doc); buildOptionFromType(command.command, doc, parameter.in, parameter.name, { ...parameter.schema, allowEmptyValue: parameter.allowEmptyValue, required: parameter.required, }, parameterMappings, command.options, parameter.description); } catch (err) { error(`cannot add option ${p} for operation ${operation.operationId}`, err.stack); } }); }; export const handleDeprecationNotice = (operation, command) => { if (operation.deprecated && command.type === "command") { // eslint-disable-next-line no-param-reassign command.deprecationInfo = { deprecated: true, deprecatedWithWave: operation["x-deprecated-with-wave"], decommissionedAfterWave: operation["x-decommissioned-after-wave"], newCommand: operation["x-deprecation-new-command"], sapHelpUrl: operation["x-deprecation-sap-help-url"], }; } };