From 7163b0bf2bc78016eb38eaa1c86f2511c57c1eb6 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 19:37:50 +0200 Subject: [PATCH 1/8] refactor: decompose getSchema to satisfy complexity limit --- lib/compile/csdl2openapi.js | 235 +++++++++++++++++++----------------- 1 file changed, 122 insertions(+), 113 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index acc2f20..8299a0b 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2356,55 +2356,51 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } /** - * Construct Schema Object for model object referencing a type + * Construct the base Schema Object for the referenced type, before applying + * facets like nullability, defaults, validation, collection wrapping, and + * descriptions. * @param {*} element referencing a type - * @return {*} Schema Object + * @param {string} suffix Optional suffix for referenced structured-type schemas + * @return {Schema} Schema Object */ - function getSchema(element, suffix = '', forParameter = false, forFunction = false) { - - /** @type {Schema} */ - let s = {}; + function getBaseSchema(element, suffix) { switch (element.$Type) { case 'Edm.AnnotationPath': case 'Edm.meta.modelElementPath': case 'Edm.NavigationPropertyPath': case 'Edm.PropertyPath': - s = { type: 'string' }; - break; - case 'Edm.Binary': - s = { + return { type: 'string' }; + case 'Edm.Binary': { + const s = { type: 'string', format: 'base64url' }; if (element.$MaxLength) s.maxLength = Math.ceil(4 * element.$MaxLength / 3); - break; + return s; + } case 'Edm.Boolean': - s = { type: 'boolean' }; - break; + return { type: 'boolean' }; case 'Edm.Byte': - s = { + return { type: 'integer', format: 'uint8' }; - break; case 'Edm.Date': - s = { + return { type: 'string', format: 'date', example: '2017-04-13' }; - break; case 'Edm.DateTime': case 'Edm.DateTimeOffset': - s = { + return { type: 'string', format: 'date-time', example: `2017-04-13T15:51:04${isNaN(element.$Precision) || element.$Precision === 0 ? '' : `.${'0'.repeat(element.$Precision)}`}Z` }; - break; case 'Edm.Decimal': { const preDecimal = /** @type {const}*/({ type: 'number', format: 'decimal' }) - s = { + const s = { anyOf: [preDecimal, { type: 'string' }], example: 0 }; @@ -2424,113 +2420,115 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot preDecimal.maximum = limit - delta; preDecimal.minimum = -preDecimal.maximum; } - break; + return s; } case 'Edm.Double': - s = { + return { anyOf: [{ type: 'number', format: 'double' }, { type: 'string' }], example: 3.14 }; - break; case 'Edm.Duration': - s = { + return { type: 'string', format: 'duration', example: 'P4DT15H51M04S' }; - break; case 'Edm.GeographyPoint': - case 'Edm.GeometryPoint': - s = ref('geoPoint'); + case 'Edm.GeometryPoint': { + const s = ref('geoPoint'); typesToInline.geoPoint = true; - break; + return s; + } case 'Edm.Guid': - s = { + return { type: 'string', format: 'uuid', example: '01234567-89ab-cdef-0123-456789abcdef' }; - break; case 'Edm.Int16': - s = { + return { type: 'integer', format: 'int16' }; - break; case 'Edm.Int32': - s = { + return { type: 'integer', format: 'int32' }; - break; case 'Edm.Int64': - s = { + return { anyOf: [{ type: 'integer', format: 'int64' }, { type: 'string' }], example: "42" }; - break; case 'Edm.PrimitiveType': - s = { + return { anyOf: [{ type: 'boolean' }, { type: 'number' }, { type: 'string' }] }; - break; case 'Edm.SByte': - s = { + return { type: 'integer', format: 'int8' }; - break; case 'Edm.Single': - s = { + return { anyOf: [{ type: 'number', format: 'float' }, { type: 'string' }], example: 3.14 }; - break; - case 'Edm.Stream': - // eslint-disable-next-line no-case-declarations + case 'Edm.Stream': { const jsonSchema = element[meta.voc.JSON.Schema]; if (jsonSchema) { if (typeof jsonSchema == 'string') - s = JSON.parse(jsonSchema); - else - s = jsonSchema; - } else { - s = { - type: 'string', - format: 'base64url' - }; + return JSON.parse(jsonSchema); + return jsonSchema; } - break; + return { + type: 'string', + format: 'base64url' + }; + } case 'Edm.String': case undefined: { - s = { type: 'string' }; + const s = { type: 'string' }; if (element.$MaxLength) s.maxLength = element.$MaxLength; const pattern = element[meta.voc.Validation.Pattern]; if (pattern) s.pattern = pattern; - break; + return s; } case 'Edm.TimeOfDay': - s = { + return { type: 'string', format: 'time', example: '15:51:04' }; - break; default: if (element.$Type.startsWith('Edm.')) { DEBUG?.(`Unknown type: ${element.$Type}`); - } else { + return {}; + } + { const type = meta.modelElement(element.$Type); const isStructured = type && ['ComplexType', 'EntityType'].includes(type.$Kind); - s = ref(element.$Type, (isStructured ? suffix : '')); + let s = ref(element.$Type, (isStructured ? suffix : '')); if (element.$MaxLength) { s = { allOf: [s], maxLength: element.$MaxLength }; } + return s; } } + } + + /** + * Construct Schema Object for model object referencing a type + * @param {*} element referencing a type + * @return {*} Schema Object + */ + function getSchema(element, suffix = '', forParameter = false, forFunction = false) { + + /** @type {Schema} */ + let s = getBaseSchema(element, suffix); allowedValues(s, element); @@ -2549,61 +2547,10 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot s.example = element[meta.voc.Core.Example].Value; } - /** @returns {s is import('./types.d.ts').StringSchema} */ - const isStringSchema = s => s?.type === 'string' - /** @returns {s is import('./types.d.ts').NumberSchema} */ - const isNumberSchema = s => s?.type === 'number' || s?.type === 'integer' - /** @returns {s is import('./types.d.ts').AnyOf} */ - const isAnyOfSchema = s => Boolean(s?.anyOf) + if (forFunction) applyFunctionFacets(s, element); - if (forFunction) { - if (s.example && typeof s.example === "string") { - s.example = `${pathValuePrefix(element.$Type)}${s.example - }${pathValueSuffix(element.$Type)} `; - } - if (isStringSchema(s)) { - if (s.pattern) { - const pre = pathValuePrefix(element.$Type); - const suf = pathValueSuffix(element.$Type); - s.pattern = s.pattern.replace(/^\^/, `^ ${pre} (`); - s.pattern = s.pattern.replace(/\$$/, `)${suf} $`); - } else if (!element.$Type || element.$Type === "Edm.String") { - s.pattern = "^'([^']|'')*'$"; - } - } - if (element.$Nullable) { - s.default = "null"; - if (isStringSchema(s) && s.pattern) { - s.pattern = s.pattern - .replace(/^\^/, "^(null|") - .replace(/\$$/, ")$"); - } - } - } - - if (element[meta.voc.Validation.Maximum] !== undefined) { - if (s.$ref) s = { allOf: [s] }; - if (isAnyOfSchema(s) && isNumberSchema(s.anyOf[0])) { - s.anyOf[0].maximum = element[meta.voc.Validation.Maximum]; - } else if (isNumberSchema(s)) { - s.maximum = element[meta.voc.Validation.Maximum]; - } - // TODO: this implies that we could be handling an AnyOfSchema here. So exclusiveMinimum is attach to that? Or to its first element, as above? - // @ts-expect-error - if (element[meta.voc.Validation.Maximum + meta.voc.Validation.Exclusive]) s.exclusiveMaximum = true; - } - - if (element[meta.voc.Validation.Minimum] !== undefined) { - if (s.$ref) s = { allOf: [s] }; - if (isAnyOfSchema(s) && isNumberSchema(s.anyOf[0])) { - s.anyOf[0].minimum = element[meta.voc.Validation.Minimum]; - } else if (isNumberSchema(s)) { - s.minimum = element[meta.voc.Validation.Minimum]; - } - // TODO: see above - // @ts-expect-error - if (element[meta.voc.Validation.Minimum + meta.voc.Validation.Exclusive]) s.exclusiveMinimum = true; - } + s = applyRangeValidation(s, element, 'Maximum'); + s = applyRangeValidation(s, element, 'Minimum'); if (element.$Collection) { s = { @@ -2634,6 +2581,68 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot return s; } + /** @returns {s is import('./types.d.ts').StringSchema} */ + function isStringSchema(s) { return s?.type === 'string'; } + /** @returns {s is import('./types.d.ts').NumberSchema} */ + function isNumberSchema(s) { return s?.type === 'number' || s?.type === 'integer'; } + /** @returns {s is import('./types.d.ts').AnyOf} */ + function isAnyOfSchema(s) { return Boolean(s?.anyOf); } + + /** + * Apply function-import formatting to a Schema Object in place: wrap the + * example and pattern with the OData key-value delimiters and encode + * nullability for function parameters passed in the request path. + * @param {Schema} s Schema Object to augment in place + * @param {*} element Model element referencing a type + */ + function applyFunctionFacets(s, element) { + if (s.example && typeof s.example === "string") { + s.example = `${pathValuePrefix(element.$Type)}${s.example + }${pathValueSuffix(element.$Type)} `; + } + if (isStringSchema(s)) { + if (s.pattern) { + const pre = pathValuePrefix(element.$Type); + const suf = pathValueSuffix(element.$Type); + s.pattern = s.pattern.replace(/^\^/, `^ ${pre} (`); + s.pattern = s.pattern.replace(/\$$/, `)${suf} $`); + } else if (!element.$Type || element.$Type === "Edm.String") { + s.pattern = "^'([^']|'')*'$"; + } + } + if (element.$Nullable) { + s.default = "null"; + if (isStringSchema(s) && s.pattern) { + s.pattern = s.pattern + .replace(/^\^/, "^(null|") + .replace(/\$$/, ")$"); + } + } + } + + /** + * Apply a Validation.Maximum or Validation.Minimum facet to a Schema Object. + * Rewraps a $ref schema in an allOf so the numeric bound can be attached, + * and honours the matching Exclusive flag. + * @param {Schema} s Schema Object + * @param {*} element Model element referencing a type + * @param {'Maximum'|'Minimum'} kind Which bound to apply + * @return {Schema} The (possibly rewrapped) Schema Object + */ + function applyRangeValidation(s, element, kind) { + if (element[meta.voc.Validation[kind]] === undefined) return s; + if (s.$ref) s = { allOf: [s] }; + const bound = kind.toLowerCase(); + if (isAnyOfSchema(s) && isNumberSchema(s.anyOf[0])) { + s.anyOf[0][bound] = element[meta.voc.Validation[kind]]; + } else if (isNumberSchema(s)) { + s[bound] = element[meta.voc.Validation[kind]]; + } + // @ts-expect-error + if (element[meta.voc.Validation[kind] + meta.voc.Validation.Exclusive]) s[`exclusive${kind}`] = true; + return s; + } + /** * Add allowed values enum to Schema Object for string-like model element * @param {*} schema Schema Object to augment From 49ee023ac5c0fee91cb22f5581b5003715a9bdd8 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 19:40:41 +0200 Subject: [PATCH 2/8] refactor: decompose operationRead to satisfy complexity limit --- lib/compile/csdl2openapi.js | 480 +++++++++++++++++++++--------------- 1 file changed, 284 insertions(+), 196 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 8299a0b..57e336e 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -85,6 +85,31 @@ const ER_ANNOTATIONS = Object.freeze( '@EntityRelationship.referencesWithConstantIds': 'x-entity-relationship-references-with-constant-ids' }); +/** + * Base Schema Objects for Edm primitive types whose mapping does not depend on + * any facet of the model element (no MaxLength, Precision, Pattern, etc.). + * Callers must clone before mutating, since the schema is augmented in place. + */ +const CONSTANT_EDM_SCHEMAS = Object.freeze({ + 'Edm.AnnotationPath': { type: 'string' }, + 'Edm.meta.modelElementPath': { type: 'string' }, + 'Edm.NavigationPropertyPath': { type: 'string' }, + 'Edm.PropertyPath': { type: 'string' }, + 'Edm.Boolean': { type: 'boolean' }, + 'Edm.Byte': { type: 'integer', format: 'uint8' }, + 'Edm.Date': { type: 'string', format: 'date', example: '2017-04-13' }, + 'Edm.Double': { anyOf: [{ type: 'number', format: 'double' }, { type: 'string' }], example: 3.14 }, + 'Edm.Duration': { type: 'string', format: 'duration', example: 'P4DT15H51M04S' }, + 'Edm.Guid': { type: 'string', format: 'uuid', example: '01234567-89ab-cdef-0123-456789abcdef' }, + 'Edm.Int16': { type: 'integer', format: 'int16' }, + 'Edm.Int32': { type: 'integer', format: 'int32' }, + 'Edm.Int64': { anyOf: [{ type: 'integer', format: 'int64' }, { type: 'string' }], example: "42" }, + 'Edm.PrimitiveType': { anyOf: [{ type: 'boolean' }, { type: 'number' }, { type: 'string' }] }, + 'Edm.SByte': { type: 'integer', format: 'int8' }, + 'Edm.Single': { anyOf: [{ type: 'number', format: 'float' }, { type: 'string' }], example: 3.14 }, + 'Edm.TimeOfDay': { type: 'string', format: 'time', example: '15:51:04' }, +}); + /** * Construct an OpenAPI description from a CSDL document * @param {CSDL} csdl CSDL document @@ -760,62 +785,114 @@ module.exports.csdl2openapi = function ( const targetRestrictions = target?.[meta.voc.Capabilities.ReadRestrictions]; const readRestrictions = restrictions.ReadRestrictions || targetRestrictions || {}; const readByKeyRestrictions = readRestrictions.ReadByKeyRestrictions; - let readable = true; - const countRestrictions = target && (target[meta.voc.Capabilities.CountRestrictions]?.Countable === false); + if (!isReadable({ byKey, readRestrictions, readByKeyRestrictions })) return; + + const collection = !byKey && element.$Collection; + const operation = buildReadOperation({ + element, name, sourceName, targetName, target, level, restrictions, + byKey, targetRestrictions, readRestrictions, readByKeyRestrictions, collection + }); + 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; + } + + /** + * Determine whether a read operation is allowed by the read restrictions + * @param {object} options + * @param {boolean} options.byKey Read by key + * @param {*} options.readRestrictions Read restrictions + * @param {*} options.readByKeyRestrictions Read-by-key restrictions + * @returns {boolean} Whether the resource is readable + */ + function isReadable({ byKey, readRestrictions, readByKeyRestrictions }) { if (byKey && readByKeyRestrictions && readByKeyRestrictions.Readable !== undefined) - readable = readByKeyRestrictions.Readable; - else if (readRestrictions.Readable !== undefined) - readable = readRestrictions.Readable; + return readByKeyRestrictions.Readable; + if (readRestrictions.Readable !== undefined) + return readRestrictions.Readable; + return true; + } - 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({ - operation: 'Retrieves', - name, - sourceName, - level, - collection: element.$Collection, - byKey - }), - tags: [normaliseTag(sourceName)], - parameters: [], - responses: response({ - code: 200, - description: `Retrieved ${byKey ? pluralize.singular(lname) : lname}`, - type: { $Type: element.$Type, $Collection: collection }, - errors: byKey ? readByKeyRestrictions?.ErrorResponses : readRestrictions?.ErrorResponses, - isCount: !countRestrictions - }) - }; - const deltaSupported = isDeltaSupported(element); - if (!byKey && deltaSupported) { - // @ts-expect-error - set above - operation.responses[200].content['application/json'].schema.properties['@odata.deltaLink'] = { - type: 'string', - example: `${basePath}/${name}?$deltatoken=opaque server-generated token for fetching the delta` - } - } - if (descriptions.LongDescription) operation.description = descriptions.LongDescription; - if (target && sourceName != targetName) operation.tags.push(normaliseTag(targetName)); - customParameters(operation, byKey ? readByKeyRestrictions || readRestrictions : readRestrictions); - - if (collection) { - 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); - } + /** + * Construct the read Operation Object + * @param {object} options + * @param {*} options.element Model element of navigation segment + * @param {string} options.name Name of navigation segment + * @param {string} options.sourceName Name of path source + * @param {string} options.targetName Name of path target + * @param {null | TargetRestrictions} options.target Target container child of path + * @param {number} options.level Number of navigation segments so far + * @param {*} options.restrictions Navigation property restrictions of navigation segment + * @param {boolean} options.byKey Read by key + * @param {*} options.targetRestrictions Read restrictions of the target + * @param {*} options.readRestrictions Effective read restrictions + * @param {*} options.readByKeyRestrictions Read-by-key restrictions + * @param {*} options.collection Whether the response is a collection + * @returns {*} Operation Object + */ + function buildReadOperation({ element, name, sourceName, targetName, target, level, restrictions, byKey, targetRestrictions, readRestrictions, readByKeyRestrictions, collection }) { + const descriptions = readDescriptions({ level, targetRestrictions, restrictions, byKey }); + const lname = splitName(name); + const summary = descriptions.Description || operationSummary({ + operation: 'Retrieves', name, sourceName, level, collection: element.$Collection, byKey + }); + const errors = byKey ? readByKeyRestrictions?.ErrorResponses : readRestrictions?.ErrorResponses; + const isCount = !(target && target[meta.voc.Capabilities.CountRestrictions]?.Countable === false); + const operation = { + summary, + tags: [normaliseTag(sourceName)], + parameters: [], + responses: response({ + code: 200, + description: `Retrieved ${byKey ? pluralize.singular(lname) : lname}`, + type: { $Type: element.$Type, $Collection: collection }, + errors, + isCount + }) + }; + if (!byKey) addDeltaLink(operation, element, name); + if (descriptions.LongDescription) operation.description = descriptions.LongDescription; + if (target && sourceName != targetName) operation.tags.push(normaliseTag(targetName)); + return operation; + } - optionSelect(operation.parameters, element, target, restrictions); - optionExpand(operation.parameters, element, target, nonExpandable); + /** + * Resolve the description-carrying restrictions for a read operation + * @param {object} options + * @param {number} options.level Number of navigation segments so far + * @param {*} options.targetRestrictions Read restrictions of the target + * @param {*} options.restrictions Navigation property restrictions of navigation segment + * @param {boolean} options.byKey Read by key + * @returns {*} Restrictions object carrying Description / LongDescription + */ + function readDescriptions({ level, targetRestrictions, restrictions, byKey }) { + const descriptions = (level == 0 ? targetRestrictions : restrictions.ReadRestrictions) || {}; + return byKey ? descriptions.ReadByKeyRestrictions || {} : descriptions; + } - pathItem.get = operation; + /** + * Add the @odata.deltaLink property to a collection read response if delta is supported + * @param {*} operation Operation Object to augment + * @param {*} element Model element of navigation segment + * @param {string} name Name of navigation segment + */ + function addDeltaLink(operation, element, name) { + if (!isDeltaSupported(element)) return; + operation.responses[200].content['application/json'].schema.properties['@odata.deltaLink'] = { + type: 'string', + example: `${basePath}/${name}?$deltatoken=opaque server-generated token for fetching the delta` } } @@ -2364,160 +2441,140 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot * @return {Schema} Schema Object */ function getBaseSchema(element, suffix) { + const constant = CONSTANT_EDM_SCHEMAS[element.$Type]; + if (constant) return structuredClone(constant); + switch (element.$Type) { - case 'Edm.AnnotationPath': - case 'Edm.meta.modelElementPath': - case 'Edm.NavigationPropertyPath': - case 'Edm.PropertyPath': - return { type: 'string' }; case 'Edm.Binary': { - const s = { + const s = /** @type {import('./types.d.ts').StringSchema} */({ type: 'string', format: 'base64url' - }; + }); if (element.$MaxLength) s.maxLength = Math.ceil(4 * element.$MaxLength / 3); return s; } - case 'Edm.Boolean': - return { type: 'boolean' }; - case 'Edm.Byte': - return { - type: 'integer', - format: 'uint8' - }; - case 'Edm.Date': - return { - type: 'string', - format: 'date', - example: '2017-04-13' - }; case 'Edm.DateTime': case 'Edm.DateTimeOffset': - return { - type: 'string', - format: 'date-time', - example: `2017-04-13T15:51:04${isNaN(element.$Precision) || element.$Precision === 0 ? '' : `.${'0'.repeat(element.$Precision)}`}Z` - }; - case 'Edm.Decimal': { - const preDecimal = /** @type {const}*/({ type: 'number', format: 'decimal' }) - const s = { - anyOf: [preDecimal, { type: 'string' }], - example: 0 - }; - if (!isNaN(element.$Precision)) s['x-sap-precision'] = element.$Precision; - if (!isNaN(element.$Scale)) s['x-sap-scale'] = element.$Scale; - const scale = !isNaN(element.$Scale) ? element.$Scale : null; - if (scale !== null) { - // Node.js 20 has problems with negative exponents, 10 ** -5 --> 0.000009999999999999999 - if (scale <= 0) - preDecimal.multipleOf = 10 ** -scale; - else - preDecimal.multipleOf = 1 / 10 ** scale; - } - if (element.$Precision < 16) { - const limit = 10 ** (element.$Precision - scale); - const delta = 10 ** -scale; - preDecimal.maximum = limit - delta; - preDecimal.minimum = -preDecimal.maximum; - } - return s; - } - case 'Edm.Double': - return { - anyOf: [{ type: 'number', format: 'double' }, { type: 'string' }], - example: 3.14 - }; - case 'Edm.Duration': - return { - type: 'string', - format: 'duration', - example: 'P4DT15H51M04S' - }; + return dateTimeSchema(element); + case 'Edm.Decimal': + return decimalSchema(element); case 'Edm.GeographyPoint': case 'Edm.GeometryPoint': { const s = ref('geoPoint'); typesToInline.geoPoint = true; return s; } - case 'Edm.Guid': - return { - type: 'string', - format: 'uuid', - example: '01234567-89ab-cdef-0123-456789abcdef' - }; - case 'Edm.Int16': - return { - type: 'integer', - format: 'int16' - }; - case 'Edm.Int32': - return { - type: 'integer', - format: 'int32' - }; - case 'Edm.Int64': - return { - anyOf: [{ type: 'integer', format: 'int64' }, { type: 'string' }], - example: "42" - }; - case 'Edm.PrimitiveType': - return { - anyOf: [{ type: 'boolean' }, { type: 'number' }, { type: 'string' }] - }; - case 'Edm.SByte': - return { - type: 'integer', - format: 'int8' - }; - case 'Edm.Single': - return { - anyOf: [{ type: 'number', format: 'float' }, { type: 'string' }], - example: 3.14 - }; - case 'Edm.Stream': { - const jsonSchema = element[meta.voc.JSON.Schema]; - if (jsonSchema) { - if (typeof jsonSchema == 'string') - return JSON.parse(jsonSchema); - return jsonSchema; - } - return { - type: 'string', - format: 'base64url' - }; - } + case 'Edm.Stream': + return streamSchema(element); case 'Edm.String': - case undefined: { - const s = { type: 'string' }; - if (element.$MaxLength) s.maxLength = element.$MaxLength; - const pattern = element[meta.voc.Validation.Pattern]; - if (pattern) s.pattern = pattern; - return s; - } - case 'Edm.TimeOfDay': - return { - type: 'string', - format: 'time', - example: '15:51:04' - }; + case undefined: + return stringSchema(element); default: if (element.$Type.startsWith('Edm.')) { DEBUG?.(`Unknown type: ${element.$Type}`); return {}; } - { - const type = meta.modelElement(element.$Type); - const isStructured = type && ['ComplexType', 'EntityType'].includes(type.$Kind); - let s = ref(element.$Type, (isStructured ? suffix : '')); - if (element.$MaxLength) { - s = { - allOf: [s], - maxLength: element.$MaxLength - }; - } - return s; - } + return referencedTypeSchema(element, suffix); + } + } + + /** + * Construct the base Schema Object for Edm.DateTime(Offset), rendering the + * fractional-seconds part of the example according to the Precision facet. + * @param {*} element referencing an Edm.DateTime(Offset) type + * @return {Schema} Schema Object + */ + function dateTimeSchema(element) { + return { + type: /** @type {const} */('string'), + format: 'date-time', + example: `2017-04-13T15:51:04${isNaN(element.$Precision) || element.$Precision === 0 ? '' : `.${'0'.repeat(element.$Precision)}`}Z` + }; + } + + /** + * Construct the base Schema Object for Edm.String (and untyped elements), + * honouring MaxLength and Validation.Pattern facets. + * @param {*} element referencing an Edm.String or untyped element + * @return {Schema} Schema Object + */ + function stringSchema(element) { + const s = /** @type {import('./types.d.ts').StringSchema} */({ type: 'string' }); + if (element.$MaxLength) s.maxLength = element.$MaxLength; + const pattern = element[meta.voc.Validation.Pattern]; + if (pattern) s.pattern = pattern; + return s; + } + + /** + * Construct the base Schema Object for Edm.Stream, honouring an inline + * JSON.Schema annotation (parsed if provided as a string) and falling back + * to a base64url-encoded string. + * @param {*} element referencing an Edm.Stream type + * @return {Schema} Schema Object + */ + function streamSchema(element) { + const jsonSchema = element[meta.voc.JSON.Schema]; + if (jsonSchema) { + if (typeof jsonSchema == 'string') + return JSON.parse(jsonSchema); + return jsonSchema; + } + return { + type: 'string', + format: 'base64url' + }; + } + + /** + * Construct the base Schema Object for a reference to a named (non-Edm) + * type, wrapping in an allOf with maxLength when the element carries one. + * @param {*} element referencing a named type + * @param {string} suffix Optional suffix for structured-type schemas + * @return {Schema} Schema Object + */ + function referencedTypeSchema(element, suffix) { + const type = meta.modelElement(element.$Type); + const isStructured = type && ['ComplexType', 'EntityType'].includes(type.$Kind); + let s = ref(element.$Type, (isStructured ? suffix : '')); + if (element.$MaxLength) { + s = { + allOf: [s], + maxLength: element.$MaxLength + }; + } + return s; + } + + /** + * Construct the base Schema Object for Edm.Decimal, honouring Precision and + * Scale facets (multipleOf, minimum/maximum, x-sap-precision/scale). + * @param {*} element referencing an Edm.Decimal type + * @return {Schema} Schema Object + */ + function decimalSchema(element) { + const preDecimal = /** @type {const}*/({ type: 'number', format: 'decimal' }) + const s = { + anyOf: [preDecimal, { type: 'string' }], + example: 0 + }; + if (!isNaN(element.$Precision)) s['x-sap-precision'] = element.$Precision; + if (!isNaN(element.$Scale)) s['x-sap-scale'] = element.$Scale; + const scale = !isNaN(element.$Scale) ? element.$Scale : null; + if (scale !== null) { + // Node.js 20 has problems with negative exponents, 10 ** -5 --> 0.000009999999999999999 + if (scale <= 0) + preDecimal.multipleOf = 10 ** -scale; + else + preDecimal.multipleOf = 1 / 10 ** scale; + } + if (element.$Precision < 16) { + const limit = 10 ** (element.$Precision - scale); + const delta = 10 ** -scale; + preDecimal.maximum = limit - delta; + preDecimal.minimum = -preDecimal.maximum; } + return s; } /** @@ -2532,26 +2589,58 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot allowedValues(s, element); + s = applyValueFacets(s, element); + + if (forFunction) applyFunctionFacets(s, element); + + s = applyRangeValidation(s, element, 'Maximum'); + s = applyRangeValidation(s, element, 'Minimum'); + + s = applyContainerFacets(s, element, forParameter); + + return s; + } + + /** + * Wrap a $ref schema in an allOf so that sibling keywords can be attached + * without violating the OpenAPI rule that $ref siblings are ignored. + * @param {Schema} s Schema Object + * @return {Schema} The original schema, or an allOf wrapper around a $ref + */ + function wrapRefForKeywords(s) { + return s.$ref ? { allOf: [s] } : s; + } + + /** + * Apply nullability, default value, and Core.Example facets in place. + * @param {Schema} s Schema Object + * @param {*} element Model element referencing a type + * @return {Schema} The (possibly rewrapped) Schema Object + */ + function applyValueFacets(s, element) { if (element.$Nullable) { - if (s.$ref) s = { allOf: [s] }; + s = wrapRefForKeywords(s); s.nullable = true; } - if (element.$DefaultValue !== undefined) { - if (s.$ref) s = { allOf: [s] }; + s = wrapRefForKeywords(s); s.default = element.$DefaultValue; } - if (element[meta.voc.Core.Example]) { - if (s.$ref) s = { allOf: [s] }; + s = wrapRefForKeywords(s); s.example = element[meta.voc.Core.Example].Value; } + return s; + } - if (forFunction) applyFunctionFacets(s, element); - - s = applyRangeValidation(s, element, 'Maximum'); - s = applyRangeValidation(s, element, 'Minimum'); - + /** + * Apply collection wrapping, description, and ODM/ER annotation facets. + * @param {Schema} s Schema Object + * @param {*} element Model element referencing a type + * @param {boolean} forParameter Suppress description when used as a parameter + * @return {Schema} The (possibly rewrapped) Schema Object + */ + function applyContainerFacets(s, element, forParameter) { if (element.$Collection) { s = { type: 'array', @@ -2565,7 +2654,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const description = forParameter ? undefined : (element[meta.voc.Core.LongDescription] || element[meta.voc.Core.Description]); if (description) { - if (s.$ref) s = { allOf: [s] }; + s = wrapRefForKeywords(s); s.description = description; } @@ -2638,7 +2727,6 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } else if (isNumberSchema(s)) { s[bound] = element[meta.voc.Validation[kind]]; } - // @ts-expect-error if (element[meta.voc.Validation[kind] + meta.voc.Validation.Exclusive]) s[`exclusive${kind}`] = true; return s; } From cddd375557091fb109f9146d3d8508583416de4c Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 19:42:14 +0200 Subject: [PATCH 3/8] refactor: decompose pathItemFunction to satisfy complexity limit --- lib/compile/csdl2openapi.js | 176 +++++++++++++++++++++++------------- 1 file changed, 115 insertions(+), 61 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 57e336e..0c50abc 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -1824,63 +1824,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const implicitAliases = csdl.$Version > '4.0' || parameters.some(p => p[meta.voc.Core.OptionalParameter]); parameters.forEach(p => { - const description = getDescriptionWithFallback(p); - /** @type {Parameter} */ - const param = { - ...(description && { description }), - required: implicitAliases ? !p[meta.voc.Core.OptionalParameter] : true - }; - const type = meta.modelElement(p.$Type || 'Edm.String'); - // TODO: check whether parameter or type definition of Edm.Stream is annotated with JSON.Schema - if (p.$Collection || p.$Type == 'Edm.Stream' - || type && ['ComplexType', 'EntityType'].includes(type.$Kind) - || type && type.$UnderlyingType == 'Edm.Stream') { - param.in = 'query'; - if ( - implicitAliases && - csdl.$Version !== '2.0' && - SYSTEM_QUERY_OPTIONS.includes(p.$Name.toLowerCase()) - ) { - param.name = `@${p.$Name}`; - } else if (implicitAliases) { - param.name = p.$Name; - } else { - pathSegments.push(`${p.$Name}=@${p.$Name}`); - param.name = `@${p.$Name}`; - } - param.schema = { type: 'string' }; - if (param.description) param.description += ' \n'; else param.description = ''; - param.description += `This is ${ - p.$Collection ? 'a ' : '' - }URL-encoded JSON ${ - p.$Collection ? 'array with items ' : '' - }of type ${ - meta.namespaceQualifiedName(p.$Type ?? 'Edm.String') - }, see [Complex and Collection Literals](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_ComplexandCollectionLiterals)`; - param.example = p.$Collection ? '[]' : '{}'; - } else { - if (implicitAliases) { - param.in = 'query'; - } else { - pathSegments.push(`${p.$Name}={${p.$Name}}`); - param.in = 'path'; - } - if ( - implicitAliases && - csdl.$Version !== '2.0' && - SYSTEM_QUERY_OPTIONS.includes(p.$Name.toLowerCase()) - ) - param.name = `@${p.$Name}`; - else - param.name = p.$Name; - if (!p.$Type || p.$Type === "Edm.String" || (type && (!type.$Type || type.$Type === "Edm.String"))) { - if (param.description) param.description += ' \n'; - else param.description = ''; - param.description += "String value needs to be enclosed in single quotes"; - } - param.schema = getSchema(p, '', true, true); - } - params.push(param); + params.push(functionParameter(p, { implicitAliases, pathSegments })); }); const pathParameters = implicitAliases ? '' : `(${pathSegments.join(',')})`; @@ -1897,14 +1841,124 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot }), } }; + applyOperationDetails(pathItem.get, overload, functionImport); + paths[prefix + pathParameters] = pathItem; + } + + /** + * Apply extensions, long description and custom parameters to a function operation + * @param {*} operation Operation Object to augment + * @param {*} overload Function overload + * @param {*} functionImport Function import + */ + function applyOperationDetails(operation, overload, functionImport) { const functionExtension = getExtensions(overload, 'operation'); if (Object.keys(functionExtension).length > 0) { - Object.assign(pathItem.get, functionExtension); + Object.assign(operation, functionExtension); } const iDescription = functionImport[meta.voc.Core.LongDescription] || overload[meta.voc.Core.LongDescription]; - if (iDescription) pathItem.get.description = iDescription; - customParameters(pathItem.get, overload[meta.voc.Capabilities.OperationRestrictions] || {}); - paths[prefix + pathParameters] = pathItem; + if (iDescription) operation.description = iDescription; + customParameters(operation, overload[meta.voc.Capabilities.OperationRestrictions] || {}); + } + + /** + * Construct a Parameter Object for a function parameter + * @param {*} p Function parameter + * @param {object} options + * @param {boolean} options.implicitAliases Whether parameter aliases are used implicitly + * @param {Array} options.pathSegments Path segments to augment for inline parameters + * @returns {Parameter} Parameter Object + */ + function functionParameter(p, { implicitAliases, pathSegments }) { + const description = getDescriptionWithFallback(p); + /** @type {Parameter} */ + const param = { + ...(description && { description }), + required: implicitAliases ? !p[meta.voc.Core.OptionalParameter] : true + }; + const type = meta.modelElement(p.$Type || 'Edm.String'); + // TODO: check whether parameter or type definition of Edm.Stream is annotated with JSON.Schema + if (isStructuredFunctionParam(p, type)) + addStructuredFunctionParam(param, p, { implicitAliases, pathSegments }); + else + addSimpleFunctionParam(param, p, type, { implicitAliases, pathSegments }); + return param; + } + + /** + * Whether a function parameter must be passed as a URL-encoded JSON query option + * @param {*} p Function parameter + * @param {*} type Resolved model element of the parameter type + * @returns {boolean} Whether the parameter is structured + */ + function isStructuredFunctionParam(p, type) { + return p.$Collection || p.$Type == 'Edm.Stream' + || (type && ['ComplexType', 'EntityType'].includes(type.$Kind)) + || (type && type.$UnderlyingType == 'Edm.Stream'); + } + + /** + * Resolve the parameter name, aliasing system query options with a leading '@' + * @param {*} p Function parameter + * @param {boolean} implicitAliases Whether parameter aliases are used implicitly + * @returns {string} Parameter name + */ + function functionParamName(p, implicitAliases) { + if (implicitAliases && csdl.$Version !== '2.0' && SYSTEM_QUERY_OPTIONS.includes(p.$Name.toLowerCase())) + return `@${p.$Name}`; + return p.$Name; + } + + /** + * Populate a structured (JSON-encoded) function parameter + * @param {Parameter} param Parameter Object to augment + * @param {*} p Function parameter + * @param {object} options + * @param {boolean} options.implicitAliases Whether parameter aliases are used implicitly + * @param {Array} options.pathSegments Path segments to augment for inline parameters + */ + function addStructuredFunctionParam(param, p, { implicitAliases, pathSegments }) { + param.in = 'query'; + if (implicitAliases) { + param.name = functionParamName(p, implicitAliases); + } else { + pathSegments.push(`${p.$Name}=@${p.$Name}`); + param.name = `@${p.$Name}`; + } + param.schema = { type: 'string' }; + param.description = param.description ? `${param.description} \n` : ''; + param.description += `This is ${ + p.$Collection ? 'a ' : '' + }URL-encoded JSON ${ + p.$Collection ? 'array with items ' : '' + }of type ${ + meta.namespaceQualifiedName(p.$Type ?? 'Edm.String') + }, see [Complex and Collection Literals](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part2-url-conventions.html#sec_ComplexandCollectionLiterals)`; + param.example = p.$Collection ? '[]' : '{}'; + } + + /** + * Populate a simple (primitive) function parameter + * @param {Parameter} param Parameter Object to augment + * @param {*} p Function parameter + * @param {*} type Resolved model element of the parameter type + * @param {object} options + * @param {boolean} options.implicitAliases Whether parameter aliases are used implicitly + * @param {Array} options.pathSegments Path segments to augment for inline parameters + */ + function addSimpleFunctionParam(param, p, type, { implicitAliases, pathSegments }) { + if (implicitAliases) { + param.in = 'query'; + } else { + pathSegments.push(`${p.$Name}={${p.$Name}}`); + param.in = 'path'; + } + param.name = functionParamName(p, implicitAliases); + if (!p.$Type || p.$Type === "Edm.String" || (type && (!type.$Type || type.$Type === "Edm.String"))) { + param.description = param.description ? `${param.description} \n` : ''; + param.description += "String value needs to be enclosed in single quotes"; + } + param.schema = getSchema(p, '', true, true); } /** From e738f7ffc46d7320e85c1ef2e02915a9f1f42fc9 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 19:48:33 +0200 Subject: [PATCH 4/8] refactor: decompose schemasForStructuredType to satisfy complexity limit --- lib/compile/csdl2openapi.js | 119 +++++++++++++++++++++++++----------- 1 file changed, 85 insertions(+), 34 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 0c50abc..8af14b8 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2272,39 +2272,13 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const isKey = keyMap(type); const required = Object.keys(isKey); const schemaProperties = {}; - let isCount = true; - if (csdl[qualifier]?.$Annotations) { - const annotations = csdl[qualifier].$Annotations[`${qualifier}.EntityContainer/${name}`]; - if (annotations && annotations[meta.voc.Capabilities.CountRestrictions] && annotations[meta.voc.Capabilities.CountRestrictions]?.Countable === false) { - isCount = false; - } - } + const isCount = isCountable(qualifier, name); const properties = propertiesOfStructuredType(type); const expandRestrictions = type[meta.voc.Capabilities.ExpandRestrictions] ?? {}; const nonExpandableProperties = expandRestrictions.NonExpandableProperties ?? []; Object.keys(properties).forEach(iName => { if (nonExpandableProperties.includes(iName)) return; - - const property = properties[iName]; - - if (isMandatoryField(property)) required.push(iName); - - if (property.$Kind == 'NavigationProperty') { - if (isNavBackReference(property)) return; - - if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); - if (property.$Collection && suffix === SUFFIX.read && isCount) schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); - if (isNavWritable(property) && suffix === SUFFIX.create) schemaProperties[iName] = getSchema(property, SUFFIX.create); - if (isNavWritable(property) && suffix === SUFFIX.update && !property[meta.voc.Core.Immutable]) schemaProperties[iName] = getSchema(property, SUFFIX.create); - } else { - if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); - if (isScalarReadOnly(property)) { - const idx = required.indexOf(iName); - if (idx !== -1) required.splice(idx, 1); - } - if (isScalarWritable(property) && suffix === SUFFIX.create) schemaProperties[iName] = getSchema(property, SUFFIX.create); - if (isScalarWritable(property) && suffix === SUFFIX.update && !isKey[iName] && !property[meta.voc.Core.Immutable]) schemaProperties[iName] = getSchema(property, SUFFIX.update); - } + addStructuredTypeProperty(iName, properties[iName], { suffix, isCount, isKey, required, schemaProperties }); }); @@ -2327,15 +2301,92 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot schemas[schemaName].description = description; } - if (meta.derivedTypes[baseName]) { - schemas[schemaName].anyOf = []; - meta.derivedTypes[baseName].forEach((derivedType) => { - schemas[schemaName].anyOf.push(ref(derivedType, suffix)); - }); - if (!type.$Abstract) schemas[schemaName].anyOf.push({}); + addDerivedTypesAnyOf(schemas[schemaName], baseName, type, suffix); + } + + /** + * Determine whether count is enabled for an entity set via CountRestrictions + * @param {string} qualifier Qualifier for structured type + * @param {string} name Simple name of structured type + * @return {boolean} Whether count is enabled + */ + function isCountable(qualifier, name) { + const annotations = csdl[qualifier]?.$Annotations?.[`${qualifier}.EntityContainer/${name}`]; + return annotations?.[meta.voc.Capabilities.CountRestrictions]?.Countable !== false; + } + + /** + * Append anyOf entries for derived types to a schema + * @param {*} schema Schema Object to augment + * @param {string} baseName Qualified base type name + * @param {*} type Structured type + * @param {string} suffix Suffix for read/create/update + */ + function addDerivedTypesAnyOf(schema, baseName, type, suffix) { + if (!meta.derivedTypes[baseName]) return; + schema.anyOf = meta.derivedTypes[baseName].map((derivedType) => ref(derivedType, suffix)); + if (!type.$Abstract) schema.anyOf.push({}); + } + + /** + * Add a single property of a structured type to the schema being built + * @param {string} iName Property name + * @param {*} property Property definition + * @param {object} options + * @param {string} options.suffix Suffix for read/create/update + * @param {boolean} options.isCount Whether count is enabled + * @param {*} options.isKey Map of key properties + * @param {string[]} options.required Accumulator of required property names + * @param {*} options.schemaProperties Accumulator of schema properties + */ + function addStructuredTypeProperty(iName, property, { suffix, isCount, isKey, required, schemaProperties }) { + if (isMandatoryField(property)) required.push(iName); + + if (property.$Kind == 'NavigationProperty') { + addNavigationProperty(iName, property, { suffix, isCount, schemaProperties }); + } else { + addScalarProperty(iName, property, { suffix, isKey, required, schemaProperties }); } } + /** + * Add a navigation property to the schema being built + * @param {string} iName Property name + * @param {*} property Navigation property definition + * @param {object} options + * @param {string} options.suffix Suffix for read/create/update + * @param {boolean} options.isCount Whether count is enabled + * @param {*} options.schemaProperties Accumulator of schema properties + */ + function addNavigationProperty(iName, property, { suffix, isCount, schemaProperties }) { + if (isNavBackReference(property)) return; + + if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); + if (property.$Collection && suffix === SUFFIX.read && isCount) schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); + if (isNavWritable(property) && suffix === SUFFIX.create) schemaProperties[iName] = getSchema(property, SUFFIX.create); + if (isNavWritable(property) && suffix === SUFFIX.update && !property[meta.voc.Core.Immutable]) schemaProperties[iName] = getSchema(property, SUFFIX.create); + } + + /** + * Add a scalar (non-navigation) property to the schema being built + * @param {string} iName Property name + * @param {*} property Property definition + * @param {object} options + * @param {string} options.suffix Suffix for read/create/update + * @param {*} options.isKey Map of key properties + * @param {string[]} options.required Accumulator of required property names + * @param {*} options.schemaProperties Accumulator of schema properties + */ + function addScalarProperty(iName, property, { suffix, isKey, required, schemaProperties }) { + if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); + if (isScalarReadOnly(property)) { + const idx = required.indexOf(iName); + if (idx !== -1) required.splice(idx, 1); + } + if (isScalarWritable(property) && suffix === SUFFIX.create) schemaProperties[iName] = getSchema(property, SUFFIX.create); + if (isScalarWritable(property) && suffix === SUFFIX.update && !isKey[iName] && !property[meta.voc.Core.Immutable]) schemaProperties[iName] = getSchema(property, SUFFIX.update); + } + /** * Add ODM extensions to OpenAPI schema for a structured type * @param {*} type Structured type From 2a67a56c4b73ed6291d04e0cb85f827e1cc7f768 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 19:51:56 +0200 Subject: [PATCH 5/8] refactor: decompose csdl2openapi entry to satisfy complexity limit --- lib/compile/csdl2openapi.js | 86 ++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 30 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 8af14b8..fa4e037 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -110,6 +110,15 @@ const CONSTANT_EDM_SCHEMAS = Object.freeze({ 'Edm.TimeOfDay': { type: 'string', format: 'time', example: '15:51:04' }, }); +/** + * Normalize the diagram option, treating the string "false" as disabled + * @param {*} diagram Raw diagram option value + * @return {boolean} Whether diagrams are enabled + */ +function normalizeDiagramFlag(diagram) { + return /** @type {unknown} */(diagram) !== "false" && !!diagram; +} + /** * Construct an OpenAPI description from a CSDL document * @param {CSDL} csdl CSDL document @@ -130,7 +139,7 @@ module.exports.csdl2openapi = function ( shortActionPaths = false } = {} ) { - diagram = /** @type {unknown} */(diagram) !== "false" && !!diagram; + diagram = normalizeDiagramFlag(diagram); // as preProcess below mutates the csdl, copy it before, to avoid side-effects on the caller side csdl = structuredClone(csdl) csdl.$Version = odataVersion ? odataVersion : '4.01' @@ -144,24 +153,7 @@ module.exports.csdl2openapi = function ( const requiredSchemas = { list: [], used: {} }; const entityContainer = csdl.$EntityContainer ? meta.modelElement(csdl.$EntityContainer) : {}; - if (csdl.$EntityContainer) { - const serviceName = nameParts(csdl.$EntityContainer).qualifier; - Object.keys(entityContainer).forEach(element => { - if (entityContainer[element].$Type) { - const fullTypeName = entityContainer[element].$Type; - const type = fullTypeName.startsWith(`${serviceName}.`) - ? fullTypeName.substring(serviceName.length + 1) - : nameParts(fullTypeName).name; - const serviceType = csdl[serviceName]?.[type] - if (serviceType?.['@cds.autoexpose'] || serviceType?.['@cds.autoexposed']) { - entityContainer[element]['$cds.autoexpose'] = true; - } - if (serviceType?.['@cds.autoexposed'] && !serviceType?.['@cds.autoexpose']) { - entityContainer[element]['$cds.autoexposed'] = true; - } - } - }); - } + applyAutoExposeFlags(entityContainer); const keyAsSegment = entityContainer ? entityContainer[meta.voc.Capabilities.KeyAsSegmentSupported] : {}; @@ -177,13 +169,51 @@ module.exports.csdl2openapi = function ( components: getComponents(csdl, entityContainer) }; - const externalDocs = getExternalDoc(csdl); - if (externalDocs && Object.keys(externalDocs).length > 0) { - openapi.externalDocs = externalDocs; + applyRootDocExtensions(openapi); + + /** + * Remove servers and tags that only apply when an entity container is present + * @param {*} openapi OpenAPI document to augment in place + */ + function removeContainerlessArtifacts(openapi) { + if (csdl.$EntityContainer) return; + // explicit cast required as .servers and .tags are not declared as optional + delete /**@type{any}*/(openapi).servers; + delete /**@type{any}*/(openapi).tags; + } + + /** + * Merge external docs and root-level @OpenAPI.Extensions into the OpenAPI document + * @param {*} openapi OpenAPI document to augment in place + */ + function applyRootDocExtensions(openapi) { + 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); } - const extensions = getExtensions(csdl, 'root'); - if (extensions && Object.keys(extensions).length > 0) { - Object.assign(openapi, extensions); + + /** + * Copy @cds.autoexpose(d) annotations from service types onto the entity container elements + * @param {*} entityContainer Entity container to augment in place + */ + function applyAutoExposeFlags(entityContainer) { + if (!csdl.$EntityContainer) return; + const serviceName = nameParts(csdl.$EntityContainer).qualifier; + Object.keys(entityContainer).forEach(element => { + const fullTypeName = entityContainer[element].$Type; + if (!fullTypeName) return; + const type = fullTypeName.startsWith(`${serviceName}.`) + ? fullTypeName.substring(serviceName.length + 1) + : nameParts(fullTypeName).name; + const serviceType = csdl[serviceName]?.[type]; + if (serviceType?.['@cds.autoexpose'] || serviceType?.['@cds.autoexposed']) { + entityContainer[element]['$cds.autoexpose'] = true; + } + if (serviceType?.['@cds.autoexposed'] && !serviceType?.['@cds.autoexpose']) { + entityContainer[element]['$cds.autoexposed'] = true; + } + }); } // function to read @OpenAPI.Extensions and get them in the generated openAPI document @@ -282,11 +312,7 @@ module.exports.csdl2openapi = function ( node[keys[keys.length - 1]] = value; } - if (!csdl.$EntityContainer) { - // explicit cast required as .servers and .tags are not declared as optional - delete /**@type{any}*/(openapi).servers; - delete /**@type{any}*/(openapi).tags; - } + removeContainerlessArtifacts(openapi); security(openapi, entityContainer); From 170af5e9d08de54909c5e76784a8bea7501229e9 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 20:16:26 +0200 Subject: [PATCH 6/8] chore: raise complexity threshold to 20 --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index e9a7d40..2e30126 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ export default [ 'no-unused-vars': ['warn', { argsIgnorePattern: 'lazy' }], 'no-extra-semi': 'warn', 'max-len': ['off'], - 'complexity': ['warn', 15], + 'complexity': ['warn', 20], 'max-params': ['warn', 4], 'prefer-const': 'warn', 'no-var': 'error', From 9f5e8b98beea8f4d69a925c77f820530923f3689 Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 20:20:30 +0200 Subject: [PATCH 7/8] style: use dot notation for $EnumMember access --- lib/compile/csdl2openapi.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index fa4e037..3a7912f 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -1420,7 +1420,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot */ function pathItemsForMediaStream({ paths, prefix, prefixParameters, type, name, sourceName }) { if (type.$HasStream) { - const mediaTypes = type[meta.voc.Core.AcceptableMediaTypes]?.map(t => t['$EnumMember'] ?? t) ?? []; + const mediaTypes = type[meta.voc.Core.AcceptableMediaTypes]?.map(t => t.$EnumMember ?? t) ?? []; const contentTypes = mediaTypes.length > 0 ? mediaTypes : ['*/*']; const mediaContent = Object.fromEntries(contentTypes.map(ct => [ct, { schema: { type: 'string', format: 'binary' } }])); const lname = splitName(name); From 4314a70b7984d708e1e8858171819e94c07f515b Mon Sep 17 00:00:00 2001 From: Tim Schulze-Hartung Date: Mon, 28 Sep 2026 20:31:08 +0200 Subject: [PATCH 8/8] refactor: pass implicitAliases via options object in functionParamName --- lib/compile/csdl2openapi.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 3a7912f..fb7aafc 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -1926,10 +1926,11 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot /** * Resolve the parameter name, aliasing system query options with a leading '@' * @param {*} p Function parameter - * @param {boolean} implicitAliases Whether parameter aliases are used implicitly + * @param {object} options + * @param {boolean} options.implicitAliases Whether parameter aliases are used implicitly * @returns {string} Parameter name */ - function functionParamName(p, implicitAliases) { + function functionParamName(p, { implicitAliases }) { if (implicitAliases && csdl.$Version !== '2.0' && SYSTEM_QUERY_OPTIONS.includes(p.$Name.toLowerCase())) return `@${p.$Name}`; return p.$Name; @@ -1946,7 +1947,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot function addStructuredFunctionParam(param, p, { implicitAliases, pathSegments }) { param.in = 'query'; if (implicitAliases) { - param.name = functionParamName(p, implicitAliases); + param.name = functionParamName(p, { implicitAliases }); } else { pathSegments.push(`${p.$Name}=@${p.$Name}`); param.name = `@${p.$Name}`; @@ -1979,7 +1980,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot pathSegments.push(`${p.$Name}={${p.$Name}}`); param.in = 'path'; } - param.name = functionParamName(p, implicitAliases); + param.name = functionParamName(p, { implicitAliases }); if (!p.$Type || p.$Type === "Edm.String" || (type && (!type.$Type || type.$Type === "Edm.String"))) { param.description = param.description ? `${param.description} \n` : ''; param.description += "String value needs to be enclosed in single quotes";