niwe-odata-openapi
Version:
Convert OData CSDL XML or CSDL JSON to OpenAPI
445 lines (408 loc) • 15.2 kB
JavaScript
/**
* Entity Data Model for OData
*
* Latest version: https://github.com/oasis-tcs/odata-openapi/blob/main/lib/edm.js
*/
module.exports = { nameParts };
module.exports.EDM = class {
#elements = {}; // Map of namespace-qualified name to model element
//TODO: stronger encapsulation: private members with getters?
//TODO: use namespace-qualified names instead of document-local aliases
boundOverloads = {}; // Map of action/function names to bound overloads
derivedTypes = {}; // Map of type names to derived types
//TODO: maps per document for document-local aliases
#alias = {}; // Map of namespace or alias to alias
namespace = { Edm: "Edm" }; // Map of namespace or alias to namespace
namespaceUrl = {}; // Map of namespace to reference URL
voc = {}; // Map of vocabulary and terms to qualified name in this CSDL
/**
* Add CSDL document to model
* @param {object} csdl CSDL document
* @param {Array} messages Warnings
*/
//TODO: multi-document models
//TODO: different aliases per document
addDocument(csdl, messages) {
this.#processIncludes(csdl);
//TODO: different term maps per document, at least for Core, JSON, Validation
vocabularies(this.voc, this.#alias);
this.#processSchemas(csdl, messages);
//TODO: only for first? document
this.entityContainer = this.#elements[csdl.$EntityContainer];
}
/**
* Extract included namespaces and their aliases
* @param {object} csdl CSDL document
*/
#processIncludes(csdl) {
for (const [url, reference] of Object.entries(csdl.$Reference ?? {})) {
for (const include of reference.$Include ?? []) {
const qualifier = include.$Alias ?? include.$Namespace;
this.#alias[include.$Namespace] = qualifier;
this.namespace[qualifier] = include.$Namespace;
this.namespace[include.$Namespace] = include.$Namespace;
this.namespaceUrl[include.$Namespace] = url;
}
}
}
/**
* Extract namespaces, aliases, model elements, bound overloads, and derived types
* @param {object} csdl CSDL document
* @param {Array} messages Warnings
*/
#processSchemas(csdl, messages) {
for (const [namespace, schema] of Object.entries(csdl)) {
if (!isIdentifier(namespace)) continue;
const isDefaultNamespace = schema[this.voc.Core.DefaultNamespace];
const qualifier = schema.$Alias || namespace;
this.#alias[namespace] = qualifier;
this.namespace[qualifier] = namespace;
this.namespace[namespace] = namespace;
for (const [name, element] of Object.entries(schema)) {
if (!isIdentifier(name)) continue;
//TODO: copy element to avoid modifying input CSDL in processAnnotations
//TODO: namespace-qualify any type references inside copy of element?
//TODO: - $BaseType, property.$Type, $ReturnType.$Type, $Parameter[].$Type, resource.$Type, resource.$Action, resource.$Function
//TODO: - embedded annotations
//TODO: or inject reference to doc-local alias map?
this.#elements[`${namespace}.${name}`] = element;
if (Array.isArray(element)) {
const qualifiedName = qualifier + "." + name;
for (const overload of element) {
if (!overload.$IsBound) continue;
//TODO: this "-c" trick seems a bit hacky
const type =
overload.$Parameter[0].$Type +
(overload.$Parameter[0].$Collection ? "-c" : "");
if (!this.boundOverloads[type]) this.boundOverloads[type] = [];
this.boundOverloads[type].push({
name: isDefaultNamespace ? name : qualifiedName,
overload: overload,
});
}
}
}
}
for (const [namespace, schema] of Object.entries(csdl)) {
if (!isIdentifier(namespace)) continue;
this.#processAnnotations(schema.$Annotations ?? {}, messages);
for (const [name, element] of Object.entries(schema)) {
if (!isIdentifier(name)) continue;
if (element.$BaseType) {
const base = this.namespaceQualifiedName(element.$BaseType);
if (!this.derivedTypes[base]) this.derivedTypes[base] = [];
this.derivedTypes[base].push(namespace + "." + name);
}
}
}
Object.entries(this.#elements[csdl.$EntityContainer] ?? {})
.filter(([name]) => isIdentifier(name))
.forEach(([name, object]) => {
if (object["$Type"]) {
const element = this.element(object["$Type"]);
if (element) {
object[this.voc.UI.HeaderInfo] = element[this.voc.UI.HeaderInfo];
object[this.voc.Common.Label] = element[this.voc.Common.Label];
}
}
});
}
/**
* Inject annotations with external targeting into target model elements
* @param {object} externalAnnotations Annotations with external targeting
* @param {Array} messages Warnings
*/
#processAnnotations(externalAnnotations, messages) {
for (const [target, annotations] of Object.entries(externalAnnotations)) {
const segments = target.split("/");
const open = segments[0].indexOf("(");
let element;
if (open == -1) {
element = this.element(segments[0]);
} else {
element = this.element(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 || "")) ||
args ==
(overload.$Parameter || [])
.map((p) => {
const type = p.$Type || "Edm.String";
return p.$Collection ? `Collection(${type})` : type;
})
.join(","),
);
}
if (!element) {
messages.push(`Invalid annotation target '${target}'`);
} else if (Array.isArray(element)) {
messages.push(
`Ignoring annotations targeting all overloads of '${target}'`,
);
//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 (typeof element[segments[1]] === "object") {
Object.assign(element[segments[1]], annotations);
} else if (element.$Kind !== "EnumType") {
messages.push(`Invalid annotation target '${target}'`);
}
}
break;
default:
messages.push("More than two annotation target path segments");
}
}
}
}
/**
* Find model element by qualified name
* @param {string} qualifiedName Qualified name of model element
* @return {object} Model element
*/
element(qualifiedName) {
return this.#elements[this.namespaceQualifiedName(qualifiedName)];
}
/**
* a qualified name consists of a namespace or alias, a dot, and a simple name
* @param {string} qualifiedName
* @return {string} namespace-qualified name
*/
namespaceQualifiedName(qualifiedName) {
let np = nameParts(qualifiedName);
return this.namespace[np.qualifier] + "." + np.name;
}
/**
* Key of entity type
* @param {object} entityType Entity Type object
* @return {array} Key of entity type or empty array
*/
key(entityType) {
let type = entityType;
let _key = null;
while (type) {
_key = type.$Key;
if (_key || !type.$BaseType) break;
type = this.element(type.$BaseType);
}
return _key ?? [];
}
/**
* All resources of the model's entity container
* @return {Array} Array of [name, resourceObject] arrays
*/
get resources() {
return Object.entries(this.entityContainer ?? {}).filter(([name]) =>
isIdentifier(name),
);
}
/**
* All structured types onf the model
* @return {Array} Array of [name, typeObject] arrays
*/
get structuredTypes() {
return Object.entries(this.#elements).filter(([, element]) =>
["EntityType", "ComplexType"].includes(element.$Kind),
);
}
/**
* All properties of a structured type including inherited ones
* @param {object} type Structured type
* @return {object} Map of properties
*/
propertiesMapOfStructuredType(type) {
const properties =
type && type.$BaseType
? this.propertiesMapOfStructuredType(this.element(type.$BaseType))
: {};
if (type) {
Object.keys(type)
.filter((name) => isIdentifier(name))
.forEach((name) => {
properties[name] = type[name];
if (
properties[name]?.["$Type"] &&
this.element(properties[name]?.["$Type"])
) {
const element = this.element(properties[name]?.["$Type"]);
Object.keys(this.element(properties[name]?.["$Type"]))
.filter((e) => e.startsWith("@"))
.forEach((e) => {
properties[name][e] = element[e];
});
}
});
// DataPoint Annotations
Object.keys(type)
.filter((name) => name.startsWith(`${this.voc.UI.DataPoint}#`))
.forEach((name) => {
const propertyName = name.slice(19);
if (properties[propertyName]) {
properties[propertyName][this.voc.UI.DataPoint] = type[name];
}
});
}
return properties;
}
/**
* All properties of a structured type including inherited ones
* @param {object} type Structured type
* @return {Array} Array of [name, propertyObject] arrays
*/
propertiesOfStructuredType(type) {
return Object.entries(this.propertiesMapOfStructuredType(type));
}
/**
* Direct properties of a structured type excluding inherited ones
* @param {object} type Structured type
* @return {Array} Array of [name, propertyObject] arrays
*/
directPropertiesOfStructuredType(type) {
return Object.entries(type).filter(([name]) => isIdentifier(name));
}
/**
* Members of an enumeration type
* @param {object} type Structured type
* @return {Array} Array of [name, propertyObject] arrays
*/
enumTypeMembers(type) {
return Object.entries(type).filter(([name]) => isIdentifier(name));
}
/**
* Entity types referenced by root resources
* @param {Array} rootResources Array of entity container child names
* @return {Array} Array of entity type names
*/
referencedEntityTypes(rootResources) {
const entityTypes = [];
for (const name of rootResources) {
const child = this.entityContainer[name];
if (child?.$Type && !entityTypes.includes(child.$Type))
entityTypes.push(child.$Type);
// collect action/function overload return types
if (child?.$Action || child?.$Function) {
const overloads = this.element(child.$Action || child.$Function);
for (const overload of overloads) {
if (overload.$IsBound) continue;
const returnType = overload.$ReturnType?.$Type;
if (returnType && !entityTypes.includes(returnType))
entityTypes.push(returnType);
}
}
}
// collect contained entity types
for (const name of entityTypes) {
const containedTypes = this.propertiesOfStructuredType(this.element(name))
.filter((pair) => pair[1].$ContainsTarget)
.map((pair) => pair[1].$Type);
for (const ct of containedTypes)
if (!entityTypes.includes(ct)) entityTypes.push(ct);
}
return entityTypes;
}
};
/**
* an identifier does not start with $ and does not contain @
* @param {string} name
* @return {boolean} name is an identifier
*/
function isIdentifier(name) {
return !name.startsWith("$") && !name.includes("@");
}
/**
* a qualified name consists of a namespace or alias, a dot, and a simple name
* @param {string} qualifiedName
* @return {object} with components qualifier and name
*/
function nameParts(qualifiedName) {
const pos = qualifiedName.lastIndexOf(".");
return {
qualifier: qualifiedName.substring(0, pos),
name: qualifiedName.substring(pos + 1),
};
}
/**
* 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 vocabularies(voc, alias) {
const terms = {
Aggregation: ["ApplySupported"],
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",
],
UI: ["DataPoint", "Hidden", "HeaderInfo"],
Common: ["Label", "QuickInfo", "SemanticObject"],
JSON: ["Schema"],
Validation: ["AllowedValues", "Exclusive", "Maximum", "Minimum", "Pattern"],
};
for (const vocab of Object.keys(terms)) {
voc[vocab] = {};
const namespace =
vocab === "UI" || vocab === "Common"
? `com.sap.vocabularies.${vocab}.v1`
: `Org.OData.${vocab}.V1`;
for (const term of terms[vocab]) {
voc[vocab][term] = `@${alias[namespace] || namespace}.${term}`;
}
}
}