diff --git a/CHANGELOG.md b/CHANGELOG.md index ac43b6f..db7e4f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## Version 1.2.0 - tbd +### Added + +- Now supports `@EntityRelationship` annotations. + +### Fixed + - Fixed action/function invocation on navigation path to align with CAP runtime. ## Version 1.1.2 - 27.01.2025 diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 1b72320..e85d057 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -41,6 +41,19 @@ const ODM_ANNOTATIONS = Object.freeze( '@ODM.oid': 'x-sap-odm-oid' }); +const ER_ANNOTATION_PREFIX = '@EntityRelationship' +const ER_ANNOTATIONS = Object.freeze( + { + '@EntityRelationship.entityType': 'x-entity-relationship-entity-type', + '@EntityRelationship.entityIds': 'x-entity-relationship-entity-ids', + '@EntityRelationship.propertyType': 'x-entity-relationship-property-type', + '@EntityRelationship.reference': 'x-entity-relationship-reference', + '@EntityRelationship.compositeReferences': 'x-entity-relationship-composite-references', + '@EntityRelationship.temporalIds': 'x-entity-relationship-temporal-ids', + '@EntityRelationship.temporalReferences': 'x-entity-relationship-temporal-references', + '@EntityRelationship.referencesWithConstantIds': 'x-entity-relationship-references-with-constant-ids' + }); + /** * Construct an OpenAPI description from a CSDL document * @param {object} csdl CSDL document @@ -589,7 +602,7 @@ module.exports.csdl2openapi = function ( */ function getTags(container) { const tags = new Map(); - // all entity sets and singletons + // all entity sets and singletons Object.keys(container) .filter(name => isIdentifier(name) && container[name].$Type) .forEach(child => { @@ -2023,6 +2036,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot if (suffix === SUFFIX.read && type["@ODM.root"]) schemas[schemaName]["x-sap-root-entity"] = type["@ODM.root"] odmExtensions(type, schemas[schemaName]); + erExtensions(type, schemas[schemaName]); if (suffix === SUFFIX.create && required.length > 0) schemas[schemaName].required = [...new Set(required)]; @@ -2052,6 +2066,17 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } } + /** + * Add entity relationship extensions to OpenAPI schema for a structured type + * @param {object} type Structured type + * @param {object} schema OpenAPI schema to augment + */ + function erExtensions(type, schema) { + for (const [annotation, openApiExtension] of Object.entries(ER_ANNOTATIONS)) { + if (type[annotation]) schema[openApiExtension] = type[annotation]; + } + } + /** * Collect all properties of a structured type along the inheritance hierarchy * @param {object} type Structured type @@ -2423,6 +2448,12 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot if (element['@ODM.oidReference']?.entityName) { s['x-sap-odm-oid-reference-entity-name'] = element['@ODM.oidReference'].entityName } + + for (const key in element) { + if (key.startsWith(ER_ANNOTATION_PREFIX) && ER_ANNOTATIONS[key]) { + s[ER_ANNOTATIONS[key]] = element[key]; + } + } return s; } diff --git a/package.json b/package.json index 1a4b587..3e02925 100644 --- a/package.json +++ b/package.json @@ -34,4 +34,4 @@ "jest": "^29", "eslint": "^8.56.0" } -} +} \ No newline at end of file diff --git a/test/lib/compile/openapi.test.js b/test/lib/compile/openapi.test.js index 99d582c..bae3fc6 100644 --- a/test/lib/compile/openapi.test.js +++ b/test/lib/compile/openapi.test.js @@ -12,64 +12,64 @@ const SCENARIO = Object.freeze({ function checkAnnotations(csn, annotations, scenario = SCENARIO.positive, property = '') { const openApi = toOpenApi(csn); - const schemas = Object.entries(openApi.components.schemas).filter(([key]) => key.startsWith('sap.odm.test.A.E1')) - // Test if the openAPI document was generated with some schemas. - expect(openApi.components.schemas).toBeDefined() - expect(openApi).toBeDefined() - expect(schemas.length > 0).toBeTruthy() - - // Expect that not-allowed ODM annotations are unavailable in the schema. - if (scenario === SCENARIO.notAllowedAnnotations) { - for (const [, schema] of schemas) { - for (const [annKey] of annotations) { - expect(schema[annKey]).not.toBeDefined() - } - } - return; - } - - // Expect that even the ODM annotations with not-matched values will be derived. - if (scenario === SCENARIO.notMatchingValues) { - for (const [, schema] of schemas) { - for (const [annKey, annValue] of annotations) { - expect(schema[annKey]).toBe(annValue) - } + const schemas = Object.entries(openApi.components.schemas).filter(([key]) => key.startsWith('sap.odm.test.A.E1')) + // Test if the openAPI document was generated with some schemas. + expect(openApi.components.schemas).toBeDefined() + expect(openApi).toBeDefined() + expect(schemas.length > 0).toBeTruthy() + + // Expect that not-allowed ODM annotations are unavailable in the schema. + if (scenario === SCENARIO.notAllowedAnnotations) { + for (const [, schema] of schemas) { + for (const [annKey] of annotations) { + expect(schema[annKey]).not.toBeDefined() } - return; } + return; + } - if (scenario === SCENARIO.checkProperty) { - for (const [, schema] of schemas) { - const propertyObj = schema.properties[property] - for (const [annKey, annValue] of annotations) { - expect(propertyObj[annKey]).toBe(annValue) - } + // Expect that even the ODM annotations with not-matched values will be derived. + if (scenario === SCENARIO.notMatchingValues) { + for (const [, schema] of schemas) { + for (const [annKey, annValue] of annotations) { + expect(schema[annKey]).toBe(annValue) } - return } + return; + } + if (scenario === SCENARIO.checkProperty) { for (const [, schema] of schemas) { + const propertyObj = schema.properties[property] for (const [annKey, annValue] of annotations) { - expect(schema[annKey]).toBe(annValue) + expect(propertyObj[annKey]).toBe(annValue) } } + return + } - // Test that no other places contain the ODM extensions in the OpenAPI document. - - // components.schemas where the schemas are not from entity E1. - const notE1 = Object.entries(openApi.components.schemas).filter(([key]) => !key.startsWith('sap.odm.test.A.E1')) - for (const [, schema] of notE1) { - const schemaString = JSON.stringify(schema) - for (const [annKey] of annotations) { - expect(schemaString).not.toContain(annKey) - } + for (const [, schema] of schemas) { + for (const [annKey, annValue] of annotations) { + expect(schema[annKey]).toBe(annValue) } + } + + // Test that no other places contain the ODM extensions in the OpenAPI document. - // all other components of the OpenAPI document except the schemas. - const openApiNoSchemas = JSON.stringify({ ...openApi, components: { parameters: { ...openApi.components.parameters }, responses: { ...openApi.components.responses } } }) + // components.schemas where the schemas are not from entity E1. + const notE1 = Object.entries(openApi.components.schemas).filter(([key]) => !key.startsWith('sap.odm.test.A.E1')) + for (const [, schema] of notE1) { + const schemaString = JSON.stringify(schema) for (const [annKey] of annotations) { - expect(openApiNoSchemas).not.toContain(annKey) + expect(schemaString).not.toContain(annKey) } + } + + // all other components of the OpenAPI document except the schemas. + const openApiNoSchemas = JSON.stringify({ ...openApi, components: { parameters: { ...openApi.components.parameters }, responses: { ...openApi.components.responses } } }) + for (const [annKey] of annotations) { + expect(openApiNoSchemas).not.toContain(annKey) + } } @@ -416,6 +416,87 @@ service CatalogService { ) }) + describe('ER annotations', () => { + test('er annotations is correct', () => { + const csn = cds.compile.to.csn(` + service A { + @EntityRelationship.entityType: 'sap.vdm.sont:Material' + @EntityRelationship.entityIds : [{propertyTypes: ['sap.vdm.gfn:MaterialId']}] + @ODM.entityName : 'Product' + @ODM.oid : 'oid' + entity Material { + @EntityRelationship.propertyType: 'sap.vdm.gfn:MaterialId' + key ObjectID : String(18); + + @EntityRelationship.reference : { + referencedEntityType : 'sap.vdm.sont:BusinessPartner', + referencedPropertyType: 'sap.vdm.gfn::BusinessPartnerNumber' + } + manufacturer : String(40); + + @EntityRelationship.reference : { + referencedEntityType : 'sap.sm:PurchaseOrder', + referencedPropertyType: 'sap.sm:PurchaseOrderUUID' + } + @ODM.oidReference.entityName : 'PurchaseOrder' + PurchaseOrder : UUID; + + @EntityRelationship.reference : { + referencedEntityType : 'sap.vdm.sont:BillOfMaterial', + referencedPropertyType: 'sap.vdm.gfn:BillOfMaterialId' + } + BOM : String(30); + } + } + `) + const openAPI = toOpenApi(csn); + expect(openAPI).toBeDefined(); + const materialSchema = openAPI.components.schemas["A.Material"]; + expect(materialSchema).toBeDefined(); + expect(materialSchema["x-entity-relationship-entity-type"]).toBe('sap.vdm.sont:Material'); + expect(materialSchema["x-entity-relationship-entity-ids"]).toMatchObject([{ "propertyTypes": ["sap.vdm.gfn:MaterialId"] }]); + expect(materialSchema["x-sap-odm-entity-name"]).toBe('Product'); + expect(materialSchema["x-sap-odm-oid"]).toBe('oid'); + + const properties = materialSchema.properties; + expect(properties).toBeDefined(); + expect(properties.ObjectID).toMatchObject({ + maxLength: 18, + type: 'string', + "x-entity-relationship-property-type": 'sap.vdm.gfn:MaterialId' + }); + expect(properties.manufacturer).toMatchObject({ + maxLength: 40, + nullable: true, + type: 'string', + "x-entity-relationship-reference": { + referencedEntityType: 'sap.vdm.sont:BusinessPartner', + referencedPropertyType: 'sap.vdm.gfn::BusinessPartnerNumber' + } + }); + expect(properties.PurchaseOrder).toMatchObject({ + example: '01234567-89ab-cdef-0123-456789abcdef', + format: 'uuid', + nullable: true, + type: 'string', + "x-entity-relationship-reference": { + referencedEntityType: 'sap.sm:PurchaseOrder', + referencedPropertyType: 'sap.sm:PurchaseOrderUUID' + }, + "x-sap-odm-oid-reference-entity-name": 'PurchaseOrder' + }); + expect(properties.BOM).toMatchObject({ + maxLength: 30, + nullable: true, + type: 'string', + "x-entity-relationship-reference": { + referencedEntityType: 'sap.vdm.sont:BillOfMaterial', + referencedPropertyType: 'sap.vdm.gfn:BillOfMaterialId' + } + }); + }) + }); + test('OpenAPI annotations: @OpenAPI.externalDocs annotation is added to the schema', () => { const csn = cds.compile.to.csn(` namespace sap.OpenAPI.test; @@ -475,16 +556,16 @@ service CatalogService { }`); const openAPI = toOpenApi(csn); - expect(openAPI).toBeDefined(); - expect(openAPI['x-sap-compliance-level']).toBe('sap:base:v1'); - expect(openAPI['x-sap-ext-overview'].name).toBe('Communication Scenario'); - expect(openAPI['x-sap-ext-overview'].values.text).toBe('Planning Calendar API Integration'); - expect(openAPI['x-sap-ext-overview'].values.format).toBe('plain'); - expect(openAPI.components.schemas["sap.OpenAPI.test.A.E1"]["x-sap-dpp-is-potentially-sensitive"]).toBe('true'); - expect(openAPI.paths["/F1"].get["x-sap-operation-intent"]).toBe('read-collection for function'); - expect(openAPI.paths["/A1"].post["x-sap-operation-intent"]).toBe('read-collection for action'); - expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].deprecationDate).toBe('2022-12-31'); - expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].successorOperationId).toBe('successorOperation'); - expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].notValidKey).toBeUndefined(); + expect(openAPI).toBeDefined(); + expect(openAPI['x-sap-compliance-level']).toBe('sap:base:v1'); + expect(openAPI['x-sap-ext-overview'].name).toBe('Communication Scenario'); + expect(openAPI['x-sap-ext-overview'].values.text).toBe('Planning Calendar API Integration'); + expect(openAPI['x-sap-ext-overview'].values.format).toBe('plain'); + expect(openAPI.components.schemas["sap.OpenAPI.test.A.E1"]["x-sap-dpp-is-potentially-sensitive"]).toBe('true'); + expect(openAPI.paths["/F1"].get["x-sap-operation-intent"]).toBe('read-collection for function'); + expect(openAPI.paths["/A1"].post["x-sap-operation-intent"]).toBe('read-collection for action'); + expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].deprecationDate).toBe('2022-12-31'); + expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].successorOperationId).toBe('successorOperation'); + expect(openAPI.paths["/F1"].get["x-sap-deprecated-operation"].notValidKey).toBeUndefined(); }); });