UNPKG

@cap-js/openapi

Version:

CAP tool for OpenAPI

1,077 lines (994 loc) 117 kB
/** * Converts OData CSDL JSON to OpenAPI 3.0.2 */ const cds = require('@sap/cds'); var 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 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 {object} csdl CSDL document * @param {object} 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' serviceRoot = serviceRoot || (scheme + '://' + host + basePath); const queryOptionPrefix = csdl.$Version <= '4.01' ? '$' : ''; const typesToInline = {}; // filled in schema() and used in inlineTypes() const boundOverloads = {}; const derivedTypes = {}; const alias = {}; const namespace = { 'Edm': 'Edm' }; const namespaceUrl = {}; const voc = {}; const requiredSchemas = { list: [], used: {} }; preProcess(csdl, boundOverloads, derivedTypes, alias, namespace, namespaceUrl, voc); const entityContainer = csdl.$EntityContainer ? modelElement(csdl.$EntityContainer) : {}; if (csdl.$EntityContainer) { let serviceName = nameParts(csdl.$EntityContainer).qualifier; Object.keys(entityContainer).forEach(element => { if (entityContainer[element].$Type) { let 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[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) { let extensionObj = {}; let containerSchema = {}; if (level ==='root'){ const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; containerSchema = csdl.$EntityContainer ? 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); } } } let 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"] }, }; checkForExtentionEnums(extensionObj, extensionEnums); let extenstionSchema = { "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, extenstionSchema); return extensionObj; } function checkForExtentionEnums(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, extenstionSchema) { for (const [key, value] of Object.entries(extensionObj)) { if (extenstionSchema[key]) { if (Array.isArray(value)) { extensionObj[key] = value.filter((v) => extenstionSchema[key].includes(v)); } else if (typeof value === "object" && value !== null) { for (const field in value) { if (!extenstionSchema[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) { delete openapi.servers; delete openapi.tags; } security(openapi, entityContainer); return openapi; /** * Collect model info for easier lookup * @param {object} csdl CSDL document * @param {object} boundOverloads Map of action/function names to bound overloads * @param {object} derivedTypes Map of type names to derived types * @param {object} alias Map of namespace or alias to alias * @param {object} namespace Map of namespace or alias to namespace * @param {object} namespaceUrl Map of namespace to reference URL * @param {object} voc Map of vocabularies and terms */ function preProcess(csdl, boundOverloads, derivedTypes, alias, namespace, namespaceUrl, voc) { Object.keys(csdl.$Reference || {}).forEach(url => { const reference = csdl.$Reference[url]; (reference.$Include || []).forEach(include => { const qualifier = include.$Alias || include.$Namespace; alias[include.$Namespace] = qualifier; namespace[qualifier] = include.$Namespace; namespace[include.$Namespace] = include.$Namespace; namespaceUrl[include.$Namespace] = url; }); }); getVocabularies(voc, alias); Object.keys(csdl).filter(name => isIdentifier(name)).forEach(name => { const schema = csdl[name]; const qualifier = schema.$Alias || name; const isDefaultNamespace = schema[voc.Core.DefaultNamespace]; alias[name] = qualifier; namespace[qualifier] = name; namespace[name] = name; Object.keys(schema).filter(iName => isIdentifier(iName)).forEach(iName2 => { const qualifiedName = qualifier + '.' + iName2; const element = schema[iName2]; if (Array.isArray(element)) { element.filter(overload => overload.$IsBound).forEach(overload => { const type = overload.$Parameter[0].$Type + (overload.$Parameter[0].$Collection ? '-c' : ''); if (!boundOverloads[type]) boundOverloads[type] = []; boundOverloads[type].push({ name: (isDefaultNamespace ? iName2 : qualifiedName), overload: overload }); }); } else if (element.$BaseType) { const base = namespaceQualifiedName(element.$BaseType); if (!derivedTypes[base]) derivedTypes[base] = []; derivedTypes[base].push(qualifiedName); } }); Object.keys(schema.$Annotations || {}).forEach(target => { const annotations = schema.$Annotations[target]; const segments = target.split('/'); const open = segments[0].indexOf('('); let element; if (open == -1) { element = modelElement(segments[0]); } else { element = modelElement(segments[0].substring(0, open)); let args = segments[0].substring(open + 1, segments[0].length - 1); element = element.find( (overload) => (overload.$Kind == "Action" && overload.$IsBound != true && args == "") || (overload.$Kind == "Action" && args == (overload.$Parameter[0].$Collection ? `Collection(${overload.$Parameter[0].$Type})` : overload.$Parameter[0].$Type || "")) || (overload.$Parameter || []) .map((p) => { const type = p.$Type || "Edm.String"; return p.$Collection ? `Collection(${type})` : type; }) .join(",") == args ); } if (!element) { DEBUG?.(`Invalid annotation target '${target}'`); } else if (Array.isArray(element)) { //TODO: action or function: //- loop over all overloads //- if there are more segments, a parameter or the return type is targeted } else { switch (segments.length) { case 1: Object.assign(element, annotations); break; case 2: if (['Action', 'Function'].includes(element.$Kind)) { if (segments[1] == '$ReturnType') { if (element.$ReturnType) Object.assign(element.$ReturnType, annotations); } else { const parameter = element.$Parameter.find(p => p.$Name == segments[1]); Object.assign(parameter, annotations); } } else { if (element[segments[1]]) { Object.assign(element[segments[1]], annotations); } else { // DEBUG?.(`Invalid annotation target '${target}'`) } } break; default: DEBUG?.('More than two annotation target path segments'); } } }); }); } /** * Construct map of qualified term names * @param {object} voc Map of vocabularies and terms * @param {object} alias Map of namespace or alias to alias */ function getVocabularies(voc, alias) { const terms = { Authorization: ['Authorizations', 'SecuritySchemes'], Capabilities: ['BatchSupport', 'BatchSupported', 'ChangeTracking', 'CountRestrictions', 'DeleteRestrictions', 'DeepUpdateSupport', 'ExpandRestrictions', 'FilterRestrictions', 'IndexableByKey', 'InsertRestrictions', 'KeyAsSegmentSupported', 'NavigationRestrictions', 'OperationRestrictions', 'ReadRestrictions', 'SearchRestrictions', 'SelectSupport', 'SkipSupported', 'SortRestrictions', 'TopSupported', 'UpdateRestrictions'], Core: ['AcceptableMediaTypes', 'Computed', 'ComputedDefaultValue', 'DefaultNamespace', 'Description', 'Example', 'Immutable', 'LongDescription', 'OptionalParameter', 'Permissions', 'SchemaVersion'], JSON: ['Schema'], Validation: ['AllowedValues', 'Exclusive', 'Maximum', 'Minimum', 'Pattern'] }; Object.keys(terms).forEach(vocab => { voc[vocab] = {}; terms[vocab].forEach(term => { if (alias['Org.OData.' + vocab + '.V1'] != undefined) voc[vocab][term] = '@' + alias['Org.OData.' + vocab + '.V1'] + '.' + term; }); }); voc.Common = { Label: `@${alias['com.sap.vocabularies.Common.v1']}.Label` } } /** * Construct the Info Object * @param {object} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {object} Info Object */ function getInfo(csdl, entityContainer) { const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; let description; if (entityContainer && entityContainer[voc.Core.LongDescription]) { description = entityContainer[voc.Core.LongDescription]; } else if (containerSchema && containerSchema[voc.Core.LongDescription]) { description = containerSchema[voc.Core.LongDescription]; } else { description = "Use @Core.LongDescription: '...' on your CDS service to provide a meaningful description."; } description += (diagram ? getResourceDiagram(csdl, entityContainer) : ''); let title; if (entityContainer && entityContainer[voc.Common.Label]) { title = entityContainer[voc.Common.Label]; } else { title = "Use @title: '...' on your CDS service to provide a meaningful title."; } return { title: title, description: csdl.$EntityContainer ? description : '', version: containerSchema[voc.Core.SchemaVersion] || '' }; } /** * Construct the externalDocs Object * @param {object} csdl CSDL document * @return {object} externalDocs Object */ function getExternalDoc(csdl) { const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; let 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 resource diagram using web service at https://yuml.me * @param {object} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {string} resource diagram */ function getResourceDiagram(csdl, entityContainer) { let diagram = ''; let comma = ''; //TODO: make colors configurable let color = { resource: '{bg:lawngreen}', entityType: '{bg:lightslategray}', complexType: '', external: '{bg:whitesmoke}' } Object.keys(csdl).filter(name => isIdentifier(name)).forEach(namespace => { const schema = csdl[namespace]; Object.keys(schema).filter(name => isIdentifier(name) && ['EntityType', 'ComplexType'].includes(schema[name].$Kind)) .forEach(typeName => { const type = schema[typeName]; diagram += comma + (type.$BaseType ? '[' + nameParts(type.$BaseType).name + ']^' : '') + '[' + typeName + (type.$Kind == 'EntityType' ? color.entityType : color.complexType) + ']'; Object.keys(type).filter(name => isIdentifier(name)).forEach(propertyName => { const property = type[propertyName]; const targetNP = nameParts(property.$Type || 'Edm.String'); if (property.$Kind == 'NavigationProperty' || targetNP.qualifier != 'Edm') { const target = modelElement(property.$Type); const bidirectional = property.$Partner && target && target[property.$Partner] && target[property.$Partner].$Partner == propertyName; // Note: if the partner has the same name then it will also be depicted if (!bidirectional || propertyName <= property.$Partner) { diagram += ',[' + typeName + ']' + ((property.$Kind != 'NavigationProperty' || property.$ContainsTarget) ? '++' : (bidirectional ? cardinality(target[property.$Partner]) : '')) + '-' + cardinality(property) + ((property.$Kind != 'NavigationProperty' || bidirectional) ? '' : '>') + '[' + (target ? targetNP.name : property.$Type + color.external) + ']'; } } }); comma = ','; }); }); Object.keys(entityContainer).filter(name => isIdentifier(name)).reverse().forEach(name => { const resource = entityContainer[name]; if (resource.$Type) { diagram += comma + '[' + name + '%20' + color.resource + ']' // additional space in case entity set and type have same name + '++-' + cardinality(resource) + '>[' + nameParts(resource.$Type).name + ']'; } else { if (resource.$Action) { diagram += comma + '[' + name + color.resource + ']'; const overload = modelElement(resource.$Action).find(pOverload => !pOverload.$IsBound); diagram += overloadDiagram(name, overload); } else if (resource.$Function) { diagram += comma + '[' + name + color.resource + ']'; const overloads = modelElement(resource.$Function); if (overloads) { const unbound = overloads.filter(overload => !overload.$IsBound); // TODO: loop over all overloads, add new source box after first arrow diagram += overloadDiagram(name, unbound[0]); } } } }); if (diagram != '') { diagram = '\n\n## Entity Data Model\n![ER Diagram](https://yuml.me/diagram/class/' + diagram + ')\n\n### Legend\n![Legend](https://yuml.me/diagram/plain;dir:TB;scale:60/class/[External.Type' + color.external + '],[ComplexType' + color.complexType + '],[EntityType' + color.entityType + '],[EntitySet/Singleton/Operation' + color.resource + '])'; } return diagram; /** * Diagram representation of property cardinality * @param {object} typedElement Typed model element, e.g. property * @return {string} cardinality */ function cardinality(typedElement) { return typedElement.$Collection ? '*' : (typedElement.$Nullable ? '0..1' : ''); } /** * Diagram representation of action or function overload * @param {string} name Name of action or function import * @param {object} overload Action or function overload * @return {string} diagram part */ function overloadDiagram(name, overload) { let diag = ""; if (overload.$ReturnType) { const type = modelElement(overload.$ReturnType.$Type || "Edm.String"); if (type) { diag += "-" + cardinality(overload.$ReturnType) + ">[" + nameParts(overload.$ReturnType.$Type).name + "]"; } } for (const param of overload.$Parameter || []) { const type = modelElement(param.$Type || "Edm.String"); if (type) { diag += comma + "[" + name + color.resource + "]in-" + cardinality(param.$Type) + ">[" + nameParts(param.$Type).name + "]"; } } return diag; } } /** * Find model element by qualified name * @param {string} qname Qualified name of model element * @return {object} Model element */ function modelElement(qname) { const q = nameParts(qname); const schema = csdl[q.qualifier] || csdl[namespace[q.qualifier]]; return schema ? schema[q.name] : null; } /** * Construct the short text * @param {object} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {string} short text */ function getShortText(csdl, entityContainer) { const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; let shortText; if (entityContainer && entityContainer[voc.Core.Description]) { shortText = entityContainer[voc.Core.Description]; } else if (containerSchema && containerSchema[voc.Core.Description]) { shortText = containerSchema[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 {Array} The list of servers */ function getServers(serviceRoot, serversObject) { let servers; if (serversObject) { try { servers = JSON.parse(serversObject); } catch (err) { 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) { const tags = new Map(); // all entity sets and singletons Object.keys(container) .filter(name => isIdentifier(name) && container[name].$Type) .forEach(child => { const type = modelElement(container[child].$Type) || {}; const tag = { name: type[voc.Common.Label] || child }; const description = container[child][voc.Core.Description] || type[voc.Core.Description]; if (description) tag.description = description; tags.set(tag.name, tag); }); return Array.from(tags.values()).sort((pre, next) => pre.name.localeCompare(next.name)); } /** * Construct the Paths Object from the entity container * @param {object} container Entity container * @return {object} Paths Object */ function getPaths(container) { const paths = {}; const resources = Object.keys(container).filter(name => isIdentifier(name)); resources.forEach(name => { let child = container[name]; if (child.$Type) { const type = modelElement(child.$Type); const sourceName = (type && type[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 {object} 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 {string} target Target container child of path * @param {integer} 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 = 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[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[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 {object} 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 {string} target Target container child of path * @param {integer} 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[voc.Capabilities.IndexableByKey] != false; if (restrictions.IndexableByKey == true || restrictions.IndexableByKey != false && targetIndexable) { const name = prefix.substring(prefix.lastIndexOf('/') + 1); const type = 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: 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 {string} target Target container child of path * @param {integer} 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 && target[voc.Capabilities.InsertRestrictions] || {}; let countRestrictions = target && (target[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 = modelElement(element.$Type); pathItem.post = { summary: insertRestrictions.Description || operationSummary('Creates', name, sourceName, level, true, true), tags: [sourceName], requestBody: { description: type && type[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(targetName); customParameters(pathItem.post, insertRestrictions); } } /** * Split camel-cased name into words * @param {string} name Name to split * @return {string} Split name */ function splitName(name) { return name.split(/(?=[A-Z])/g).join(' ').toLowerCase().replace(/ i d/g, ' id'); } /** * Construct operation summary * @param {string} operation Operation (verb) * @param {string} name Name of navigation segment * @param {string} sourceName Name of path source * @param {integer} level Number of navigation segments so far * @param {boolean} collection Access a collection * @param {boolean} byKey Access by key * @return {string} Operation Text */ function operationSummary(operation, name, sourceName, level, collection, byKey) { let lname = splitName(name); let 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 {string} target Target container child of path * @param {integer} 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?.[voc.Capabilities.ReadRestrictions]; const readRestrictions = restrictions.ReadRestrictions || targetRestrictions || {}; const readByKeyRestrictions = readRestrictions.ReadByKeyRestrictions; let readable = true; let countRestrictions = target && (target[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: [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[voc.Capabilities.ChangeTracking] && element[voc.Capabilities.ChangeTracking].Supported; if (!byKey && deltaSupported) { 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(targetName); customParameters(operation, byKey ? readByKeyRestrictions || readRestrictions : readRestrictions); if (collection) { optionTop(operation.parameters, target, restrictions); optionSkip(operation.parameters, target, restrictions); if (csdl.$Version >= '4.0') optionSearch(operation.parameters, target, restrictions); 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 {string} target Target container child of path */ function optionCount(parameters, target) { const targetRestrictions = target && target[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 {string} target Target container child of path * @param {array} nonExpandable Non-expandable navigation properties */ function optionExpand(parameters, element, target, nonExpandable) { const targetRestrictions = target && target[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[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 {integer} level Number of navigation segments so far * @return {Array} Array of navigation property paths */ function navigationPaths(element, prefix = '', level = 0) { const paths = []; const type = 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 {string} 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 && target[voc.Capabilities.FilterRestrictions] || {}; if (filterRestrictions.Filterable !== false) { const filter = { name: queryOptionPrefix + 'filter', description: filterRestrictions[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 {string} 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 && target[voc.Capabilities.SortRestrictions] || {}; if (sortRestrictions.Sortable !== false) { const nonSortable