@cap-js/openapi
Version:
CAP tool for OpenAPI
1,077 lines (994 loc) • 117 kB
JavaScript
/**
* 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\n\n### Legend\n';
}
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