UNPKG

@cap-js/openapi

Version:

CAP tool for OpenAPI

1,122 lines (1,027 loc) 106 kB
/** * Converts OData CSDL JSON to OpenAPI 3.0.2 */ const cds = require('@sap/cds'); const { CSDLMeta, nameParts, isIdentifier } = require('./csdl'); const { camelCaseToWords: splitName } = require('./string-util'); const { Diagram } = require('./diagram') const pluralize = require('pluralize') const DEBUG = cds.debug('openapi'); // Initialize cds.debug with the 'openapi' //TODO // - Core.Example for complex types // - reduce number of loops over schemas // - inject $$name into each model element to make parameter passing easier? // - allow passing additional files for referenced documents // - delta: headers Prefer and Preference-Applied // - inline definitions for Edm.* to make OpenAPI documents self-contained // - both "clickable" and freestyle $expand, $select, $orderby - does not work yet, open issue for OpenAPI UI // - system query options for actions/functions/imports depending on $Collection // - 200 response for PATCH // - ETag for GET / If-Match for PATCH and DELETE depending on @Core.OptimisticConcurrency // - CountRestrictions for GET collection-valued (containment) navigation - https://issues.oasis-open.org/browse/ODATA-1300 // - InsertRestrictions/NonInsertableProperties // - InsertRestrictions/NonInsertableNavigationProperties // - see //TODO comments below /** * @typedef {import('./types').CSDL} CSDL * @typedef {import('./types').Paths} Paths * @typedef {import('./types').Parameter} Parameter * @typedef {import('./types').Response} Response * @typedef {import('./types').Schema} Schema * @typedef {import('./types').TargetRestrictions} TargetRestrictions */ const SUFFIX = { read: "", create: "-create", update: "-update", }; const TITLE_SUFFIX = { "": "", "-create": " (for create)", "-update": " (for update)", }; const SYSTEM_QUERY_OPTIONS = [ "compute", "expand", "select", "filter", "search", "count", "orderby", "skip", "top", "format", "index", "schemaversion", "skiptoken", "apply", ]; /** * ODM annotations in CDS that should be converted into OpenAPI. */ const ODM_ANNOTATIONS = Object.freeze( { '@ODM.entityName': 'x-sap-odm-entity-name', '@ODM.oid': 'x-sap-odm-oid' }); const ER_ANNOTATION_PREFIX = '@EntityRelationship' const ER_ANNOTATIONS = Object.freeze( { '@EntityRelationship.entityType': 'x-entity-relationship-entity-type', '@EntityRelationship.entityIds': 'x-entity-relationship-entity-ids', '@EntityRelationship.propertyType': 'x-entity-relationship-property-type', '@EntityRelationship.reference': 'x-entity-relationship-reference', '@EntityRelationship.compositeReferences': 'x-entity-relationship-composite-references', '@EntityRelationship.temporalIds': 'x-entity-relationship-temporal-ids', '@EntityRelationship.temporalReferences': 'x-entity-relationship-temporal-references', '@EntityRelationship.referencesWithConstantIds': 'x-entity-relationship-references-with-constant-ids' }); /** * Construct an OpenAPI description from a CSDL document * @param {CSDL} csdl CSDL document * @param {{ url?: string, servers?: object, odataVersion?: string, scheme?: string, host?: string, basePath?: string, diagram?: boolean, maxLevels?: number }} options Optional parameters * @return {object} OpenAPI description */ module.exports.csdl2openapi = function ( csdl, { url: serviceRoot, servers: serversObject, odataVersion: odataVersion, scheme: scheme = 'https', host: host = 'localhost', basePath: basePath = '/service-root', diagram: diagram = false, maxLevels: maxLevels = 5 } = {} ) { // as preProcess below mutates the csdl, copy it before, to avoid side-effects on the caller side csdl = JSON.parse(JSON.stringify(csdl)) csdl.$Version = odataVersion ? odataVersion : '4.01' const meta = new CSDLMeta(csdl) serviceRoot = serviceRoot ?? (`${scheme}://${host}${basePath}`); const queryOptionPrefix = csdl.$Version <= '4.01' ? '$' : ''; const typesToInline = {}; // filled in schema() and used in inlineTypes() /** @type {{ list: { namespace:string, name: string, suffix: string }[], used: Record<string, boolean> }} */ const requiredSchemas = { list: [], used: {} }; const entityContainer = csdl.$EntityContainer ? meta.modelElement(csdl.$EntityContainer) : {}; if (csdl.$EntityContainer) { const serviceName = nameParts(csdl.$EntityContainer).qualifier; Object.keys(entityContainer).forEach(element => { if (entityContainer[element].$Type) { const type = nameParts(entityContainer[element].$Type).name; if ((csdl[serviceName]?.[type]?.['@cds.autoexpose'] || csdl[serviceName]?.[type]?.['@cds.autoexposed']) && !entityContainer[type]) entityContainer[element]['$cds.autoexpose'] = true; } }); } const keyAsSegment = entityContainer ? entityContainer[meta.voc.Capabilities.KeyAsSegmentSupported] : {}; const openapi = { openapi: '3.0.2', info: getInfo(csdl, entityContainer), 'x-sap-api-type': 'ODATAV4', 'x-odata-version': csdl.$Version, 'x-sap-shortText': getShortText(csdl, entityContainer), servers: getServers(serviceRoot, serversObject), tags: entityContainer ? getTags(entityContainer) : {}, paths: entityContainer ? getPaths(entityContainer) : {}, components: getComponents(csdl, entityContainer) }; const externalDocs = getExternalDoc(csdl); if (externalDocs && Object.keys(externalDocs).length > 0) { openapi.externalDocs = externalDocs; } const extensions = getExtensions(csdl, 'root'); if (extensions && Object.keys(extensions).length > 0) { Object.assign(openapi, extensions); } // function to read @OpenAPI.Extensions and get them in the generated openAPI document function getExtensions(csdl, level) { const extensionObj = {}; let containerSchema = {}; if (level ==='root'){ const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; containerSchema = namespace ? csdl[namespace] : {}; } else if(level === 'schema' || level === 'operation'){ containerSchema = csdl; } for (const [key, value] of Object.entries(containerSchema)) { if (key.startsWith('@OpenAPI.Extensions')) { const annotationProperties = key.split('@OpenAPI.Extensions.')[1] ?? '' const keys = annotationProperties.split('.'); if (!keys[0].startsWith("x-sap-")) { keys[0] = (keys[0].startsWith("sap-") ? "x-" : "x-sap-") + keys[0]; } if (keys.length === 1) { extensionObj[keys[0]] = value; } else { nestedAnnotation(extensionObj, keys[0], keys, value); } } } const extensionEnums = { "x-sap-compliance-level": {allowedValues: ["sap:base:v1", "sap:core:v1", "sap:core:v2" ] } , "x-sap-api-type": {allowedValues: [ "ODATA", "ODATAV4", "REST" , "SOAP"] }, "x-sap-direction": {allowedValues: ["inbound", "outbound", "mixed"] , default : "inbound" }, "x-sap-dpp-entity-semantics": {allowedValues: ["sap:DataSubject", "sap:DataSubjectDetails", "sap:Other"] }, "x-sap-dpp-field-semantics": {allowedValues: ["sap:DataSubjectID", "sap:ConsentID", "sap:PurposeID", "sap:ContractRelatedID", "sap:LegalEntityID", "sap:DataControllerID", "sap:UserID", "sap:EndOfBusinessDate", "sap:BlockingDate", "sap:EndOfRetentionDate"] }, }; checkForExtensionEnums(extensionObj, extensionEnums); const extensionSchema = { "x-sap-stateInfo": ['state', 'deprecationDate', 'decomissionedDate', 'link'], "x-sap-ext-overview": ['name', 'values'], "x-sap-deprecated-operation" : ['deprecationDate', 'successorOperationRef', "successorOperationId"], "x-sap-odm-semantic-key" : ['name', 'values'], }; checkForExtentionSchema(extensionObj, extensionSchema); return extensionObj; } function checkForExtensionEnums(extensionObj, extensionEnums){ for (const [key, value] of Object.entries(extensionObj)) { if(extensionEnums[key] && extensionEnums[key].allowedValues && !extensionEnums[key].allowedValues.includes(value)){ if(extensionEnums[key].default){ extensionObj[key] = extensionEnums[key].default; } else{ delete extensionObj[key]; } } } } function checkForExtentionSchema(extensionObj, extensionSchema) { for (const [key, value] of Object.entries(extensionObj)) { if (extensionSchema[key]) { if (Array.isArray(value)) { extensionObj[key] = value.filter((v) => extensionSchema[key].includes(v)); } else if (typeof value === "object" && value !== null) { for (const field in value) { if (!extensionSchema[key].includes(field)) { delete extensionObj[key][field]; } } } } } } function nestedAnnotation(resObj, openapiProperty, keys, value) { if (resObj[openapiProperty] === undefined) { resObj[openapiProperty] = {}; } let node = resObj[openapiProperty]; // traverse the annotation property and define the objects if they're not defined for (let nestedIndex = 1; nestedIndex < keys.length - 1; nestedIndex++) { const nestedElement = keys[nestedIndex]; if (node[nestedElement] === undefined) { node[nestedElement] = {}; } node = node[nestedElement]; } // set value annotation property node[keys[keys.length - 1]] = value; } if (!csdl.$EntityContainer) { // explicit cast required as .servers and .tags are not declared as optional delete /**@type{any}*/(openapi).servers; delete /**@type{any}*/(openapi).tags; } security(openapi, entityContainer); return openapi; function getContainerSchema(csdl) { const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; return namespace ? csdl[namespace] : {}; } /** * Construct the Info Object * @param {CSDL} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {object} Info Object */ function getInfo(csdl, entityContainer) { const containerSchema = getContainerSchema(csdl) let description; if (entityContainer && entityContainer[meta.voc.Core.LongDescription]) { description = entityContainer[meta.voc.Core.LongDescription]; } else if (containerSchema && containerSchema[meta.voc.Core.LongDescription]) { description = containerSchema[meta.voc.Core.LongDescription]; } else if (entityContainer && entityContainer[meta.voc.Core.Description]) { description = entityContainer[meta.voc.Core.Description]; } else if (containerSchema && containerSchema[meta.voc.Core.Description]) { description = containerSchema[meta.voc.Core.Description]; } else { description = "Use @Core.LongDescription: '...' or @Core.Description: '...' on your CDS service to provide a meaningful description."; } description += (diagram ? new Diagram(meta).getResourceDiagram(entityContainer) : ''); let title; if (entityContainer && entityContainer[meta.voc.Common.Label]) { title = entityContainer[meta.voc.Common.Label]; } else { title = "Use @title: '...' on your CDS service to provide a meaningful title."; } return { title, description: csdl.$EntityContainer ? description : '', version: containerSchema[meta.voc.Core.SchemaVersion] || '' }; } /** * Construct the externalDocs Object * @param {CSDL} csdl CSDL document * @return {object} externalDocs Object */ function getExternalDoc(csdl) { const containerSchema = getContainerSchema(csdl) const externalDocs = {}; if (containerSchema?.['@OpenAPI.externalDocs.description']) { externalDocs.description = containerSchema['@OpenAPI.externalDocs.description']; } if (containerSchema?.['@OpenAPI.externalDocs.url']) { externalDocs.url = containerSchema['@OpenAPI.externalDocs.url']; } return externalDocs; } /** * Construct the short text * @param {CSDL} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {string} short text */ function getShortText(csdl, entityContainer) { const containerSchema = getContainerSchema(csdl); let shortText; if (entityContainer && entityContainer[meta.voc.Core.Description]) { shortText = entityContainer[meta.voc.Core.Description]; } else if (containerSchema && containerSchema[meta.voc.Core.Description]) { shortText = containerSchema[meta.voc.Core.Description]; } else { shortText = "Use @Core.Description: '...' on your CDS service to provide a meaningful short text."; } return shortText; } /** * Construct an array of Server Objects * @param {object} serviceRoot The service root * @param {object} serversObject Input servers object * @return {{url: string}[]} The list of servers */ function getServers(serviceRoot, serversObject) { let servers; if (serversObject) { try { servers = JSON.parse(serversObject); } catch { throw new Error(`The input server object is invalid.`); } if (!servers.length) { throw new Error(`The input server object should be an array.`); } } else { servers = [{ url: serviceRoot }]; } return servers; } /** * Construct an array of Tag Objects from the entity container * @param {object} container The entity container * @return {Array} The list of tags */ function getTags(container) { /** @type {Map<string, { name: string, description?: string }>} */ const tags = new Map(); // all entity sets and singletons Object.keys(container) .filter(name => isIdentifier(name) && container[name].$Type) .forEach(child => { const type = meta.modelElement(container[child].$Type) || {}; const tag = { name: type[meta.voc.Common.Label] || child }; const description = container[child][meta.voc.Core.Description] || type[meta.voc.Core.Description]; if (description) tag.description = description; tags.set(tag.name, tag); }); return Array .from(tags.values()) .map(normaliseTag) .sort((pre, next) => pre.name.localeCompare(next.name)); } /** * @template {string | { name: string }} T * @param {T} tag * @returns {T} */ function normaliseTag(tag) { const normalise = s => s .replaceAll('_', ' ') .replace(/([a-z])([A-Z])/g, '$1 $2'); // "camelCase" to "camel Case" if (typeof tag === 'string') { tag = normalise(tag); } else { tag.name = normalise(tag.name); } return tag; } /** * Construct the Paths Object from the entity container * @param {object} container Entity container * @return {Paths} Paths Object */ function getPaths(container) { /** @type {Paths} */ const paths = {}; const resources = Object.keys(container).filter(name => isIdentifier(name)); resources.forEach(name => { const child = container[name]; if (child.$Type) { const type = meta.modelElement(child.$Type); const sourceName = (type && type[meta.voc.Common.Label]) || name; // entity sets and singletons are almost containment navigation properties child.$ContainsTarget = true; pathItems(paths, `/${name}`, [], child, child, sourceName, sourceName, child, 0, ''); } else if (child.$Action) { pathItemActionImport(paths, name, child); } else if (child.$Function) { pathItemFunctionImport(paths, name, child); } else { DEBUG?.(`Unrecognized entity container child: ${name}`); } }) if (resources.length > 0) pathItemBatch(paths, container); return Object.keys(paths).sort().reduce((p, c) => (p[c] = paths[c], p), {}); } /** * Add path and Path Item Object for a navigation segment * @param {Paths} paths Paths Object to augment * @param {string} prefix Prefix for path * @param {Array} prefixParameters Parameter Objects for prefix * @param {object} element Model element of navigation segment * @param {object} root Root model element * @param {string} sourceName Name of path source * @param {string} targetName Name of path target * @param {null | TargetRestrictions[]} target Target container child of path * @param {number} level Number of navigation segments so far * @param {string} navigationPath Path for finding navigation restrictions */ function pathItems(paths, prefix, prefixParameters, element, root, sourceName, targetName, target, level, navigationPath) { const name = prefix.substring(prefix.lastIndexOf('/') + 1); const type = meta.modelElement(element.$Type); const pathItem = {}; const restrictions = navigationPropertyRestrictions(root, navigationPath); const nonExpandable = nonExpandableProperties(root, navigationPath); paths[prefix] = pathItem; if (prefixParameters.length > 0) pathItem.parameters = prefixParameters; operationRead(pathItem, element, name, sourceName, targetName, target, level, restrictions, false, nonExpandable); if (!root['$cds.autoexpose'] && element.$Collection && (element.$ContainsTarget || level < 2 && target)) { operationCreate(pathItem, element, name, sourceName, targetName, target, level, restrictions); } pathItemsForBoundOperations(paths, prefix, prefixParameters, element, sourceName); if (element.$ContainsTarget) { if (element.$Collection) { if (level < maxLevels) pathItemsWithKey(paths, prefix, prefixParameters, element, root, sourceName, targetName, target, level, navigationPath, restrictions, nonExpandable); } else { if (!root['$cds.autoexpose']) { operationUpdate(pathItem, element, name, sourceName, target, level, restrictions); if (element.$Nullable) { operationDelete(pathItem, element, name, sourceName, target, level, restrictions); } } pathItemsForBoundOperations(paths, prefix, prefixParameters, element, sourceName); pathItemsWithNavigation(paths, prefix, prefixParameters, type, root, sourceName, level, navigationPath); } } if (Object.keys(pathItem).filter((i) => i !== "parameters").length === 0) delete paths[prefix]; } /** * Find navigation restrictions for a navigation path * @param {object} root Root model element * @param {string} navigationPath Path for finding navigation restrictions * @return Navigation property restrictions of navigation segment */ function navigationPropertyRestrictions(root, navigationPath) { const navigationRestrictions = root[meta.voc.Capabilities.NavigationRestrictions] ?? {}; return (navigationRestrictions.RestrictedProperties ?? []).find(item => navigationPropertyPath(item.NavigationProperty) == navigationPath) ?? {}; } /** * Find non-expandable properties for a navigation path * @param {object} root Root model element * @param {string} navigationPath Path for finding navigation restrictions * @return Navigation property restrictions of navigation segment */ function nonExpandableProperties(root, navigationPath) { const expandRestrictions = root[meta.voc.Capabilities.ExpandRestrictions] || {}; const prefix = navigationPath.length === 0 ? '' : `${navigationPath}/` const from = prefix.length const nonExpandable = [] for (const path of (expandRestrictions.NonExpandableProperties ?? [])) { if (path.startsWith(prefix)) { nonExpandable.push(path.substring(from)) } } return nonExpandable; } /** * Add path and Path Item Object for a navigation segment with key * @param {Paths} paths Paths Object to augment * @param {string} prefix Prefix for path * @param {Array} prefixParameters Parameter Objects for prefix * @param {object} element Model element of navigation segment * @param {object} root Root model element * @param {string} sourceName Name of path source * @param {string} targetName Name of path target * @param {null | object} target Target container child of path * @param {number} level Number of navigation segments so far * @param {string} navigationPath Path for finding navigation restrictions * @param {object} restrictions Navigation property restrictions of navigation segment * @param {array} nonExpandable Non-expandable navigation properties */ function pathItemsWithKey(paths, prefix, prefixParameters, element, root, sourceName, targetName, target, level, navigationPath, restrictions, nonExpandable) { const targetIndexable = target == null || target[meta.voc.Capabilities.IndexableByKey] != false; if (restrictions.IndexableByKey == true || restrictions.IndexableByKey != false && targetIndexable) { const name = prefix.substring(prefix.lastIndexOf('/') + 1); const type = meta.modelElement(element.$Type); const key = entityKey(type, level); if (key.parameters.length > 0) { const path = prefix + key.segment; const parameters = prefixParameters.concat(key.parameters); const pathItem = { parameters }; paths[path] = pathItem; operationRead(pathItem, element, name, sourceName, targetName, target, level, restrictions, true, nonExpandable); if (!root['$cds.autoexpose']) { operationUpdate(pathItem, element, name, sourceName, target, level, restrictions, true); operationDelete(pathItem, element, name, sourceName, target, level, restrictions, true); } if (Object.keys(pathItem).filter((i) => i !== "parameters").length === 0) delete paths[path]; pathItemsForBoundOperations(paths, path, parameters, element, sourceName, true); pathItemsWithNavigation(paths, path, parameters, type, root, sourceName, level, navigationPath); } } } /** * Construct Operation Object for create * @param {object} pathItem Path Item Object to augment * @param {object} element Model element of navigation segment * @param {string} name Name of navigation segment * @param {string} sourceName Name of path source * @param {string} targetName Name of path target * @param {null | TargetRestrictions[]} target Target container child of path * @param {number} level Number of navigation segments so far * @param {object} restrictions Navigation property restrictions of navigation segment */ function operationCreate(pathItem, element, name, sourceName, targetName, target, level, restrictions) { const insertRestrictions = restrictions.InsertRestrictions || target?.[meta.voc.Capabilities.InsertRestrictions] || {}; const countRestrictions = target?.[meta.voc.Capabilities.CountRestrictions]?.Countable === false // count property will be added if CountRestrictions is false if (insertRestrictions.Insertable !== false) { const lname = pluralize.singular(splitName(name)); const type = meta.modelElement(element.$Type); pathItem.post = { summary: insertRestrictions.Description || operationSummary('Creates', name, sourceName, level, true, true), tags: [normaliseTag(sourceName)], requestBody: { description: type && type[meta.voc.Core.Description] || `New ${lname}`, required: true, content: { 'application/json': { schema: ref(element.$Type, SUFFIX.create), } } }, responses: response(201, `Created ${lname}`, { $Type: element.$Type }, insertRestrictions.ErrorResponses, !countRestrictions), }; if (insertRestrictions.LongDescription) pathItem.post.description = insertRestrictions.LongDescription; if (targetName && sourceName != targetName) pathItem.post.tags.push(normaliseTag(targetName)); customParameters(pathItem.post, insertRestrictions); } } /** * Construct operation summary * @param {string} operation Operation (verb) * @param {string} name Name of navigation segment * @param {string} sourceName Name of path source * @param {number} level Number of navigation segments so far * @param {boolean} collection Access a collection * @param {boolean} byKey Access by key * @return Operation Text */ function operationSummary(operation, name, sourceName, level, collection, byKey) { const lname = splitName(name); const sname = splitName(sourceName); return `${operation} ${ byKey ? 'a single ' : (collection ? 'a list of ' : '') }${byKey ? pluralize.singular(lname) : lname //TODO: suppress "a" for all singletons }${level == 0 ? '' : (level == 1 && sname == 'me' ? ' of me' : ` of a ${pluralize.singular(sname)}`) }.` } /** * Construct Operation Object for read * @param {object} pathItem Path Item Object to augment * @param {object} element Model element of navigation segment * @param {string} name Name of navigation segment * @param {string} sourceName Name of path source * @param {string} targetName Name of path target * @param {null | TargetRestrictions[]} target Target container child of path * @param {number} level Number of navigation segments so far * @param {object} restrictions Navigation property restrictions of navigation segment * @param {boolean} byKey Read by key * @param {array} nonExpandable Non-expandable navigation properties */ function operationRead(pathItem, element, name, sourceName, targetName, target, level, restrictions, byKey, nonExpandable) { const targetRestrictions = target?.[meta.voc.Capabilities.ReadRestrictions]; const readRestrictions = restrictions.ReadRestrictions || targetRestrictions || {}; const readByKeyRestrictions = readRestrictions.ReadByKeyRestrictions; let readable = true; const countRestrictions = target && (target[meta.voc.Capabilities.CountRestrictions]?.Countable === false); if (byKey && readByKeyRestrictions && readByKeyRestrictions.Readable !== undefined) readable = readByKeyRestrictions.Readable; else if (readRestrictions.Readable !== undefined) readable = readRestrictions.Readable; if (readable) { let descriptions = (level == 0 ? targetRestrictions : restrictions.ReadRestrictions) || {}; if (byKey) descriptions = descriptions.ReadByKeyRestrictions || {}; const lname = splitName(name); const collection = !byKey && element.$Collection; const operation = { summary: descriptions.Description || operationSummary('Retrieves', name, sourceName, level, element.$Collection, byKey), tags: [normaliseTag(sourceName)], parameters: [], responses: response(200, `Retrieved ${byKey ? pluralize.singular(lname) : lname}`, { $Type: element.$Type, $Collection: collection }, byKey ? readByKeyRestrictions?.ErrorResponses : readRestrictions?.ErrorResponses, !countRestrictions) }; const deltaSupported = element[meta.voc.Capabilities.ChangeTracking] && element[meta.voc.Capabilities.ChangeTracking].Supported; if (!byKey && deltaSupported) { // @ts-expect-error - set above operation.responses[200].content['application/json'].schema.properties['@odata.deltaLink'] = { type: 'string', example: `${basePath}/${name}?$deltatoken=opaque server-generated token for fetching the delta` } } if (descriptions.LongDescription) operation.description = descriptions.LongDescription; if (target && sourceName != targetName) operation.tags.push(normaliseTag(targetName)); customParameters(operation, byKey ? readByKeyRestrictions || readRestrictions : readRestrictions); if (collection) { // @ts-expect-error - see FIXME in optionTop and optionSkip optionTop(operation.parameters, target, restrictions); // @ts-expect-error optionSkip(operation.parameters, target, restrictions); if (csdl.$Version >= '4.0') optionSearch(operation.parameters, target, restrictions); // @ts-expect-error optionFilter(operation.parameters, target, restrictions); optionCount(operation.parameters, target); optionOrderBy(operation.parameters, element, target, restrictions); } optionSelect(operation.parameters, element, target, restrictions); optionExpand(operation.parameters, element, target, nonExpandable); pathItem.get = operation; } } /** * Add custom headers and query options * @param {object} operation Operation object to augment * @param {object} restrictions Restrictions for operation */ function customParameters(operation, restrictions) { if ( !operation.parameters && (restrictions.CustomHeaders || restrictions.CustomQueryOptions) ) operation.parameters = []; for (const custom of restrictions.CustomHeaders || []) { operation.parameters.push(customParameter(custom, "header")); } for (const custom of restrictions.CustomQueryOptions || []) { operation.parameters.push(customParameter(custom, "query")); } } /** * Construct custom parameter * @param {object} custom custom parameter in OData format * @param {string} location "header" or "query" */ function customParameter(custom, location) { return { name: custom.Name, in: location, required: custom.Required || false, ...(custom.Description && { description: custom.Description }), schema: { type: "string", ...(custom.DocumentationURL && { externalDocs: { url: custom.DocumentationURL }, }), //TODO: Examples }, }; } /** * Add parameter for query option $count * @param {Array} parameters Array of parameters to augment * @param {null | TargetRestrictions[]} target Target container child of path */ function optionCount(parameters, target) { const targetRestrictions = target?.[meta.voc.Capabilities.CountRestrictions]; const targetCountable = target == null || targetRestrictions == null || targetRestrictions.Countable !== false; if (targetCountable) { parameters.push({ $ref: '#/components/parameters/count' }); } } /** * Add parameter for query option $expand * @param {Array} parameters Array of parameters to augment * @param {object} element Model element of navigation segment * @param {null | TargetRestrictions[]} target Target container child of path * @param {array} nonExpandable Non-expandable navigation properties */ function optionExpand(parameters, element, target, nonExpandable) { const targetRestrictions = target?.[meta.voc.Capabilities.ExpandRestrictions]; const supported = targetRestrictions == null || targetRestrictions.Expandable != false; if (supported) { const expandItems = ['*'].concat(navigationPaths(element).filter(path => !nonExpandable.includes(path))); if (expandItems.length > 1) { parameters.push({ name: `${queryOptionPrefix}expand`, description: (targetRestrictions && targetRestrictions[meta.voc.Core.Description]) || 'The value of $expand query option is a comma-separated list of navigation property names, \ stream property names, or $value indicating the stream content of a media-entity. \ The corresponding related entities and stream values will be represented inline, \ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)', in: 'query', explode: false, schema: { type: 'array', uniqueItems: true, items: { type: 'string', enum: expandItems } } }); } } } /** * Collect navigation paths of a navigation segment and its potentially structured components * @param {object} element Model element of navigation segment * @param {string} prefix Navigation prefix * @param {number} level Number of navigation segments so far * @return {Array} Array of navigation property paths */ function navigationPaths(element, prefix = '', level = 0) { const paths = []; const type = meta.modelElement(element.$Type); const properties = propertiesOfStructuredType(type); Object.keys(properties).forEach(key => { if (properties[key].$Kind == 'NavigationProperty') { paths.push(prefix + key) } else if (properties[key].$Type && level < maxLevels) { paths.push(...navigationPaths(properties[key], `${prefix + key}/`, level + 1)); } }) return paths; } /** * Add parameter for query option $filter * @param {Array} parameters Array of parameters to augment * @param {null | TargetRestrictions} target Target container child of path * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionFilter(parameters, target, restrictions) { const filterRestrictions = restrictions.FilterRestrictions || target?.[meta.voc.Capabilities.FilterRestrictions] || {}; if (filterRestrictions.Filterable !== false) { const filter = { name: `${queryOptionPrefix}filter`, description: filterRestrictions[meta.voc.Core.Description] || 'Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)', in: 'query', schema: { type: 'string' } }; if (filterRestrictions.RequiresFilter) filter.required = true; if (filterRestrictions.RequiredProperties) { filter.description += '\n\nRequired filter properties:'; filterRestrictions.RequiredProperties.forEach( item => filter.description += `\n- ${propertyPath(item)}` ); } parameters.push(filter); } } /** * Add parameter for query option $orderby * @param {Array} parameters Array of parameters to augment * @param {object} element Model element of navigation segment * @param {null | TargetRestrictions[]} target Target container child of path * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionOrderBy(parameters, element, target, restrictions) { const sortRestrictions = restrictions.SortRestrictions || target?.[meta.voc.Capabilities.SortRestrictions] || {}; if (sortRestrictions.Sortable !== false) { const nonSortable = {}; (sortRestrictions.NonSortableProperties || []).forEach(name => { nonSortable[propertyPath(name)] = true; }); const orderbyItems = []; primitivePaths(element).filter(property => !nonSortable[property]).forEach(property => { orderbyItems.push(property); orderbyItems.push(`${property} desc`); }); if (orderbyItems.length > 0) { parameters.push({ name: `${queryOptionPrefix}orderby`, description: sortRestrictions[meta.voc.Core.Description] || 'Order items by property values, see [Sorting](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionorderby)', in: 'query', explode: false, schema: { type: 'array', uniqueItems: true, items: { type: 'string', enum: orderbyItems } } }); } } } /** * Unpack EnumMember value if it uses CSDL JSON CS01 style, like CAP does * @param {string | object} member Qualified name of referenced type * @return {object} Reference Object */ function enumMember(member) { if (typeof member == 'string') return member; else if (typeof member == 'object') return member.$EnumMember; } /** * Unpack NavigationPropertyPath value if it uses CSDL JSON CS01 style, like CAP does * @param {string | object} path Qualified name of referenced type * @return {object} Reference Object */ function navigationPropertyPath(path) { if (typeof path == 'string') return path; return path.$NavigationPropertyPath; } /** * Unpack PropertyPath value if it uses CSDL JSON CS01 style, like CAP does * @param {string | object} path Qualified name of referenced type * @return {object} Reference Object */ function propertyPath(path) { if (typeof path == 'string') return path; return path.$PropertyPath; } /** * Collect primitive paths of a navigation segment and its potentially structured components * @param {object} element Model element of navigation segment * @param {string} prefix Navigation prefix * @return {Array} Array of primitive property paths */ function primitivePaths(element, prefix = '') { const paths = []; const elementType = meta.modelElement(element.$Type); if (!elementType) { DEBUG?.(`Unknown type for element: ${JSON.stringify(element)}`); return paths; } const propsOfType = propertiesOfStructuredType(elementType); const ignore = Object.entries(propsOfType) .filter(entry => entry[1].$Kind !== 'NavigationProperty') .filter(entry => entry[1].$Type) .filter(entry => nameParts(entry[1].$Type).qualifier !== 'Edm') .filter(entry => !meta.modelElement(entry[1].$Type)); // Keep old logging ignore.forEach(entry => DEBUG?.(`Unknown type for element: ${JSON.stringify(entry)}`)); const properties = Object.entries(propsOfType) .filter(entry => entry[1].$Kind !== 'NavigationProperty') .filter(entry => !ignore.includes(entry)) .map(entryToProperty({ path: prefix, typeRefChain: [] })); for (let i = 0; i < properties.length; i++) { const property = properties[i]; if (!property.isComplex) { paths.push(property.path); continue; } const typeRefChainTail = property.typeRefChain[property.typeRefChain.length - 1]; // Allow full cycle to be shown (0) times if (property.typeRefChain.filter(_type => _type === typeRefChainTail).length > 1) { DEBUG?.(`Cycle detected ${property.typeRefChain.join('->')}`); continue; } const expanded = Object.entries(property.properties) .filter(pProperty => pProperty[1].$Kind !== 'NavigationProperty') .map(entryToProperty(property)) properties.splice(i + 1, 0, ...expanded); } return paths; } function entryToProperty(parent) { return function (entry) { const key = entry[0]; const property = entry[1]; const propertyType = property.$Type && meta.modelElement(property.$Type); if (propertyType && propertyType.$Kind && propertyType.$Kind === 'ComplexType') { return { properties: propertiesOfStructuredType(propertyType), path: `${parent.path}${key}/`, typeRefChain: parent.typeRefChain.concat(property.$Type), isComplex: true } } return { properties: {}, path: `${parent.path}${key}`, typeRefChain: [], isComplex: false, } }; } /** * Add parameter for query option $search * @param {Array} parameters Array of parameters to augment * @param {null | TargetRestrictions[]} target Target container child of path * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionSearch(parameters, target, restrictions) { const searchRestrictions = restrictions.SearchRestrictions ?? target?.[meta.voc.Capabilities.SearchRestrictions] ?? {}; if (searchRestrictions.Searchable !== false) { if (searchRestrictions[meta.voc.Core.Description]) { parameters.push({ name: `${queryOptionPrefix}search`, description: searchRestrictions[meta.voc.Core.Description], in: 'query', schema: { type: 'string' } }); } else { parameters.push({ $ref: '#/components/parameters/search' }); } } } /** * Add parameter for query option $select * @param {Array} parameters Array of parameters to augment * @param {object} element Model element of navigation segment * @param {null | TargetRestrictions[]} target Target container child of path * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionSelect(parameters, element, target, restrictions) { const selectSupport = restrictions.SelectSupport ?? target?.[meta.voc.Capabilities.SelectSupport] ?? {}; if (selectSupport.Supported !== false) { const type = meta.modelElement(element.$Type) || {}; const properties = propertiesOfStructuredType(type); const selectItems = []; Object.keys(properties).filter(key => properties[key].$Kind != 'NavigationProperty').forEach( key => selectItems.push(key) ) if (selectItems.length > 0) { parameters.push({ name: `${queryOptionPrefix}select`, description: 'Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)', in: 'query', explode: false, schema: { type: 'array', uniqueItems: true, items: { type: 'string', enum: selectItems } } }); } } } /** * Add parameter for query option $skip * @param {Array} parameters Array of parameters to augment * @param {Record<string, boolean>} target Target container child of path FIXME: this seems to be an incorrect use of TargetRestrictions * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionSkip(parameters, target, restrictions) { const supported = restrictions.SkipSupported !== undefined ? restrictions.SkipSupported : target == null || target[meta.voc.Capabilities.SkipSupported] !== false; if (supported) { parameters.push({ $ref: '#/components/parameters/skip' }); } } /** * Add parameter for query option $top * @param {Array} parameters Array of parameters to augment * @param {Record<string, boolean>} target Target container child of path FIXME: this seems to be an incorrect use of TargetRestrictions * @param {object} restrictions Navigation property restrictions of navigation segment */ function optionTop(parameters, target, restrictions) { const supported = restrictions.TopSupported !== undefined ? restrictions.TopSupported : target == null || target?.[meta.voc.Capabilities.TopSupported] !== false; if (supported) { parameters.push({ $ref: '#/components/parameters/top' }); } } /** * Construct Operation Object for update * @param {object} pathItem Path Item Object to augment * @param {object} element Model element of navigation segment * @param {string} name Name of navigation segment * @param {string} sourceName Name of path source * @param {null | TargetRestrictions[]} target Target container child of path * @param {number} level Number of navigation segments so far * @param {object} restrictions Navigation property restrictions of navigation segment * @param {boolean} byKey Update by key */ function operationUpdate(pathItem, element, name, sourceName, target, level, restrictions, byKey = false) { const updateRestrictions = restrictions.UpdateRestrictions || target?.[meta.voc.Capabilities.UpdateRestrictions] || {}; const countRestrictions = target?.[meta.voc.Capabilities.CountRestrictions]?.Countable === false; if (updateRestrictions.Updatable !== false && !element[meta.voc.Core.Immutable]) { const type = meta.modelElement(element.$Type); const operation = { summary: updateRestrictions.Description || operationSummary('Changes', name, sourceName, level, element.$Collection, byKey), tags: [normaliseTag(sourceName)], requestBody: { description: type && type[meta.voc.Core.Description] || 'New property values', required: true, content: { 'application/json': { schema: ref(element.$Type, SUFFIX.update), } } }, responses: response(204, "Success", undefined, updateRestrictions.ErrorResponses, !countRestrictions), }; if (updateRestrictions.LongDescription) operation.description = updateRestrictions.LongDescription; customParameters(operation, updateRestrictions); const updateMethod = updateRestrictions.UpdateMethod?.['$Capabilities.HttpMethod']?.toLowerCase() ?? 'patch'; pathItem[updateMethod] = operation; } } /** * Construct Operation Object for delete * @param {object} pathItem Path Item Object to augment * @param {object} element Model element of navigation segment * @param {string} name Name of navigation segment *