niwe-odata-openapi
Version:
Convert OData CSDL XML or CSDL JSON to OpenAPI
1,578 lines (1,465 loc) • 97.2 kB
JavaScript
/**
* Converts OData CSDL JSON to OpenAPI 3.0.2
*
* Latest version: https://github.com/oasis-tcs/odata-openapi/blob/main/lib/csdl2openapi.js
*/
const { EDM, nameParts } = require("./edm");
const { resourceDiagram } = require("./diagram");
const pluralize = require("pluralize");
const a = require("indefinite");
//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
// - $Extends for entity container: include /paths from referenced container
// - both "clickable" and freestyle $expand, $select, $orderby - does not work yet, open issue for Swagger 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
// - UpdateRestrictions/NonUpdatableProperties
// - UpdateRestrictions/NonUpdatableNavigationProperties
// - 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",
];
/**
* 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,
{
scheme = "https",
host = "localhost",
basePath = "/service-root",
diagram = false,
openapiVersion = "3.0.2",
maxLevels = 4,
messages = [],
skipBatchPath = false,
defaultTitle = null,
defaultDescription = null,
rootResourcesToKeep = null,
} = {},
) {
const serviceRoot = scheme + "://" + host + basePath;
const queryOptionPrefix = csdl.$Version <= "4.0" ? "$" : "";
const typesToInline = {}; // filled in schema() and used in inlineTypes()
const oas31 = openapiVersion >= "3.1.0";
const requiredSchemas = {
list: [],
used: {},
entityReferenceNeeded: false,
};
const model = new EDM();
model.addDocument(csdl, messages);
const entityTypesToKeep = rootResourcesToKeep
? model.referencedEntityTypes(rootResourcesToKeep)
: null;
const voc = model.voc;
const entityContainer = model.entityContainer ?? {};
const keyAsSegment = entityContainer[voc.Capabilities.KeyAsSegmentSupported];
const applySupported = entityContainer[voc.Aggregation.ApplySupported];
const deepUpdate =
entityContainer[voc.Capabilities.DeepUpdateSupport] &&
entityContainer[voc.Capabilities.DeepUpdateSupport].Supported;
const openapi = {
openapi: openapiVersion,
info: info(csdl, entityContainer),
servers: servers(serviceRoot),
tags: tags(),
paths: paths(model.entityContainer),
components: components(!!model.entityContainer),
};
if (!model.entityContainer) {
delete openapi.servers;
delete openapi.tags;
}
securitySchemes(openapi.components, entityContainer);
security(openapi, model.entityContainer);
return openapi;
/**
* Construct the Info Object
* @param {object} csdl CSDL document
* @param {object} entityContainer Entity Container object
* @return {object} Info Object
*/
function info(csdl, entityContainer) {
const containerNamespace =
csdl.$EntityContainer && nameParts(csdl.$EntityContainer).qualifier;
const containerSchema = csdl[containerNamespace] ?? {};
const description =
(entityContainer[voc.Core.LongDescription] ||
containerSchema[voc.Core.LongDescription] ||
defaultDescription ||
"This service is located at [" +
serviceRoot +
"/](" +
serviceRoot.replace(/\(/g, "%28").replace(/\)/g, "%29") +
"/)") +
(diagram
? resourceDiagram(model, rootResourcesToKeep, entityTypesToKeep)
: "");
return {
title:
entityContainer[voc.Core.Description] ||
containerSchema[voc.Core.Description] ||
defaultTitle ||
(csdl.$EntityContainer
? "Service for namespace " + containerNamespace
: "OData CSDL document"),
description: csdl.$EntityContainer ? description : "",
version: containerSchema[voc.Core.SchemaVersion] ?? "",
};
}
/**
* Construct an array of Server Objects
* @param {string} serviceRoot Service root URL
* @return {Array} The list of servers
*/
function servers(serviceRoot) {
return [{ url: serviceRoot }];
}
/**
* Construct an array of Tag Objects from the entity container
* @param {object} container The entity container
* @return {Array} The list of tags
*/
function tags() {
const tags = [];
for (const [name, resource] of model.resources) {
// all entity sets and singletons
if (!resource.$Type) continue;
const tag = { name };
const type = model.element(resource.$Type);
const description =
resource[voc.Core.Description] || (type && type[voc.Core.Description]);
if (description) tag.description = description;
tags.push(tag);
}
return tags;
}
/**
* Construct the Paths Object from the entity container
* @param {object} container Entity container
* @return {object} Paths Object
*/
function paths(container) {
const paths = {};
for (const [name, child] of model.resources) {
if (rootResourcesToKeep && !rootResourcesToKeep.includes(name)) continue;
if (child.$Type) {
pathItems(
paths,
"/" + name,
[],
// entity sets and singletons are almost containment navigation properties
{ ...child, $ContainsTarget: true },
child,
name,
name,
child,
0,
"",
);
} else if (child.$Action) {
pathItemActionImport(paths, name, child);
} else if (child.$Function) {
pathItemFunctionImport(paths, name, child);
} else {
messages.push("Unrecognized entity container child: " + name);
}
}
if (model.resources.length > 0) pathItemBatch(paths, container);
return paths;
}
/**
* 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 = model.element(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 (
element.$Collection &&
(element.$ContainsTarget || (level < 2 && target))
) {
operationCreate(
pathItem,
element,
name,
sourceName,
targetName,
target,
level,
restrictions,
);
}
if (element.$ContainsTarget) {
pathItemsForBoundOperations(
paths,
prefix,
prefixParameters,
element,
sourceName,
);
if (element.$Collection) {
if (level <= maxLevels)
pathItemsWithKey(
paths,
prefix,
prefixParameters,
element,
root,
sourceName,
targetName,
target,
level,
navigationPath,
restrictions,
nonExpandable,
);
} else {
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];
if (element.$Collection) {
pathItemForFilterSegment(
paths,
prefix,
prefixParameters,
element,
sourceName,
target,
level,
restrictions,
);
}
}
/**
* 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) => 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 = model.element(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,
);
operationUpdate(
pathItem,
element,
name,
sourceName,
target,
level,
restrictions,
);
operationDelete(
pathItem,
element,
name,
sourceName,
target,
level,
restrictions,
);
if (
Object.keys(pathItem).filter((i) => i !== "parameters").length === 0
)
delete paths[path];
pathItemForMediaResource(
paths,
path,
parameters,
type,
name,
sourceName,
);
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 label = element[voc.Common.Label] || name;
const headInfo = element[voc.UI.HeaderInfo];
label = headInfo?.TypeName || label;
let summary =
insertRestrictions.Description ||
(level > 0 ? `Create related ${label}` : `Create ${label}`);
if (insertRestrictions.Insertable !== false) {
const type = model.element(element.$Type);
const hasStream = type && type.$HasStream;
pathItem.post = {
summary: summary,
...(insertRestrictions.LongDescription && {
description: insertRestrictions.LongDescription,
}),
tags: [sourceName, "CREATE"],
requestBody: {
description:
(type && type[voc.Core.Description]) ||
(hasStream ? "New media resource" : "New entity"),
required: true,
content: hasStream
? { "*/*": { schema: { type: "string", format: "binary" } } }
: {
"application/json": {
schema: ref(element.$Type, SUFFIX.create),
},
},
},
operationId: `create_${sourceName?.toLowerCase()}`,
responses: response(
201,
"Created entity",
{ $Type: element.$Type },
insertRestrictions.ErrorResponses,
),
};
if (targetName && sourceName != targetName)
pathItem.post.tags.push(targetName);
customParameters(pathItem.post, insertRestrictions);
}
}
/**
* 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;
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 || {};
let label = element[voc.Common.Label] || name;
const headInfo = element[voc.UI.HeaderInfo];
label = byKey
? headInfo?.TypeName || label
: headInfo?.TypeNamePlural || pluralize(label);
let summary =
descriptions.Description ||
(level > 0 ? `Get ${label}` : `Get ${byKey ? a(label) : label}`);
const collection = !byKey && element.$Collection;
const operation = {
summary: summary,
...(descriptions.LongDescription && {
description: descriptions.LongDescription,
}),
tags: [sourceName, collection ? "QUERY" : "GET"],
parameters: [],
operationId: `${
collection ? "query" : "get"
}_${sourceName?.toLowerCase()}`,
responses: response(
200,
"Retrieved entit" + (collection ? "ies" : "y"),
{ $Type: element.$Type, $Collection: collection },
byKey
? readByKeyRestrictions?.ErrorResponses
: readRestrictions?.ErrorResponses,
),
};
const deltaSupported =
element[voc.Capabilities.ChangeTracking] &&
element[voc.Capabilities.ChangeTracking].Supported;
if (collection && deltaSupported) {
const deltaLinkSchema = {
type: "string",
example:
basePath +
"/" +
name +
"?$deltatoken=opaque server-generated token for fetching the delta",
};
if (oas31) {
deltaLinkSchema.examples = [deltaLinkSchema.example];
delete deltaLinkSchema.example;
}
operation.responses[200].content["application/json"].schema.properties[
"@odata.deltaLink"
] = deltaLinkSchema;
}
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);
// if (collection) {
// optionApply(operation.parameters);
// }
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) {
let schema = jsonSchema(custom);
if (!schema) schema = { type: "string" };
if (custom.DocumentationURL)
schema.externalDocs = { url: custom.DocumentationURL };
//TODO: Examples in schema
return {
name: custom.Name,
in: location,
required: custom.Required || false,
...(custom.Description && { description: custom.Description }),
schema,
};
}
/**
* Add parameter for query option $apply
* @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 optionApply(parameters) {
if (applySupported) {
parameters.push({
$ref: "#/components/parameters/apply",
});
}
}
/**
* 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) {
if (csdl.$Version === "2.0") expandItems.shift(1);
parameters.push({
name: queryOptionPrefix + "expand",
description:
(targetRestrictions && targetRestrictions[voc.Core.Description]) ||
`Expand related entities, see ${
csdl.$Version === "2.0"
? "[URI Conventions (OData Version 2.0)](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/)"
: "[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,
},
},
example: "*",
});
}
}
}
/**
* 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 = model.element(element.$Type);
for (const [key, property] of model.propertiesOfStructuredType(type)) {
if (property.$Kind == "NavigationProperty") {
paths.push(prefix + key);
} else if (property.$Type && level < maxLevels) {
paths.push(...navigationPaths(property, 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) {
let example = "";
if (target?.$Type) {
const entityElement = model.element(target?.$Type);
example =
entityElement?.["$Key"]?.length > 0
? `${entityElement?.["$Key"][0]} ne null`
: "";
}
const filter = {
name: queryOptionPrefix + "filter",
description:
filterRestrictions[voc.Core.Description] ||
`Filter items by property values, see ${
csdl.$Version === "2.0"
? "[URI Conventions (OData Version 2.0)](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/)"
: "[Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)"
}`,
in: "query",
schema: {
type: "string",
},
example,
};
if (filterRestrictions.RequiresFilter) filter.required = true;
if (filterRestrictions.RequiredProperties) {
filter.description += "\n\nRequired filter properties:";
for (const item of filterRestrictions.RequiredProperties) {
filter.description += "\n- " + 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 = {};
for (const name of sortRestrictions.NonSortableProperties || []) {
nonSortable[name] = true;
}
const orderbyItems = [];
for (const property of primitivePaths(element)) {
if (nonSortable[property]) continue;
orderbyItems.push(property);
orderbyItems.push(property + " desc");
}
let example = "";
if (target?.$Type) {
const entityElement = model.element(target?.$Type);
example = entityElement?.["$Key"]
? entityElement?.["$Key"].toString()
: "";
}
if (orderbyItems.length > 0) {
parameters.push({
name: queryOptionPrefix + "orderby",
description:
sortRestrictions[voc.Core.Description] ||
`Order items by property values, see ${
csdl.$Version === "2.0"
? "[URI Conventions (OData Version 2.0)](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/)"
: "[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,
},
},
example,
});
}
}
}
/**
* 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 = model.element(element.$Type);
if (!elementType) {
messages.push(`Unknown type for element: ${JSON.stringify(element)}`);
return paths;
}
const propsOfType = model.propertiesOfStructuredType(elementType);
const ignore = propsOfType.filter(
([, type]) =>
type.$Kind !== "NavigationProperty" &&
type.$Type &&
!type.$Type.startsWith("Edm.") &&
!model.element(type.$Type),
);
// Keep old logging
for (const entry of ignore) {
messages.push(`Unknown type for element: ${JSON.stringify(entry)}`);
}
const properties = 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
) {
// messages.push(`Cycle detected ${property.typeRefChain.join("->")}`);
continue;
}
const expanded = Object.entries(property.properties)
.filter((property) => property[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 && model.element(property.$Type);
if (
propertyType &&
propertyType.$Kind &&
propertyType.$Kind === "ComplexType"
) {
return {
properties: model.propertiesMapOfStructuredType(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 {string} 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 && target[voc.Capabilities.SearchRestrictions]) ||
{};
if (searchRestrictions.Searchable !== false) {
if (searchRestrictions[voc.Core.Description]) {
parameters.push({
name: queryOptionPrefix + "search",
description: searchRestrictions[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 {string} 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 && target[voc.Capabilities.SelectSupport]) ||
{};
if (selectSupport.Supported !== false) {
const type = model.element(element.$Type) || {};
const selectItems = [];
for (const [name, property] of model.propertiesOfStructuredType(type)) {
if (property.$Kind === "NavigationProperty" && csdl.$Version !== "2.0")
continue;
selectItems.push(name);
}
if (selectItems.length > 0) {
parameters.push({
name: queryOptionPrefix + "select",
description: `Select properties to be returned, see ${
csdl.$Version === "2.0"
? "[URI Conventions (OData Version 2.0)](https://www.odata.org/documentation/odata-version-2-0/uri-conventions/)"
: "[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,
},
},
example: "*",
});
}
}
}
/**
* Add parameter for query option $skip
* @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 optionSkip(parameters, target, restrictions) {
const supported =
Object.keys(restrictions).length > 0
? restrictions.SkipSupported !== false
: target == null || target[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 {string} target Target container child of path
* @param {object} restrictions Navigation property restrictions of navigation segment
*/
function optionTop(parameters, target, restrictions) {
const supported =
Object.keys(restrictions).length > 0
? restrictions.TopSupported !== false
: target == null || target[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 {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 operationUpdate(
pathItem,
element,
name,
sourceName,
target,
level,
restrictions,
) {
const updateRestrictions =
restrictions.UpdateRestrictions ||
(target && target[voc.Capabilities.UpdateRestrictions]) ||
{};
let label = element[voc.Common.Label] || name;
const headInfo = element[voc.UI.HeaderInfo];
label = headInfo?.TypeName || label;
let summary =
updateRestrictions.Description ||
(level > 0 ? `Update related ${label}` : `Update ${label}`);
if (updateRestrictions.Updatable !== false) {
const type = model.element(element.$Type);
const operation = {
summary: summary,
...(updateRestrictions.LongDescription && {
description: updateRestrictions.LongDescription,
}),
tags: [sourceName, "PATCH"],
requestBody: {
description:
(type && type[voc.Core.Description]) || "New property values",
required: true,
content: {
"application/json": {
schema:
csdl.$Version === "2.0"
? {
type: "object",
title: "Modified " + nameParts(element.$Type).name,
properties: { d: ref(element.$Type, SUFFIX.update) },
}
: ref(element.$Type, SUFFIX.update),
},
},
},
operationId: `patch_${sourceName?.toLowerCase()}`,
responses: response(
204,
"Success",
undefined,
updateRestrictions.ErrorResponses,
),
};
customParameters(operation, updateRestrictions);
pathItem[updateRestrictions.UpdateMethod?.toLowerCase() || "patch"] =
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
* @param {string} sourceName Name of path source
* @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 operationDelete(
pathItem,
element,
name,
sourceName,
target,
level,
restrictions,
) {
const deleteRestrictions =
restrictions.DeleteRestrictions ||
(target && target[voc.Capabilities.DeleteRestrictions]) ||
{};
let label = element[voc.Common.Label] || name;
const headInfo = element[voc.UI.HeaderInfo];
label = headInfo?.TypeName || label;
let summary =
deleteRestrictions.Description ||
(level > 0 ? `Delete related ${label}` : `Delete ${label}`);
if (deleteRestrictions.Deletable !== false) {
pathItem.delete = {
summary: summary,
...(deleteRestrictions.LongDescription && {
description: deleteRestrictions.LongDescription,
}),
tags: [sourceName, "DELETE"],
operationId: `delete_${sourceName?.toLowerCase()}`,
responses: response(
204,
"Success",
undefined,
deleteRestrictions.ErrorResponses,
),
};
customParameters(pathItem.delete, deleteRestrictions);
}
}
/**
* Add path and Path Item Object for media resource
* @param {object} paths The Paths Object to augment
* @param {string} prefix Prefix for path
* @param {Array} prefixParameters Parameter Objects for prefix
* @param {object} type Entity type object of navigation segment
* @param {string} name Name of navigation segment
* @param {string} sourceName Name of path source
*/
function pathItemForMediaResource(
paths,
prefix,
prefixParameters,
type,
name,
sourceName,
) {
if (!type.$HasStream) return;
const pathItem = {
parameters: prefixParameters,
get: {
summary: `Get media resource from ${name} by key`,
tags: [sourceName],
responses: {
200: {
description: "Retrieved media resource",
content: {
"*/*": { schema: { type: "string", format: "binary" } },
},
},
"4XX": {
$ref: "#/components/responses/error",
},
},
},
};
paths[`${prefix}/$value`] = pathItem;
}
/**
* Add paths and Path Item Objects for navigation segments
* @param {object} paths The Paths Object to augment
* @param {string} prefix Prefix for path
* @param {Array} prefixParameters Parameter Objects for prefix
* @param {object} type Entity type object of navigation segment
* @param {string} sourceName Name of path source
* @param {integer} level Number of navigation segments so far
* @param {string} navigationPrefix Path for finding navigation restrictions
*/
function pathItemsWithNavigation(
paths,
prefix,
prefixParameters,
type,
root,
sourceName,
level,
navigationPrefix,
) {
if (
type["@SAP__common.ResultContext"] !== true &&
(!type || level >= maxLevels)
) {
return;
}
const parentRestrictions = navigationPropertyRestrictions(
root,
navigationPrefix,
);
if (parentRestrictions.Navigability == "Single") return;
const navigationRestrictions =
root[voc.Capabilities.NavigationRestrictions] || {};
const rootNavigable =
(level == 0 && navigationRestrictions.Navigability != "None") ||
(level == 1 && navigationRestrictions.Navigability != "Single") ||
level > 1;
const properties = navigationPathMap(type);
for (const [name, property] of Object.entries(properties)) {
if (
!property.$ContainsTarget &&
entityTypesToKeep &&
!entityTypesToKeep.includes(property.$Type)
)
continue;
const navigationPath =
navigationPrefix + (navigationPrefix.length > 0 ? "/" : "") + name;
const restrictions = navigationPropertyRestrictions(root, navigationPath);
if (
["Recursive", "Single"].includes(restrictions.Navigability) ||
(restrictions.Navigability == null && rootNavigable)
) {
const targetSetName =
root.$NavigationPropertyBinding &&
root.$NavigationPropertyBinding[navigationPath];
const target = entityContainer[targetSetName];
pathItems(
paths,
prefix + "/" + name,
prefixParameters,
property,
root,
sourceName,
targetSetName,
target,
level + 1,
navigationPath,
);
}
}
}
/**
* Collect navigation paths of a navigation segment and its potentially structured components
* @param {object} type Structured type
* @param {object} map Map of navigation property paths and their types
* @param {string} prefix Navigation prefix
* @param {integer} level Number of navigation segments so far
* @return {object} Map of navigation property paths and their types
*/
function navigationPathMap(type, map = {}, prefix = "", level = 0) {
for (const [name, property] of model.propertiesOfStructuredType(type)) {
if (property.$Kind == "NavigationProperty") {
map[prefix + name] = property;
} else if (property.$Type && !property.$Collection && level < maxLevels) {
navigationPathMap(
model.element(property.$Type),
map,
prefix + name + "/",
level + 1,
);
}
}
return map;
}
/**
* Construct map of key names for an entity type
* @param {object} type Entity type object
* @return {object} Map of key names
*/
function keyMap(type) {
const map = {};
if (type.$Kind == "EntityType") {
const keys = model.key(type);
for (const key of keys) {
if (typeof key == "string") map[key] = true;
}
}
return map;
}
/**
* Key for path item
* @param {object} entityType Entity Type object
* @param {integer} level Number of navigation segments so far
* @return {object} key: Key segment, parameters: key parameters
*/
function entityKey(entityType, level) {
let segment = "";
const params = [];
const keys = model.key(entityType);
const properties = model.propertiesMapOfStructuredType(entityType);
keys.forEach((key, index) => {
const suffix = level > 0 ? "_" + level : "";
if (keyAsSegment) segment += "/";
else {
if (index > 0) segment += ",";
if (keys.length != 1) segment += key + "=";
}
let parameter;
let property = {};
if (typeof key == "string") {
parameter = key;
property = properties[key];
} else {
parameter = Object.keys(key)[0];
const segments = key[parameter].split("/");
property = properties[segments[0]];
for (let i = 1; i < segments.length; i++) {
const complexType = model.element(property.$Type);
const properties = model.propertiesMapOfStructuredType(complexType);
property = properties[segments[i]];
}
}
const propertyType = property.$Type;
segment +=
pathValuePrefix(propertyType) +
"{" +
parameter +
suffix +
"}" +
pathValueSuffix(propertyType);
const param = {
description:
[property[voc.Core.Description], property[voc.Core.LongDescription]]
.filter((t) => t)
.join(" \n") || "key: " + parameter,
in: "path",
name: parameter + suffix,
required: true,
schema: schema(property, "", true),
};
params.push(param);
});
return {
segment: (keyAsSegment ? "" : "(") + segment + (keyAsSegment ? "" : ")"),
parameters: params,
};
}
/**
* Prefix for key value in key segment
* @param {typename} Qualified name of key property type
* @return {string} value prefix
*/
function pathValuePrefix(typename) {
//TODO: handle other Edm types, enumeration types, and type definitions
if (
[
"Edm.Int64",
"Edm.Int32",
"Edm.Int16",
"Edm.SByte",
"Edm.Byte",
"Edm.Decimal",
"Edm.Double",
"Edm.Single",
"Edm.Boolean",
"Edm.Date",
"Edm.DateTimeOffset",
"Edm.TimeOfDay",
"Edm.Guid",
].includes(typename)
)
return "";
if (keyAsSegment) return "";
return "'";
}
/**
* Suffix for key value in key segment
* @param {typename} Qualified name of key property type
* @return {string} value prefix
*/
function pathValueSuffix(typename) {
//TODO: handle other Edm types, enumeration types, and type definitions
if (
[
"Edm.Int64",
"Edm.Int32",
"Edm.Int16",
"Edm.SByte",
"Edm.Byte",
"Edm.Decimal",
"Edm.Double",
"Edm.Single",
"Edm.Boolean",
"Edm.Date",
"Edm.DateTimeOffset",
"Edm.TimeOfDay",
"Edm.Guid",
].includes(typename)
)
return "";
if (keyAsSegment) return "";
return "'";
}
/**
* Add path and Path Item Object for actions and functions bound to the element
* @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 the operations are bound to
* @param {string} sourceName Name of path source
* @param {boolean} byKey read by key
*/
function pathItemsForBoundOperations(
paths,
prefix,
prefixParameters,
element,
sourceName,
byKey = false,
) {
const overloads =
//TODO: make method
m