diff --git a/lib/compile/csdl.d.ts b/lib/compile/csdl.d.ts new file mode 100644 index 0000000..e29ee27 --- /dev/null +++ b/lib/compile/csdl.d.ts @@ -0,0 +1,556 @@ +// generated using https://app.quicktype.io/ +// and https://raw.githubusercontent.com/oasis-tcs/odata-csdl-schemas/refs/heads/main/schemas/csdl.schema.json +// modifcations: +// - CSDLProperties.$Version changed to string, as that is how we use it +// - CSDLProperties.$EntityContrainer changed to string + +export type CSDL = { + readonly $schema: string; + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: CSDLPatternProperties; + readonly propertyNames: PropertyNames; + readonly properties: CSDLProperties; + readonly required: string[]; + readonly definitions: Definitions; +} + +export type Definitions = { + readonly Schema: Schema; + readonly EntityType: EntityType; + readonly ComplexType: ComplexType; + readonly Property: Property; + readonly NavigationProperty: NavigationProperty; + readonly EnumType: EnumType; + readonly TypeDefinition: TypeDefinition; + readonly Term: Term; + readonly Action: Ction; + readonly Function: Ction; + readonly EntityContainer: EntityContainer; + readonly EntitySet: EntitySet; + readonly Singleton: Singleton; + readonly ActionImport: ActionImport; + readonly FunctionImport: FunctionImport; + readonly NavigationPropertyBinding: NavigationPropertyBinding; + readonly Parameter: Parameter; + readonly ReturnType: ReturnType; + readonly MaxLength: MaxLength; + readonly Unicode: Unicode; + readonly Precision: MaxLength; + readonly Scale: Scale; + readonly SimpleIdentifier: QualifiedName; + readonly QualifiedName: QualifiedName; + readonly SRID: Srid; + readonly Annotation: Annotation; +} + +export type Ction = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: ActionProperties; + readonly required: string[]; +} + +export type ActionPatternProperties = { + readonly "^@": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$ = { + readonly $ref: string; +} + +export type ActionProperties = { + readonly $Kind: Version; + readonly $IsBound: Unicode; + readonly $EntitySetPath: Srid; + readonly $Parameter: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $ReturnType: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $IsComposable?: Unicode; +} + +export type Srid = { + readonly description: string; + readonly type: SRIDType; +} + +export enum SRIDType { + Integer = "integer", + String = "string", +} + +export type Unicode = { + readonly description: string; + readonly type: UnicodeType; + readonly default: boolean; + readonly examples: boolean[]; +} + +export enum UnicodeType { + Boolean = "boolean", +} + +export type Version = { + readonly description: string; + readonly enum: string[]; +} + +export type ActionImport = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: ActionImportProperties; + readonly required: string[]; +} + +export type ActionImportProperties = { + readonly $Action: Srid; + readonly $EntitySet: Srid; +} + +export type Annotation = { + readonly description: string; +} + +export type ComplexType = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ComplexTypePatternProperties; + readonly properties: ComplexTypeProperties; + readonly required: string[]; +} + +export type ComplexTypePatternProperties = { + readonly "^@": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly "^(_|\\p{L}|\\p{Nl})(_|\\p{L}|\\p{Nl}|\\p{Nd}|\\p{Mn}|\\p{Mc}|\\p{Pc}|\\p{Cf}){0,127}$": PurplePLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type PurplePLPNlPLPNlPNdPMnPMcPPCPCF0127$ = { + readonly oneOf: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$[]; +} + +export type ComplexTypeProperties = { + readonly $Kind: Version; + readonly $Abstract: Unicode; + readonly $OpenType: Unicode; + readonly $BaseType: Srid; +} + +export type EntityContainer = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ComplexTypePatternProperties; + readonly properties: EntityContainerProperties; + readonly required: string[]; +} + +export type EntityContainerProperties = { + readonly $Kind: Version; + readonly $Extends: Srid; +} + +export type EntitySet = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: EntitySetProperties; + readonly required: string[]; +} + +export type EntitySetProperties = { + readonly $Collection: Collection; + readonly $Type: Class; + readonly $NavigationPropertyBinding: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $IncludeInServiceDocument: Unicode; +} + +export type Collection = { + readonly description: string; + readonly enum: boolean[]; +} + +export type Class = { + readonly description: string; + readonly $ref: Ref; +} + +export enum Ref { + DefinitionsAnnotation = "#/definitions/Annotation", + DefinitionsQualifiedName = "#/definitions/QualifiedName", + DefinitionsSimpleIdentifier = "#/definitions/SimpleIdentifier", +} + +export type EntityType = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ComplexTypePatternProperties; + readonly properties: EntityTypeProperties; + readonly required: string[]; +} + +export type EntityTypeProperties = { + readonly $Kind: Version; + readonly $HasStream: Unicode; + readonly $Key: Key; + readonly $Abstract: Unicode; + readonly $OpenType: Unicode; + readonly $BaseType: Srid; +} + +export type Key = { + readonly description: string; + readonly type: string; + readonly items: KeyItems; +} + +export type KeyItems = { + readonly description: string; + readonly oneOf: ItemsOneOf[]; +} + +export type ItemsOneOf = { + readonly type: string; + readonly patternProperties?: OneOfPatternProperties; +} + +export type OneOfPatternProperties = { + readonly ".*": Srid; +} + +export type EnumType = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: EnumTypePatternProperties; + readonly properties: EnumTypeProperties; + readonly required: string[]; +} + +export type EnumTypePatternProperties = { + readonly "@": Class; + readonly "^(_|\\p{L}|\\p{Nl})(_|\\p{L}|\\p{Nl}|\\p{Nd}|\\p{Mn}|\\p{Mc}|\\p{Pc}|\\p{Cf}){0,127}$": Srid; +} + +export type EnumTypeProperties = { + readonly $Kind: Version; + readonly $IsFlags: Unicode; + readonly $UnderlyingType: UnderlyingType; +} + +export type UnderlyingType = { + readonly description: string; + readonly enum: string[]; + readonly default: string; +} + +export type FunctionImport = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: FunctionImportProperties; + readonly required: string[]; +} + +export type FunctionImportProperties = { + readonly $Function: Srid; + readonly $EntitySet: Srid; + readonly $IncludeInServiceDocument: Unicode; +} + +export type MaxLength = { + readonly description: string; + readonly type: SRIDType; + readonly minimum: number; +} + +export type NavigationProperty = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: NavigationPropertyPatternProperties; + readonly properties: NavigationPropertyProperties; + readonly required: string[]; +} + +export type NavigationPropertyPatternProperties = { + readonly "^@": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly "^\\$OnDelete@": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type NavigationPropertyProperties = { + readonly $Kind: Version; + readonly $Type: Class; + readonly $Collection: Unicode; + readonly $Nullable: Unicode; + readonly $Partner: Srid; + readonly $ContainsTarget: Unicode; + readonly $ReferentialConstraint: ReferentialConstraint; + readonly $OnDelete: Version; +} + +export type ReferentialConstraint = { + readonly type: string; + readonly patternProperties: ReferentialConstraintPatternProperties; +} + +export type ReferentialConstraintPatternProperties = { + readonly "@": Class; + readonly ".*": Srid; +} + +export type NavigationPropertyBinding = { + readonly description: string; + readonly type: string; + readonly patternProperties: OneOfPatternProperties; +} + +export type Parameter = { + readonly description: string; + readonly type: string; + readonly items: ParameterItems; +} + +export type ParameterItems = { + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: PropertyProperties; + readonly required: string[]; +} + +export type PropertyProperties = { + readonly $Name?: Class; + readonly $Type: Type; + readonly $Collection: Unicode; + readonly $Nullable: Unicode; + readonly $MaxLength: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Unicode: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Precision: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Scale: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $SRID: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Kind?: Version; + readonly $DefaultValue?: Annotation; + readonly $BaseTerm?: Srid; + readonly $AppliesTo?: AppliesTo; +} + +export type AppliesTo = { + readonly description: string; + readonly type: string; + readonly items: OneOf; +} + +export type OneOf = { + readonly type?: SRIDType; + readonly enum?: string[]; +} + +export type Type = { + readonly description: string; + readonly default: string; + readonly $ref: Ref; +} + +export type Property = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: PropertyProperties; +} + +export type QualifiedName = { + readonly description: string; + readonly type: SRIDType; + readonly pattern: string; +} + +export type ReturnType = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties?: ReturnTypeProperties; +} + +export type ReturnTypeProperties = { + readonly $Type?: Type; + readonly $Collection?: Unicode; + readonly $Nullable?: Unicode; + readonly $MaxLength?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Unicode?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Precision?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Scale?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $SRID?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Include?: Include; + readonly $IncludeAnnotations?: IncludeAnnotations; +} + +export type Include = { + readonly description: string; + readonly type: string; + readonly items: IncludeItems; +} + +export type IncludeItems = { + readonly type: string; + readonly additionalProperties: boolean; + readonly properties: PurpleProperties; + readonly patternProperties: ActionPatternProperties; + readonly required: string[]; +} + +export type PurpleProperties = { + readonly $Namespace: Class; + readonly $Alias: Class; +} + +export type IncludeAnnotations = { + readonly description: string; + readonly type: string; + readonly items: IncludeAnnotationsItems; +} + +export type IncludeAnnotationsItems = { + readonly type: string; + readonly additionalProperties: boolean; + readonly properties: FluffyProperties; + readonly required: string[]; +} + +export type FluffyProperties = { + readonly $TermNamespace: Class; + readonly $TargetNamespace: Class; + readonly $Qualifier: Class; +} + +export type Scale = { + readonly description: string; + readonly oneOf: OneOf[]; +} + +export type Schema = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: SchemaPatternProperties; + readonly properties: SchemaProperties; +} + +export type SchemaPatternProperties = { + readonly "^@": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly "^(_|\\p{L}|\\p{Nl})(_|\\p{L}|\\p{Nl}|\\p{Nd}|\\p{Mn}|\\p{Mc}|\\p{Pc}|\\p{Cf}){0,127}$": FluffyPLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type FluffyPLPNlPLPNlPNdPMnPMcPPCPCF0127$ = { + readonly oneOf: PLPNlPLPNlPNdPMnPMcPPCPCF0127$_OneOf[]; +} + +export type PLPNlPLPNlPNdPMnPMcPPCPCF0127$_OneOf = { + readonly $ref?: string; + readonly description?: string; + readonly type?: string; + readonly items?: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type SchemaProperties = { + readonly $Alias: Class; + readonly $Annotations: Annotations; +} + +export type Annotations = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: AnnotationsPatternProperties; +} + +export type AnnotationsPatternProperties = { + readonly "^[^$]": ReturnType; +} + +export type Singleton = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: SingletonProperties; + readonly required: string[]; +} + +export type SingletonProperties = { + readonly $Type: Class; + readonly $Nullable: Unicode; + readonly $NavigationPropertyBinding: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type Term = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: PropertyProperties; + readonly required: string[]; +} + +export type TypeDefinition = { + readonly description: string; + readonly type: string; + readonly additionalProperties: boolean; + readonly patternProperties: ActionPatternProperties; + readonly properties: TypeDefinitionProperties; + readonly required: string[]; +} + +export type TypeDefinitionProperties = { + readonly $Kind: Version; + readonly $UnderlyingType: Srid; + readonly $MaxLength: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Unicode: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Precision: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $Scale: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; + readonly $SRID: PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type CSDLPatternProperties = { + readonly "^(_|\\p{L}|\\p{Nl})(_|\\p{L}|\\p{Nl}|\\p{Nd}|\\p{Mn}|\\p{Mc}|\\p{Pc}|\\p{Cf}){0,127}(\\.(_|\\p{L}|\\p{Nl})(_|\\p{L}|\\p{Nl}|\\p{Nd}|\\p{Mn}|\\p{Mc}|\\p{Pc}|\\p{Cf}){0,127})*$": PLPNlPLPNlPNdPMnPMcPPCPCF0127__PLPNlPLPNlPNdPMnPMcPPCPCF0127$; +} + +export type CSDLProperties = { + $Version: string; + readonly $EntityContainer: string; + readonly $Reference: Reference; +} + +export type Reference = { + readonly description: string; + readonly type: string; + readonly patternProperties: ReferencePatternProperties; +} + +export type ReferencePatternProperties = { + readonly ".*": ReturnType; +} + +export type PropertyNames = { + readonly maxLength: number; +} + +// Converts JSON strings to/from your types +export class Convert { + public static toCSDL(json: string): CSDL { + return JSON.parse(json); + } + + public static CSDLToJson(value: CSDL): string { + return JSON.stringify(value); + } +} diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 1b4b622..547b4d7 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -5,6 +5,11 @@ const cds = require('@sap/cds'); var pluralize = require('pluralize') const DEBUG = cds.debug('openapi'); // Initialize cds.debug with the 'openapi' +// Import preprocessing utilities +const { preProcess } = require('./preprocessing'); +// Import OpenAPI info and extensions utilities +const { getInfo, getExternalDoc, getShortText, getExtensions } = require('./openapi-info'); +const { namespaceQualifiedName, enumMember, isIdentifier, nameParts, modelElement, propertiesOfStructuredType, propertyPath, navigationPropertyPath } = require('./model-navigation') //TODO // - Core.Example for complex types @@ -75,7 +80,7 @@ const ER_ANNOTATIONS = Object.freeze( /** * Construct an OpenAPI description from a CSDL document - * @param {object} csdl CSDL document + * @param {import('./types').CSDL} csdl CSDL document * @param {object} options Optional parameters * @return {object} OpenAPI description */ @@ -103,13 +108,15 @@ module.exports.csdl2openapi = function ( const alias = {}; const namespace = { 'Edm': 'Edm' }; const namespaceUrl = {}; + /** @type {import('./preprocessing').Vocabulary} */ + // @ts-expect-error - voc is modified by reference to match the type const voc = {}; /** @type {{ list: { namespace:string, name: string, suffix: string }[], used: Record }} */ const requiredSchemas = { list: [], used: {} }; preProcess(csdl, boundOverloads, derivedTypes, alias, namespace, namespaceUrl, voc); - const entityContainer = csdl.$EntityContainer ? modelElement(csdl.$EntityContainer) : {}; + const entityContainer = csdl.$EntityContainer ? modelElement(csdl.$EntityContainer, csdl, namespace) : {}; if (csdl.$EntityContainer) { let serviceName = nameParts(csdl.$EntityContainer).qualifier; Object.keys(entityContainer).forEach(element => { @@ -125,10 +132,10 @@ module.exports.csdl2openapi = function ( const openapi = { openapi: '3.0.2', - info: getInfo(csdl, entityContainer), + info: getInfo(csdl, entityContainer, voc, diagram, namespace), 'x-sap-api-type': 'ODATAV4', 'x-odata-version': csdl.$Version, - 'x-sap-shortText': getShortText(csdl, entityContainer), + 'x-sap-shortText': getShortText(csdl, entityContainer, voc), servers: getServers(serviceRoot, serversObject), tags: entityContainer ? getTags(entityContainer) : {}, paths: entityContainer ? getPaths(entityContainer) : {}, @@ -144,101 +151,6 @@ module.exports.csdl2openapi = function ( Object.assign(openapi, extensions); } - // function to read @OpenAPI.Extensions and get them in the generated openAPI document - function getExtensions(csdl, level) { - let extensionObj = {}; - let containerSchema = {}; - if (level ==='root'){ - const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; - containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; - } - else if(level === 'schema' || level === 'operation'){ - containerSchema = csdl; - } - - for (const [key, value] of Object.entries(containerSchema)) { - if (key.startsWith('@OpenAPI.Extensions')) { - const annotationProperties = key.split('@OpenAPI.Extensions.')[1] ?? '' - const keys = annotationProperties.split('.'); - if (!keys[0].startsWith("x-sap-")) { - keys[0] = (keys[0].startsWith("sap-") ? "x-" : "x-sap-") + keys[0]; - } - if (keys.length === 1) { - extensionObj[keys[0]] = value; - } else { - nestedAnnotation(extensionObj, keys[0], keys, value); - } - } - } - let extensionEnums = { - "x-sap-compliance-level": {allowedValues: ["sap:base:v1", "sap:core:v1", "sap:core:v2" ] } , - "x-sap-api-type": {allowedValues: [ "ODATA", "ODATAV4", "REST" , "SOAP"] }, - "x-sap-direction": {allowedValues: ["inbound", "outbound", "mixed"] , default : "inbound" }, - "x-sap-dpp-entity-semantics": {allowedValues: ["sap:DataSubject", "sap:DataSubjectDetails", "sap:Other"] }, - "x-sap-dpp-field-semantics": {allowedValues: ["sap:DataSubjectID", "sap:ConsentID", "sap:PurposeID", "sap:ContractRelatedID", "sap:LegalEntityID", "sap:DataControllerID", "sap:UserID", "sap:EndOfBusinessDate", "sap:BlockingDate", "sap:EndOfRetentionDate"] }, - }; - checkForExtensionEnums(extensionObj, extensionEnums); - - let extensionSchema = { - "x-sap-stateInfo": ['state', 'deprecationDate', 'decomissionedDate', 'link'], - "x-sap-ext-overview": ['name', 'values'], - "x-sap-deprecated-operation" : ['deprecationDate', 'successorOperationRef', "successorOperationId"], - "x-sap-odm-semantic-key" : ['name', 'values'], - }; - - checkForExtentionSchema(extensionObj, extensionSchema); - return extensionObj; - } - - function checkForExtensionEnums(extensionObj, extensionEnums){ - for (const [key, value] of Object.entries(extensionObj)) { - if(extensionEnums[key] && extensionEnums[key].allowedValues && !extensionEnums[key].allowedValues.includes(value)){ - if(extensionEnums[key].default){ - extensionObj[key] = extensionEnums[key].default; - } - else{ - delete extensionObj[key]; - } - } - } - } - - function checkForExtentionSchema(extensionObj, extensionSchema) { - for (const [key, value] of Object.entries(extensionObj)) { - if (extensionSchema[key]) { - if (Array.isArray(value)) { - extensionObj[key] = value.filter((v) => extensionSchema[key].includes(v)); - } else if (typeof value === "object" && value !== null) { - for (const field in value) { - if (!extensionSchema[key].includes(field)) { - delete extensionObj[key][field]; - } - } - } - } - } - } - - - function nestedAnnotation(resObj, openapiProperty, keys, value) { - if (resObj[openapiProperty] === undefined) { - resObj[openapiProperty] = {}; - } - - let node = resObj[openapiProperty]; - - // traverse the annotation property and define the objects if they're not defined - for (let nestedIndex = 1; nestedIndex < keys.length - 1; nestedIndex++) { - const nestedElement = keys[nestedIndex]; - if (node[nestedElement] === undefined) { - node[nestedElement] = {}; - } - node = node[nestedElement]; - } - - // set value annotation property - node[keys[keys.length - 1]] = value; - } if (!csdl.$EntityContainer) { // explicit cast required as .servers and .tags are not declared as optional @@ -250,347 +162,6 @@ module.exports.csdl2openapi = function ( return openapi; - - /** - * Collect model info for easier lookup - * @param {object} csdl CSDL document - * @param {object} boundOverloads Map of action/function names to bound overloads - * @param {object} derivedTypes Map of type names to derived types - * @param {object} alias Map of namespace or alias to alias - * @param {object} namespace Map of namespace or alias to namespace - * @param {object} namespaceUrl Map of namespace to reference URL - * @param {object} voc Map of vocabularies and terms - */ - function preProcess(csdl, boundOverloads, derivedTypes, alias, namespace, namespaceUrl, voc) { - Object.keys(csdl.$Reference || {}).forEach(url => { - const reference = csdl.$Reference[url]; - (reference.$Include || []).forEach(include => { - const qualifier = include.$Alias || include.$Namespace; - alias[include.$Namespace] = qualifier; - namespace[qualifier] = include.$Namespace; - namespace[include.$Namespace] = include.$Namespace; - namespaceUrl[include.$Namespace] = url; - }); - }); - - getVocabularies(voc, alias); - - Object.keys(csdl).filter(name => isIdentifier(name)).forEach(name => { - const schema = csdl[name]; - const qualifier = schema.$Alias || name; - const isDefaultNamespace = schema[voc.Core.DefaultNamespace]; - - alias[name] = qualifier; - namespace[qualifier] = name; - namespace[name] = name; - - Object.keys(schema).filter(iName => isIdentifier(iName)).forEach(iName2 => { - const qualifiedName = qualifier + '.' + iName2; - const element = schema[iName2]; - if (Array.isArray(element)) { - element.filter(overload => overload.$IsBound).forEach(overload => { - const type = overload.$Parameter[0].$Type + (overload.$Parameter[0].$Collection ? '-c' : ''); - if (!boundOverloads[type]) boundOverloads[type] = []; - boundOverloads[type].push({ name: (isDefaultNamespace ? iName2 : qualifiedName), overload: overload }); - }); - } else if (element.$BaseType) { - const base = namespaceQualifiedName(element.$BaseType); - if (!derivedTypes[base]) derivedTypes[base] = []; - derivedTypes[base].push(qualifiedName); - } - }); - - Object.keys(schema.$Annotations || {}).forEach(target => { - const annotations = schema.$Annotations[target]; - const segments = target.split('/'); - const firstSegment = segments[0]; - const open = firstSegment.indexOf('('); - let element; - if (open == -1) { - element = modelElement(firstSegment); - } else { - element = modelElement(firstSegment.substring(0, open)); - let args = firstSegment.substring(open + 1, firstSegment.length - 1); - element = element.find( - (overload) => - (overload.$Kind == "Action" && - overload.$IsBound != true && - args == "") || - (overload.$Kind == "Action" && - args == - (overload.$Parameter[0].$Collection - ? `Collection(${overload.$Parameter[0].$Type})` - : overload.$Parameter[0].$Type || "")) || - (overload.$Parameter || []) - .map((p) => { - const type = p.$Type || "Edm.String"; - return p.$Collection ? `Collection(${type})` : type; - }) - .join(",") == args - ); - } - if (!element) { - DEBUG?.(`Invalid annotation target '${target}'`); - } else if (Array.isArray(element)) { - //TODO: action or function: - //- loop over all overloads - //- if there are more segments, a parameter or the return type is targeted - } else { - switch (segments.length) { - case 1: - Object.assign(element, annotations); - break; - case 2: { - const secondSegment = /**@type{string}*/(segments[1]) - if (['Action', 'Function'].includes(element.$Kind)) { - if (secondSegment === '$ReturnType') { - if (element.$ReturnType) - Object.assign(element.$ReturnType, annotations); - } else { - const parameter = element.$Parameter.find(p => p.$Name == secondSegment); - Object.assign(parameter, annotations); - } - } else if (element[secondSegment]) { - Object.assign(element[secondSegment], annotations); - } - break; - } - default: - DEBUG?.('More than two annotation target path segments'); - } - } - }); - }); - } - - /** - * Construct map of qualified term names - * @param {object} voc Map of vocabularies and terms - * @param {object} alias Map of namespace or alias to alias - */ - function getVocabularies(voc, alias) { - const terms = { - Authorization: ['Authorizations', 'SecuritySchemes'], - Capabilities: ['BatchSupport', 'BatchSupported', 'ChangeTracking', 'CountRestrictions', 'DeleteRestrictions', 'DeepUpdateSupport', 'ExpandRestrictions', - 'FilterRestrictions', 'IndexableByKey', 'InsertRestrictions', 'KeyAsSegmentSupported', 'NavigationRestrictions', 'OperationRestrictions', - 'ReadRestrictions', 'SearchRestrictions', 'SelectSupport', 'SkipSupported', 'SortRestrictions', 'TopSupported', 'UpdateRestrictions'], - Core: ['AcceptableMediaTypes', 'Computed', 'ComputedDefaultValue', 'DefaultNamespace', 'Description', 'Example', 'Immutable', 'LongDescription', - 'OptionalParameter', 'Permissions', 'SchemaVersion'], - JSON: ['Schema'], - Validation: ['AllowedValues', 'Exclusive', 'Maximum', 'Minimum', 'Pattern'] - }; - - Object.keys(terms).forEach(vocab => { - voc[vocab] = {}; - terms[vocab].forEach(term => { - if (alias['Org.OData.' + vocab + '.V1'] != undefined) - voc[vocab][term] = '@' + alias['Org.OData.' + vocab + '.V1'] + '.' + term; - }); - }); - - voc.Common = { - Label: `@${alias['com.sap.vocabularies.Common.v1']}.Label` - } - } - - /** - * Construct the Info Object - * @param {object} csdl CSDL document - * @param {object} entityContainer Entity Container object - * @return {object} Info Object - */ - function getInfo(csdl, entityContainer) { - const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; - const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; - let description; - if (entityContainer && entityContainer[voc.Core.LongDescription]) { - description = entityContainer[voc.Core.LongDescription]; - } - else if (containerSchema && containerSchema[voc.Core.LongDescription]) { - description = containerSchema[voc.Core.LongDescription]; - } - else { - description = "Use @Core.LongDescription: '...' on your CDS service to provide a meaningful description."; - } - description += (diagram ? getResourceDiagram(csdl, entityContainer) : ''); - let title; - if (entityContainer && entityContainer[voc.Common.Label]) { - title = entityContainer[voc.Common.Label]; - } - else { - title = "Use @title: '...' on your CDS service to provide a meaningful title."; - } - return { - title: title, - description: csdl.$EntityContainer ? description : '', - version: containerSchema[voc.Core.SchemaVersion] || '' - }; - } - - /** - * Construct the externalDocs Object - * @param {object} csdl CSDL document - * @return {object} externalDocs Object - */ - function getExternalDoc(csdl) { - const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; - const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; - let externalDocs = {}; - if (containerSchema?.['@OpenAPI.externalDocs.description']) { - externalDocs.description = containerSchema['@OpenAPI.externalDocs.description']; - } - if (containerSchema?.['@OpenAPI.externalDocs.url']) { - externalDocs.url = containerSchema['@OpenAPI.externalDocs.url']; - } - return externalDocs; - } - - /** - * Construct resource diagram using web service at https://yuml.me - * @param {object} csdl CSDL document - * @param {object} entityContainer Entity Container object - * @return {string} resource diagram - */ - function getResourceDiagram(csdl, entityContainer) { - let diagram = ''; - let comma = ''; - //TODO: make colors configurable - let color = { resource: '{bg:lawngreen}', entityType: '{bg:lightslategray}', complexType: '', external: '{bg:whitesmoke}' } - - Object.keys(csdl).filter(name => isIdentifier(name)).forEach(namespace => { - const schema = csdl[namespace]; - Object.keys(schema).filter(name => isIdentifier(name) && ['EntityType', 'ComplexType'].includes(schema[name].$Kind)) - .forEach(typeName => { - const type = schema[typeName]; - diagram += comma - + (type.$BaseType ? '[' + nameParts(type.$BaseType).name + ']^' : '') - + '[' + typeName + (type.$Kind == 'EntityType' ? color.entityType : color.complexType) + ']'; - Object.keys(type).filter(name => isIdentifier(name)).forEach(propertyName => { - const property = type[propertyName]; - const targetNP = nameParts(property.$Type || 'Edm.String'); - if (property.$Kind == 'NavigationProperty' || targetNP.qualifier != 'Edm') { - const target = modelElement(property.$Type); - const bidirectional = property.$Partner && target && target[property.$Partner] && target[property.$Partner].$Partner == propertyName; - // Note: if the partner has the same name then it will also be depicted - if (!bidirectional || propertyName <= property.$Partner) { - diagram += ',[' + typeName + ']' - + ((property.$Kind != 'NavigationProperty' || property.$ContainsTarget) ? '++' : (bidirectional ? cardinality(target[property.$Partner]) : '')) - + '-' - + cardinality(property) - + ((property.$Kind != 'NavigationProperty' || bidirectional) ? '' : '>') - + '[' - + (target ? targetNP.name : property.$Type + color.external) - + ']'; - } - } - }); - comma = ','; - }); - }); - - Object.keys(entityContainer).filter(name => isIdentifier(name)).reverse().forEach(name => { - const resource = entityContainer[name]; - if (resource.$Type) { - diagram += comma - + '[' + name + '%20' + color.resource + ']' // additional space in case entity set and type have same name - + '++-' - + cardinality(resource) - + '>[' + nameParts(resource.$Type).name + ']'; - } else { - if (resource.$Action) { - diagram += comma - + '[' + name + color.resource + ']'; - const overload = modelElement(resource.$Action).find(pOverload => !pOverload.$IsBound); - diagram += overloadDiagram(name, overload); - } else if (resource.$Function) { - diagram += comma - + '[' + name + color.resource + ']'; - const overloads = modelElement(resource.$Function); - if (overloads) { - const unbound = overloads.filter(overload => !overload.$IsBound); - // TODO: loop over all overloads, add new source box after first arrow - diagram += overloadDiagram(name, unbound[0]); - } - } - } - }); - - if (diagram != '') { - diagram = '\n\n## Entity Data Model\n![ER Diagram](https://yuml.me/diagram/class/' - + diagram - + ')\n\n### Legend\n![Legend](https://yuml.me/diagram/plain;dir:TB;scale:60/class/[External.Type' + color.external - + '],[ComplexType' + color.complexType + '],[EntityType' + color.entityType - + '],[EntitySet/Singleton/Operation' + color.resource + '])'; - } - - return diagram; - - /** - * Diagram representation of property cardinality - * @param {object} typedElement Typed model element, e.g. property - * @return {string} cardinality - */ - function cardinality(typedElement) { - return typedElement.$Collection ? '*' : (typedElement.$Nullable ? '0..1' : ''); - } - - /** - * Diagram representation of action or function overload - * @param {string} name Name of action or function import - * @param {object} overload Action or function overload - * @return {string} diagram part - */ - function overloadDiagram(name, overload) { - let diag = ""; - if (overload.$ReturnType) { - const type = modelElement(overload.$ReturnType.$Type || "Edm.String"); - if (type) { - diag += "-" + cardinality(overload.$ReturnType) + ">[" + nameParts(overload.$ReturnType.$Type).name + "]"; - } - } - for (const param of overload.$Parameter || []) { - const type = modelElement(param.$Type || "Edm.String"); - if (type) { - diag += comma + "[" + name + color.resource + "]in-" + cardinality(param.$Type) + ">[" + nameParts(param.$Type).name + "]"; - } - } - return diag; - } - } - - /** - * Find model element by qualified name - * @param {string} qname Qualified name of model element - * @return {object} Model element - */ - function modelElement(qname) { - const q = nameParts(qname); - const schema = csdl[q.qualifier] || csdl[namespace[q.qualifier]]; - return schema ? schema[q.name] : null; - } - - /** - * Construct the short text - * @param {object} csdl CSDL document - * @param {object} entityContainer Entity Container object - * @return {string} short text - */ - function getShortText(csdl, entityContainer) { - const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; - const containerSchema = csdl.$EntityContainer ? csdl[namespace] : {}; - let shortText; - if (entityContainer && entityContainer[voc.Core.Description]) { - shortText = entityContainer[voc.Core.Description]; - } - else if (containerSchema && containerSchema[voc.Core.Description]) { - shortText = containerSchema[voc.Core.Description]; - } - else { - shortText = "Use @Core.Description: '...' on your CDS service to provide a meaningful short text."; - } - return shortText; - } - /** * Construct an array of Server Objects * @param {object} serviceRoot The service root @@ -626,7 +197,7 @@ module.exports.csdl2openapi = function ( Object.keys(container) .filter(name => isIdentifier(name) && container[name].$Type) .forEach(child => { - const type = modelElement(container[child].$Type) || {}; + const type = modelElement(container[child].$Type, csdl, namespace) || {}; const tag = { name: type[voc.Common.Label] || child }; @@ -648,7 +219,7 @@ module.exports.csdl2openapi = function ( resources.forEach(name => { let child = container[name]; if (child.$Type) { - const type = modelElement(child.$Type); + const type = modelElement(child.$Type, csdl, namespace); const sourceName = (type && type[voc.Common.Label]) || name; // entity sets and singletons are almost containment navigation properties child.$ContainsTarget = true; @@ -680,7 +251,7 @@ module.exports.csdl2openapi = function ( */ function pathItems(paths, prefix, prefixParameters, element, root, sourceName, targetName, target, level, navigationPath) { const name = prefix.substring(prefix.lastIndexOf('/') + 1); - const type = modelElement(element.$Type); + const type = modelElement(element.$Type, csdl, namespace); const pathItem = {}; const restrictions = navigationPropertyRestrictions(root, navigationPath); const nonExpandable = nonExpandableProperties(root, navigationPath); @@ -764,7 +335,7 @@ module.exports.csdl2openapi = function ( const targetIndexable = target == null || target[voc.Capabilities.IndexableByKey] != false; if (restrictions.IndexableByKey == true || restrictions.IndexableByKey != false && targetIndexable) { const name = prefix.substring(prefix.lastIndexOf('/') + 1); - const type = modelElement(element.$Type); + const type = modelElement(element.$Type, csdl, namespace); const key = entityKey(type, level); if (key.parameters.length > 0) { const path = prefix + key.segment; @@ -802,7 +373,7 @@ module.exports.csdl2openapi = function ( let countRestrictions = target?.[voc.Capabilities.CountRestrictions]?.Countable === false // count property will be added if CountRestrictions is false if (insertRestrictions.Insertable !== false) { const lname = pluralize.singular(splitName(name)); - const type = modelElement(element.$Type); + const type = modelElement(element.$Type, csdl, namespace); pathItem.post = { summary: insertRestrictions.Description || operationSummary('Creates', name, sourceName, level, true, true), tags: [sourceName], @@ -1024,8 +595,8 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot */ function navigationPaths(element, prefix = '', level = 0) { const paths = []; - const type = modelElement(element.$Type); - const properties = propertiesOfStructuredType(type); + const type = modelElement(element.$Type, csdl, namespace); + const properties = propertiesOfStructuredType(type, csdl, namespace); Object.keys(properties).forEach(key => { if (properties[key].$Kind == 'NavigationProperty') { paths.push(prefix + key) @@ -1107,42 +678,6 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } } - /** - * Unpack EnumMember value if it uses CSDL JSON CS01 style, like CAP does - * @param {string | object} member Qualified name of referenced type - * @return {object} Reference Object - */ - function enumMember(member) { - if (typeof member == 'string') - return member; - else if (typeof member == 'object') - return member.$EnumMember; - } - - /** - * Unpack NavigationPropertyPath value if it uses CSDL JSON CS01 style, like CAP does - * @param {string | object} path Qualified name of referenced type - * @return {object} Reference Object - */ - function navigationPropertyPath(path) { - if (typeof path == 'string') - return path; - else - return path.$NavigationPropertyPath; - } - - /** - * Unpack PropertyPath value if it uses CSDL JSON CS01 style, like CAP does - * @param {string | object} path Qualified name of referenced type - * @return {object} Reference Object - */ - function propertyPath(path) { - if (typeof path == 'string') - return path; - else - return path.$PropertyPath; - } - /** * Collect primitive paths of a navigation segment and its potentially structured components * @param {object} element Model element of navigation segment @@ -1151,19 +686,19 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot */ function primitivePaths(element, prefix = '') { const paths = []; - const elementType = modelElement(element.$Type); + const elementType = modelElement(element.$Type, csdl, namespace); if (!elementType) { DEBUG?.(`Unknown type for element: ${JSON.stringify(element)}`); return paths; } - const propsOfType = propertiesOfStructuredType(elementType); + const propsOfType = propertiesOfStructuredType(elementType, csdl, namespace); const ignore = Object.entries(propsOfType) .filter(entry => entry[1].$Kind !== 'NavigationProperty') .filter(entry => entry[1].$Type) .filter(entry => nameParts(entry[1].$Type).qualifier !== 'Edm') - .filter(entry => !modelElement(entry[1].$Type)); + .filter(entry => !modelElement(entry[1].$Type, csdl, namespace)); // Keep old logging ignore.forEach(entry => DEBUG?.(`Unknown type for element: ${JSON.stringify(entry)}`)); @@ -1202,11 +737,11 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot return function (entry) { const key = entry[0]; const property = entry[1]; - const propertyType = property.$Type && modelElement(property.$Type); + const propertyType = property.$Type && modelElement(property.$Type, csdl, namespace); if (propertyType && propertyType.$Kind && propertyType.$Kind === 'ComplexType') { return { - properties: propertiesOfStructuredType(propertyType), + properties: propertiesOfStructuredType(propertyType, csdl, namespace), path: `${parent.path}${key}/`, typeRefChain: parent.typeRefChain.concat(property.$Type), isComplex: true @@ -1256,8 +791,8 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const selectSupport = restrictions.SelectSupport ?? target?.[voc.Capabilities.SelectSupport] ?? {}; if (selectSupport.Supported !== false) { - const type = modelElement(element.$Type) || {}; - const properties = propertiesOfStructuredType(type); + const type = modelElement(element.$Type, csdl, namespace) || {}; + const properties = propertiesOfStructuredType(type, csdl, namespace); const selectItems = []; Object.keys(properties).filter(key => properties[key].$Kind != 'NavigationProperty').forEach( key => selectItems.push(key) @@ -1332,7 +867,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const updateRestrictions = restrictions.UpdateRestrictions || target?.[voc.Capabilities.UpdateRestrictions] || {}; let countRestrictions = target?.[voc.Capabilities.CountRestrictions]?.Countable === false; if (updateRestrictions.Updatable !== false) { - const type = modelElement(element.$Type); + const type = modelElement(element.$Type, csdl, namespace); const operation = { summary: updateRestrictions.Description || operationSummary('Changes', name, sourceName, level, element.$Collection, byKey), tags: [sourceName], @@ -1391,8 +926,8 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot */ function pathItemsWithNavigation(paths, prefix, prefixParameters, type, root, sourceName, level, navigationPrefix) { const navigationRestrictions = root[voc.Capabilities.NavigationRestrictions] ?? {}; - const rootNavigable = level == 0 && enumMember(navigationRestrictions.Navigability) != 'None' - || level == 1 && enumMember(navigationRestrictions.Navigability) != 'Single' + const rootNavigable = level == 0 && enumMember(navigationRestrictions.Navigability) !== 'None' + || level == 1 && enumMember(navigationRestrictions.Navigability) !== 'Single' || level > 1; if (type && level < maxLevels) { @@ -1407,7 +942,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot || restrictions.Navigability == null && rootNavigable) { const targetSetName = root.$NavigationPropertyBinding && root.$NavigationPropertyBinding[navigationPath]; const target = entityContainer[targetSetName]; - const targetType = target && modelElement(target.$Type); + const targetType = target && modelElement(target.$Type, csdl, namespace); const targetName = (targetType && targetType[voc.Common.Label]) || targetSetName; pathItems(paths, prefix + '/' + name, prefixParameters, properties[name], root, sourceName, targetName, target, level + 1, navigationPath); } @@ -1424,12 +959,12 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot * @return {object} Map of navigation property paths and their types */ function navigationPathMap(type, map = {}, prefix = '', level = 0) { - const properties = propertiesOfStructuredType(type); + const properties = propertiesOfStructuredType(type, csdl, namespace); Object.keys(properties).forEach(key => { if (properties[key].$Kind == 'NavigationProperty') { map[prefix + key] = properties[key]; } else if (properties[key].$Type && !properties[key].$Collection && level < maxLevels) { - navigationPathMap(modelElement(properties[key].$Type), map, prefix + key + '/', level + 1); + navigationPathMap(modelElement(properties[key].$Type, csdl, namespace), map, prefix + key + '/', level + 1); } }) return map; @@ -1463,7 +998,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot while (type) { keys = type.$Key; if (keys || !type.$BaseType) break; - type = modelElement(type.$BaseType); + type = modelElement(type.$BaseType, csdl, namespace); } return keys; } @@ -1478,7 +1013,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot let segment = ''; const params = []; const keys = getKey(entityType) || []; - const properties = propertiesOfStructuredType(entityType); + const properties = propertiesOfStructuredType(entityType, csdl, namespace); keys.forEach((key, index) => { const suffix = level > 0 ? '_' + level : ''; @@ -1498,8 +1033,8 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const segments = key[parameter].split('/'); property = properties[segments[0]]; for (let i = 1; i < segments.length; i++) { - const complexType = modelElement(property.$Type); - const properties = propertiesOfStructuredType(complexType); + const complexType = modelElement(property.$Type, csdl, namespace); + const properties = propertiesOfStructuredType(complexType, csdl, namespace); property = properties[segments[i]]; } } @@ -1574,7 +1109,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot * @param {object} child Action import object */ function pathItemActionImport(paths, name, child) { - const overload = modelElement(child.$Action).find(pOverload => !pOverload.$IsBound); + const overload = modelElement(child.$Action, csdl, namespace).find(pOverload => !pOverload.$IsBound); pathItemAction(paths, '/' + name, [], child.$Action, overload, child.$EntitySet, child); } @@ -1633,7 +1168,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot * @param {object} child Function import object */ function pathItemFunctionImport(paths, name, child) { - const overloads = modelElement(child.$Function); + const overloads = modelElement(child.$Function, csdl, namespace); console.assert(overloads, 'Unknown function "' + child.$Function + '" in function import "' + name + '"'); overloads && overloads.filter(overload => !overload.$IsBound).forEach(overload => pathItemFunction(paths, '/' + name, [], child.$Function, overload, child.$EntitySet, child)); } @@ -1663,7 +1198,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot }; const description = [p[voc.Core.Description], p[voc.Core.LongDescription]].filter(t => t).join(' \n'); if (description) param.description = description; - const type = modelElement(p.$Type || 'Edm.String'); + const type = modelElement(p.$Type ?? 'Edm.String', csdl, namespace); // 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) @@ -1688,7 +1223,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot + 'URL-encoded JSON ' + (p.$Collection ? 'array with items ' : '') + 'of type ' - + namespaceQualifiedName(p.$Type || 'Edm.String') + + namespaceQualifiedName(p.$Type ?? 'Edm.String', namespace) + ', 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 { @@ -1860,7 +1395,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot /** * Construct the Components Object from the types of the CSDL document - * @param {object} csdl CSDL document + * @param {import('./types').CSDL} csdl CSDL document * @param {object} entityContainer Entity Container object * @return {object} Components Object */ @@ -1897,7 +1432,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const unordered = {}; for (const r of requiredSchemas.list) { - const type = modelElement(`${r.namespace}.${r.name}`); + const type = modelElement(`${r.namespace}.${r.name}`, csdl, namespace); if (!type) continue; switch (type.$Kind) { case "ComplexType": @@ -2034,7 +1569,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot isCount = false; } } - const properties = propertiesOfStructuredType(type); + const properties = propertiesOfStructuredType(type, csdl, namespace); Object.keys(properties).forEach(iName => { const property = properties[iName]; if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); @@ -2114,21 +1649,6 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } } - /** - * Collect all properties of a structured type along the inheritance hierarchy - * @param {object} type Structured type - * @return {object} Map of properties - */ - function propertiesOfStructuredType(type) { - const properties = (type && type.$BaseType) ? propertiesOfStructuredType(modelElement(type.$BaseType)) : {}; - if (type) { - Object.keys(type).filter(name => isIdentifier(name)).forEach(name => { - properties[name] = type[name]; - }); - } - return properties; - } - /** * Construct Parameter Objects for type-independent OData system query options * @return {object} Map of Parameter Objects @@ -2408,7 +1928,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot if (element.$Type.startsWith('Edm.')) { DEBUG?.('Unknown type: ' + element.$Type); } else { - let type = modelElement(element.$Type); + let type = modelElement(element.$Type, csdl, namespace); let isStructured = type && ['ComplexType', 'EntityType'].includes(type.$Kind); s = ref(element.$Type, (isStructured ? suffix : '')); if (element.$MaxLength) { @@ -2437,11 +1957,11 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot s.example = element[voc.Core.Example].Value; } - /** @returns {s is import('./types.d.ts').StringSchema} */ + /** @returns {s is import('./types').StringSchema} */ const isStringSchema = s => s?.type === 'string' - /** @returns {s is import('./types.d.ts').NumberSchema} */ + /** @returns {s is import('./types').NumberSchema} */ const isNumberSchema = s => s?.type === 'number' || s?.type === 'integer' - /** @returns {s is import('./types.d.ts').AnyOf} */ + /** @returns {s is import('./types').AnyOf} */ const isAnyOfSchema = s => Boolean(s?.anyOf) if (forFunction) { @@ -2531,13 +2051,12 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot */ function ref(typename, suffix = '') { let name = typename; - let nsp = ''; let url = ''; if (typename.indexOf('.') != -1) { let parts = nameParts(typename); - nsp = namespace[parts.qualifier]; + const nsp = namespace[parts.qualifier]; name = nsp + '.' + parts.name; - url = namespaceUrl[nsp] || ''; + url = namespaceUrl[nsp] ?? ''; if (url === "" && !requiredSchemas.used[name + suffix]) { requiredSchemas.used[name + suffix] = true; requiredSchemas.list.push({ namespace: nsp, name: parts.name, suffix }); @@ -2639,42 +2158,9 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } if (securitySchemes.length > 0) openapi.security = []; securitySchemes.forEach(scheme => { - const s = {}; - s[scheme.Authorization] = scheme.RequiredScopes || []; - openapi.security.push(s); + openapi.security.push({ + [scheme.Authorization]: scheme.RequiredScopes ?? [] + }); }); } - - /** - * a qualified name consists of a namespace or alias, a dot, and a simple name - * @param {string} qualifiedName - * @return {string} namespace-qualified name - */ - function namespaceQualifiedName(qualifiedName) { - let np = nameParts(qualifiedName); - return namespace[np.qualifier] + '.' + np.name; - } - - /** - * 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('.'); - console.assert(pos > 0, 'Invalid qualified name ' + qualifiedName); - return { - qualifier: qualifiedName.substring(0, pos), - name: qualifiedName.substring(pos + 1) - }; - } - - /** - * 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('@'); - } }; diff --git a/lib/compile/index.js b/lib/compile/index.js index 4f6cdf5..10838f2 100644 --- a/lib/compile/index.js +++ b/lib/compile/index.js @@ -21,11 +21,9 @@ module.exports = function processor(csn, options = {}) { const openApiOptions = toOpenApiOptions(csdl, csn, options); const serviceName = csdl.$EntityContainer.replace(/\.[^.]+$/, ""); openApiDocs = _getOpenApi(csdl, openApiOptions,serviceName); - if(Object.keys(openApiDocs).length===1){ - return openApiDocs[serviceName]; - }else{ - return _iterate(openApiDocs); - } + return Object.keys(openApiDocs).length === 1 + ? openApiDocs[serviceName] + : _iterate(openApiDocs); } } @@ -44,18 +42,14 @@ function _getOpenApiForMultipleServices(csdl, csn, options) { function* _iterate(openApiDocs) { for (const key in openApiDocs) { - if (key != "") { - yield [openApiDocs[key], { file: key }]; - } else { - yield [openApiDocs[key]]; - } + yield key !== "" + ? [openApiDocs[key], { file: key }] + : [openApiDocs[key]] } } function _getOpenApi(csdl, options, serviceName = "") { const openApiDocs = {}; - let filename; - const protocols = Object.keys(options["url"]); protocols.forEach((protocol) => { @@ -70,11 +64,10 @@ function _getOpenApi(csdl, options, serviceName = "") { const openapi = csdl2openapi.csdl2openapi(csdl, sOptions); - if (protocols.length > 1) { - filename = serviceName + "." + protocol; - } else { - filename = serviceName; - } + const filename = protocols.length > 1 + ? serviceName + "." + protocol + : serviceName + openApiDocs[filename] = openapi; }); diff --git a/lib/compile/model-navigation.js b/lib/compile/model-navigation.js new file mode 100644 index 0000000..a200582 --- /dev/null +++ b/lib/compile/model-navigation.js @@ -0,0 +1,121 @@ +/** + * Model Navigation Utilities + * + * Provides utilities for navigating and querying CSDL model elements + */ + +/** + * 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 + */ +function nameParts(qualifiedName) { + const pos = qualifiedName.lastIndexOf('.'); + console.assert(pos > 0, 'Invalid qualified name ' + qualifiedName); + return { + qualifier: qualifiedName.substring(0, pos), + name: qualifiedName.substring(pos + 1) + }; +} + +/** + * Convert qualified name to namespace-qualified name + * @param {string} qualifiedName + * @param {Record} namespace Map of namespace or alias to namespace + * @return namespace-qualified name + */ +function namespaceQualifiedName(qualifiedName, namespace) { + const np = nameParts(qualifiedName); + return namespace[np.qualifier] + '.' + np.name; +} + +/** + * Find model element by qualified name + * @param {string} qname Qualified name of model element + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} namespace Map of namespace or alias to namespace + * @return {object|null} Model element or null if not found + */ +function modelElement(qname, csdl, namespace) { + if (!qname) return null; + const q = nameParts(qname); + const schema = csdl[q.qualifier] || csdl[namespace[q.qualifier]]; + return schema ? schema[q.name] : null; +} + +/** + * Get all properties of a structured type, including inherited properties + * @param {object} type Structured type (EntityType or ComplexType) + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} namespace Map of namespace or alias to namespace + * @return {object} Map of properties + */ +function propertiesOfStructuredType(type, csdl, namespace) { + const properties = (type && type.$BaseType) + ? propertiesOfStructuredType(modelElement(type.$BaseType, csdl, namespace), csdl, namespace) + : {}; + if (type) { + Object.keys(type).filter(name => isIdentifier(name)).forEach(name => { + properties[name] = type[name]; + }); + } + return properties; +} + +/** + * Unpack EnumMember value if it uses CSDL JSON CS01 style, like CAP does + * @param {string | object} member Qualified name of referenced type + * @return {string} Reference Object + */ +function enumMember(member) { + if (typeof member === 'string') + return member; + if (typeof member === 'object' && member.$EnumMember) + return member.$EnumMember; + return ''; +} + +/** + * Unpack NavigationPropertyPath value if it uses CSDL JSON CS01 style, like CAP does + * @param {string | object} path Qualified name of referenced type + * @return {string} Reference Object + */ +function navigationPropertyPath(path) { + if (typeof path === 'string') + return path; + if (typeof path === 'object' && path.$NavigationPropertyPath) + return path.$NavigationPropertyPath; + return ''; +} + +/** + * Unpack PropertyPath value if it uses CSDL JSON CS01 style, like CAP does + * @param {string | object} path Qualified name of referenced type + * @return {string} Reference Object + */ +function propertyPath(path) { + if (typeof path === 'string') + return path; + if (typeof path === 'object' && path.$PropertyPath) + return path.$PropertyPath; + return ''; +} + +module.exports = { + isIdentifier, + nameParts, + namespaceQualifiedName, + modelElement, + propertiesOfStructuredType, + enumMember, + navigationPropertyPath, + propertyPath +}; diff --git a/lib/compile/openapi-info.js b/lib/compile/openapi-info.js new file mode 100644 index 0000000..d3db3f8 --- /dev/null +++ b/lib/compile/openapi-info.js @@ -0,0 +1,323 @@ +/** + * OpenAPI Info and Extensions Module + * + * Handles generation of OpenAPI info objects, external documentation, extensions, and diagrams + */ + +const { nameParts, isIdentifier, modelElement } = require('./model-navigation'); + +/** + * Construct the Info Object + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} entityContainer Entity Container object + * @param {object} voc Vocabulary mapping + * @param {boolean} diagram Whether to include diagram + * @param {object} namespace Map of namespace or alias to namespace + * @return {object} Info Object + */ +function getInfo(csdl, entityContainer, voc, diagram, namespace) { + const namespaceQualifier = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; + const containerSchema = csdl.$EntityContainer && namespaceQualifier? csdl[namespaceQualifier] : {}; + let description; + if (entityContainer && entityContainer[voc.Core.LongDescription]) { + description = entityContainer[voc.Core.LongDescription]; + } + else if (containerSchema && containerSchema[voc.Core.LongDescription]) { + description = containerSchema[voc.Core.LongDescription]; + } + else { + description = "Use @Core.LongDescription: '...' on your CDS service to provide a meaningful description."; + } + description += (diagram ? getResourceDiagram(csdl, entityContainer, namespace) : ''); + const title = (entityContainer && entityContainer[voc.Common.Label]) + ? entityContainer[voc.Common.Label] + : "Use @title: '...' on your CDS service to provide a meaningful title." + return { + title, + description: csdl.$EntityContainer ? description : '', + version: containerSchema[voc.Core.SchemaVersion] || '' + }; +} + +/** + * Construct the externalDocs Object + * @param {import('./types').CSDL} csdl CSDL document + * @return {object} externalDocs Object + */ +function getExternalDoc(csdl) { + const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; + const containerSchema = csdl.$EntityContainer && namespace ? csdl[namespace] : {}; + let externalDocs = {}; + if (containerSchema?.['@OpenAPI.externalDocs.description']) { + externalDocs.description = containerSchema['@OpenAPI.externalDocs.description']; + } + if (containerSchema?.['@OpenAPI.externalDocs.url']) { + externalDocs.url = containerSchema['@OpenAPI.externalDocs.url']; + } + return externalDocs; +} + +/** + * Construct the short text + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} entityContainer Entity Container object + * @param {object} voc Vocabulary mapping + * @return {string} short text + */ +function getShortText(csdl, entityContainer, voc) { + const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; + const containerSchema = csdl.$EntityContainer && namespace ? csdl[namespace] : {}; + let shortText; + if (entityContainer && entityContainer[voc.Core.Description]) { + shortText = entityContainer[voc.Core.Description]; + } + else if (containerSchema && containerSchema[voc.Core.Description]) { + shortText = containerSchema[voc.Core.Description]; + } + else { + shortText = "Use @Core.Description: '...' on your CDS service to provide a meaningful short text."; + } + return shortText; +} + +/** + * Function to read @OpenAPI.Extensions and get them in the generated openAPI document + * @param {import('./types').CSDL} csdl CSDL document + * @param {string} level Processing level ('root', 'schema', 'operation') + * @return {object} Extensions object + */ +function getExtensions(csdl, level) { + let extensionObj = {}; + let containerSchema = {}; + if (level ==='root'){ + const namespace = csdl.$EntityContainer ? nameParts(csdl.$EntityContainer).qualifier : null; + containerSchema = csdl.$EntityContainer && namespace ? csdl[namespace] : {}; + } + else if(level === 'schema' || level === 'operation'){ + containerSchema = csdl; + } + + for (const [key, value] of Object.entries(containerSchema)) { + if (key.startsWith('@OpenAPI.Extensions')) { + const annotationProperties = key.split('@OpenAPI.Extensions.')[1] ?? '' + const keys = annotationProperties.split('.'); + if (!keys[0].startsWith("x-sap-")) { + keys[0] = (keys[0].startsWith("sap-") ? "x-" : "x-sap-") + keys[0]; + } + if (keys.length === 1) { + extensionObj[keys[0]] = value; + } else { + nestedAnnotation(extensionObj, keys[0], keys, value); + } + } + } + let extensionEnums = { + "x-sap-compliance-level": {allowedValues: ["sap:base:v1", "sap:core:v1", "sap:core:v2" ] } , + "x-sap-api-type": {allowedValues: [ "ODATA", "ODATAV4", "REST" , "SOAP"] }, + "x-sap-direction": {allowedValues: ["inbound", "outbound", "mixed"] , default : "inbound" }, + "x-sap-dpp-entity-semantics": {allowedValues: ["sap:DataSubject", "sap:DataSubjectDetails", "sap:Other"] }, + "x-sap-dpp-field-semantics": {allowedValues: ["sap:DataSubjectID", "sap:ConsentID", "sap:PurposeID", "sap:ContractRelatedID", "sap:LegalEntityID", "sap:DataControllerID", "sap:UserID", "sap:EndOfBusinessDate", "sap:BlockingDate", "sap:EndOfRetentionDate"] }, + }; + checkForExtensionEnums(extensionObj, extensionEnums); + + let extensionSchema = { + "x-sap-stateInfo": ['state', 'deprecationDate', 'decomissionedDate', 'link'], + "x-sap-ext-overview": ['name', 'values'], + "x-sap-deprecated-operation" : ['deprecationDate', 'successorOperationRef', "successorOperationId"], + "x-sap-odm-semantic-key" : ['name', 'values'], + }; + + checkForExtentionSchema(extensionObj, extensionSchema); + return extensionObj; +} + +/** + * Check and validate extension enums + * @param {object} extensionObj Extensions object to validate + * @param {object} extensionEnums Valid enum values + */ +function checkForExtensionEnums(extensionObj, extensionEnums){ + for (const [key, value] of Object.entries(extensionObj)) { + if(extensionEnums[key] && extensionEnums[key].allowedValues && !extensionEnums[key].allowedValues.includes(value)){ + if(extensionEnums[key].default){ + extensionObj[key] = extensionEnums[key].default; + } + else { + delete extensionObj[key]; + } + } + } +} + +/** + * Check and validate extension schema + * @param {object} extensionObj Extensions object to validate + * @param {object} extensionSchema Valid schema properties + */ +function checkForExtentionSchema(extensionObj, extensionSchema) { + for (const [key, value] of Object.entries(extensionObj)) { + if (extensionSchema[key]) { + if (Array.isArray(value)) { + extensionObj[key] = value.filter((v) => extensionSchema[key].includes(v)); + } else if (typeof value === "object" && value !== null) { + for (const field in value) { + if (!extensionSchema[key].includes(field)) { + delete extensionObj[key][field]; + } + } + } + } + } +} + +/** + * Handle nested annotation properties + * @param {object} resObj Result object + * @param {string} openapiProperty OpenAPI property name + * @param {string[]} keys Property keys + * @param {any} value Property value + */ +function nestedAnnotation(resObj, openapiProperty, keys, value) { + if (resObj[openapiProperty] === undefined) { + resObj[openapiProperty] = {}; + } + + let node = resObj[openapiProperty]; + + // traverse the annotation property and define the objects if they're not defined + for (let nestedIndex = 1; nestedIndex < keys.length - 1; nestedIndex++) { + const nestedElement = keys[nestedIndex]; + if (node[nestedElement] === undefined) { + node[nestedElement] = {}; + } + node = node[nestedElement]; + } + + // set value annotation property + node[keys[keys.length - 1]] = value; +} + +/** + * Construct resource diagram using web service at https://yuml.me + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} entityContainer Entity Container object + * @param {object} namespace Map of namespace or alias to namespace + * @return {string} resource diagram + */ +function getResourceDiagram(csdl, entityContainer, namespace) { + let diagram = ''; + let comma = ''; + //TODO: make colors configurable + let color = { resource: '{bg:lawngreen}', entityType: '{bg:lightslategray}', complexType: '', external: '{bg:whitesmoke}' } + + Object.keys(csdl).filter(name => isIdentifier(name)).forEach(namespaceName => { + const schema = csdl[namespaceName]; + Object.keys(schema).filter(name => isIdentifier(name) && ['EntityType', 'ComplexType'].includes(schema[name].$Kind)) + .forEach(typeName => { + const type = schema[typeName]; + diagram += comma + + (type.$BaseType ? '[' + nameParts(type.$BaseType).name + ']^' : '') + + '[' + typeName + (type.$Kind == 'EntityType' ? color.entityType : color.complexType) + ']'; + Object.keys(type).filter(name => isIdentifier(name)).forEach(propertyName => { + const property = type[propertyName]; + const targetNP = nameParts(property.$Type || 'Edm.String'); + if (property.$Kind == 'NavigationProperty' || targetNP.qualifier != 'Edm') { + const target = modelElement(property.$Type, csdl, namespace); + const bidirectional = property.$Partner && target && target[property.$Partner] && target[property.$Partner].$Partner == propertyName; + // Note: if the partner has the same name then it will also be depicted + if (!bidirectional || propertyName <= property.$Partner) { + diagram += ',[' + typeName + ']' + + ((property.$Kind != 'NavigationProperty' || property.$ContainsTarget) ? '++' : (bidirectional ? cardinality(target[property.$Partner]) : '')) + + '-' + + cardinality(property) + + ((property.$Kind != 'NavigationProperty' || bidirectional) ? '' : '>') + + '[' + + (target ? targetNP.name : property.$Type + color.external) + + ']'; + } + } + }); + comma = ','; + }); + }); + + Object.keys(entityContainer).filter(name => isIdentifier(name)).reverse().forEach(name => { + const resource = entityContainer[name]; + if (resource.$Type) { + diagram += comma + + '[' + name + '%20' + color.resource + ']' // additional space in case entity set and type have same name + + '++-' + + cardinality(resource) + + '>[' + nameParts(resource.$Type).name + ']'; + } else { + if (resource.$Action) { + diagram += comma + + '[' + name + color.resource + ']'; + const overload = modelElement(resource.$Action, csdl, namespace).find(pOverload => !pOverload.$IsBound); + diagram += overloadDiagram(name, overload, color, comma, csdl, namespace); + } else if (resource.$Function) { + diagram += comma + + '[' + name + color.resource + ']'; + const overloads = modelElement(resource.$Function, csdl, namespace); + if (overloads) { + const unbound = overloads.filter(overload => !overload.$IsBound); + // TODO: loop over all overloads, add new source box after first arrow + diagram += overloadDiagram(name, unbound[0], color, comma, csdl, namespace); + } + } + } + }); + + if (diagram != '') { + diagram = '\n\n## Entity Data Model\n![ER Diagram](https://yuml.me/diagram/class/' + + diagram + + ')\n\n### Legend\n![Legend](https://yuml.me/diagram/plain;dir:TB;scale:60/class/[External.Type' + color.external + + '],[ComplexType' + color.complexType + '],[EntityType' + color.entityType + + '],[EntitySet/Singleton/Operation' + color.resource + '])'; + } + + return diagram; +} + +/** + * Diagram representation of property cardinality + * @param {object} typedElement Typed model element, e.g. property + * @return {string} cardinality + */ +function cardinality(typedElement) { + return typedElement.$Collection ? '*' : (typedElement.$Nullable ? '0..1' : ''); +} + +/** + * Diagram representation of action or function overload + * @param {string} name Name of action or function import + * @param {object} overload Action or function overload + * @param {object} color Color configuration + * @param {string} comma Comma separator + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} namespace Map of namespace or alias to namespace + * @return {string} diagram part + */ +function overloadDiagram(name, overload, color, comma, csdl, namespace) { + let diag = ""; + if (overload.$ReturnType) { + const type = modelElement(overload.$ReturnType.$Type || "Edm.String", csdl, namespace); + if (type) { + diag += "-" + cardinality(overload.$ReturnType) + ">[" + nameParts(overload.$ReturnType.$Type).name + "]"; + } + } + for (const param of overload.$Parameter || []) { + const type = modelElement(param.$Type || "Edm.String", csdl, namespace); + if (type) { + diag += comma + "[" + name + color.resource + "]in-" + cardinality(param.$Type) + ">[" + nameParts(param.$Type).name + "]"; + } + } + return diag; +} + +module.exports = { + getInfo, + getExternalDoc, + getShortText, + getExtensions +}; diff --git a/lib/compile/preprocessing.js b/lib/compile/preprocessing.js new file mode 100644 index 0000000..ff2e435 --- /dev/null +++ b/lib/compile/preprocessing.js @@ -0,0 +1,170 @@ +/** + * CSDL Preprocessing Module + * + * Handles CSDL model preprocessing and vocabulary setup + */ + +/** + * @typedef {{ + * [K in keyof typeof terms]: { + * [P in typeof terms[K][number]]: string; + * } + * } & { Common: { Label: string } }} Vocabulary + */ + + +const { isIdentifier, modelElement, namespaceQualifiedName } = require('./model-navigation'); + +// Import CDS for debug functionality +const cds = require('@sap/cds'); +const DEBUG = cds.debug('openapi'); + + +const terms = { + Authorization: /**@type{const}*/(['Authorizations', 'SecuritySchemes']), + Capabilities: /**@type{const}*/([ + 'BatchSupport', 'BatchSupported', 'ChangeTracking', 'CountRestrictions', 'DeleteRestrictions', 'DeepUpdateSupport', 'ExpandRestrictions', + 'FilterRestrictions', 'IndexableByKey', 'InsertRestrictions', 'KeyAsSegmentSupported', 'NavigationRestrictions', 'OperationRestrictions', + 'ReadRestrictions', 'SearchRestrictions', 'SelectSupport', 'SkipSupported', 'SortRestrictions', 'TopSupported', 'UpdateRestrictions']), + Core: /**@type{const}*/([ + 'AcceptableMediaTypes', 'Computed', 'ComputedDefaultValue', 'DefaultNamespace', 'Description', 'Example', 'Immutable', 'LongDescription', + 'OptionalParameter', 'Permissions', 'SchemaVersion']), + JSON: /**@type{const}*/(['Schema']), + Validation: /**@type{const}*/(['AllowedValues', 'Exclusive', 'Maximum', 'Minimum', 'Pattern']) +}; + +/** + * Construct map of qualified term names + * @param {object} voc Map of vocabularies and terms + * @param {object} alias Map of namespace or alias to alias + */ +function getVocabularies(voc, alias) { + Object.keys(terms).forEach(vocab => { + voc[vocab] = {}; + terms[vocab].forEach(term => { + if (alias['Org.OData.' + vocab + '.V1'] != undefined) + voc[vocab][term] = '@' + alias['Org.OData.' + vocab + '.V1'] + '.' + term; + }); + }); + + voc.Common = { + Label: `@${alias['com.sap.vocabularies.Common.v1']}.Label` + } +} + +/** + * Collect model info for easier lookup + * @param {import('./types').CSDL} csdl CSDL document + * @param {object} boundOverloads Map of action/function names to bound overloads + * @param {object} derivedTypes Map of type names to derived types + * @param {object} alias Map of namespace or alias to alias + * @param {object} namespace Map of namespace or alias to namespace + * @param {object} namespaceUrl Map of namespace to reference URL + * @param {object} voc Map of vocabularies and terms + */ +function preProcess(csdl, boundOverloads, derivedTypes, alias, namespace, namespaceUrl, voc) { + Object.keys(csdl.$Reference || {}).forEach(url => { + const reference = csdl.$Reference[url]; + (reference.$Include || []).forEach(include => { + const qualifier = include.$Alias || include.$Namespace; + alias[include.$Namespace] = qualifier; + namespace[qualifier] = include.$Namespace; + namespace[include.$Namespace] = include.$Namespace; + namespaceUrl[include.$Namespace] = url; + }); + }); + + getVocabularies(voc, alias); + + Object.keys(csdl).filter(name => isIdentifier(name)).forEach(name => { + const schema = csdl[name]; + const qualifier = schema.$Alias || name; + const isDefaultNamespace = schema[voc.Core.DefaultNamespace]; + + alias[name] = qualifier; + namespace[qualifier] = name; + namespace[name] = name; + + Object.keys(schema).filter(iName => isIdentifier(iName)).forEach(iName2 => { + const qualifiedName = qualifier + '.' + iName2; + const element = schema[iName2]; + if (Array.isArray(element)) { + element.filter(overload => overload.$IsBound).forEach(overload => { + const type = overload.$Parameter[0].$Type + (overload.$Parameter[0].$Collection ? '-c' : ''); + boundOverloads[type] ??= []; + boundOverloads[type].push({ name: (isDefaultNamespace ? iName2 : qualifiedName), overload: overload }); + }); + } else if (element.$BaseType) { + const base = namespaceQualifiedName(element.$BaseType, namespace); + derivedTypes[base] ??= []; + derivedTypes[base].push(qualifiedName); + } + }); + + Object.keys(schema.$Annotations || {}).forEach(target => { + const annotations = schema.$Annotations[target]; + const segments = target.split('/'); + const firstSegment = segments[0]; + const open = firstSegment.indexOf('('); + let element; + if (open == -1) { + element = modelElement(firstSegment, csdl, namespace); + } else { + element = modelElement(firstSegment.substring(0, open), csdl, namespace); + let args = firstSegment.substring(open + 1, firstSegment.length - 1); + element = element.find( + (overload) => + (overload.$Kind === "Action" && + overload.$IsBound != true && + args === "") || + (overload.$Kind === "Action" && + args === + (overload.$Parameter[0].$Collection + ? `Collection(${overload.$Parameter[0].$Type})` + : overload.$Parameter[0].$Type || "")) || + (overload.$Parameter || []) + .map((p) => { + const type = p.$Type || "Edm.String"; + return p.$Collection ? `Collection(${type})` : type; + }) + .join(",") == args + ); + } + if (!element) { + DEBUG?.(`Invalid annotation target '${target}'`); + } else if (Array.isArray(element)) { + //TODO: action or function: + //- loop over all overloads + //- if there are more segments, a parameter or the return type is targeted + } else { + switch (segments.length) { + case 1: + Object.assign(element, annotations); + break; + case 2: { + const secondSegment = /**@type{string}*/(segments[1]) + if (['Action', 'Function'].includes(element.$Kind)) { + if (secondSegment === '$ReturnType') { + if (element.$ReturnType) + Object.assign(element.$ReturnType, annotations); + } else { + const parameter = element.$Parameter.find(p => p.$Name == secondSegment); + Object.assign(parameter, annotations); + } + } else if (element[secondSegment]) { + Object.assign(element[secondSegment], annotations); + } + break; + } + default: + DEBUG?.('More than two annotation target path segments'); + } + } + }); + }); +} + +module.exports = { + preProcess, + getVocabularies +}; \ No newline at end of file diff --git a/lib/compile/types.d.ts b/lib/compile/types.d.ts index 1a8f7b9..3fcf6d8 100644 --- a/lib/compile/types.d.ts +++ b/lib/compile/types.d.ts @@ -1,4 +1,4 @@ -type StringSchema = { +export type StringSchema = { type: 'string' format?: 'base64url' | 'uuid' | 'time' | 'date' | 'date-time' | 'duration' maxLength?: number @@ -6,7 +6,7 @@ type StringSchema = { pattern?: string } -type NumberSchema = { +export type NumberSchema = { type: 'number' | 'integer' format?: 'float' | 'double' | 'decimal' | 'uint8' | 'int8' | 'int16' | 'int32' | 'int64' multipleOf?: number @@ -17,11 +17,11 @@ type NumberSchema = { exclusiveMaximum?: boolean } -type BooleanSchema = { +export type BooleanSchema = { type: 'boolean' } -type ArraySchema = { +export type ArraySchema = { type: 'array', items: Schema } @@ -36,15 +36,19 @@ type Meta = { type SingleSchema = (StringSchema | NumberSchema | BooleanSchema | ArraySchema) & Meta -type AnyOf = { anyOf: Schema[] } & Meta -type AllOf = { allOf: Schema[] } & Meta +export type AnyOf = { anyOf: Schema[] } & Meta +export type AllOf = { allOf: Schema[] } & Meta type MultiSchema = AnyOf | AllOf export type Schema = (SingleSchema | MultiSchema) - - export type TargetRestrictions = { Countable?: boolean Expandable?: boolean -} \ No newline at end of file +} + +// in spite of how CSDL is define in the standard, +// we assume to be working with its .properties field +// throughout our conversion +import type { CSDL as CSDL_ } from './csdl'; +export type CSDL = CSDL_['properties'];