From d150b1ef27e13207198b2091d64120a7ca85b141 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 09:34:23 +0200 Subject: [PATCH 1/8] Backreferences --- lib/compile/csdl2openapi.js | 23 +- test/lib/compile/csdl2openapi.test.js | 8 + test/lib/compile/data/back-reference.json | 60 ++ .../compile/data/back-reference.openapi3.json | 911 ++++++++++++++++++ 4 files changed, 993 insertions(+), 9 deletions(-) create mode 100644 test/lib/compile/data/back-reference.json create mode 100644 test/lib/compile/data/back-reference.openapi3.json diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 2fddefd..3bf4efc 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2140,21 +2140,26 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot return; } const property = properties[iName]; - if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); if ((Object.prototype.hasOwnProperty.call(property, '@Common.FieldControl')) && property['@Common.FieldControl'] === 'Mandatory') { required.push(iName) } if (property.$Kind == 'NavigationProperty') { - if (property.$Collection && suffix === "" && isCount === true) { - schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); - } - if (property[meta.voc.Core.Permissions] != "Read" && !property[meta.voc.Core.Computed] && (property.$ContainsTarget || property.$OnDelete === 'Cascade')) { - if (suffix === SUFFIX.create) { - schemaProperties[iName] = getSchema(property, SUFFIX.create); + const isBackReference = property.$Partner + && meta.modelElement(property.$Type)?.[property.$Partner]?.$ContainsTarget; + if (!isBackReference) { + if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); + if (property.$Collection && suffix === "" && isCount === true) { + schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); } - if (suffix === SUFFIX.update && !property[meta.voc.Core.Immutable]) { - schemaProperties[iName] = getSchema(property, SUFFIX.create); + if (property[meta.voc.Core.Permissions] != "Read" && !property[meta.voc.Core.Computed] && (property.$ContainsTarget || property.$OnDelete === 'Cascade')) { + if (suffix === SUFFIX.create) { + schemaProperties[iName] = getSchema(property, SUFFIX.create); + } + if (suffix === SUFFIX.update && !property[meta.voc.Core.Immutable]) { + schemaProperties[iName] = getSchema(property, SUFFIX.create); + } } } } else { + if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); if (property[meta.voc.Core.Permissions] === "Read" || property[meta.voc.Core.Computed] || property[meta.voc.Core.ComputedDefaultValue]) { const index = required.indexOf(iName); if (index != -1) required.splice(index, 1); diff --git a/test/lib/compile/csdl2openapi.test.js b/test/lib/compile/csdl2openapi.test.js index e50aec8..2d00d15 100644 --- a/test/lib/compile/csdl2openapi.test.js +++ b/test/lib/compile/csdl2openapi.test.js @@ -52,6 +52,9 @@ const result12 = require("./data/autoexposed-texts.openapi3.json"); const example13 = require("./data/autoexposed-direct.json"); const result13 = require("./data/autoexposed-direct.openapi3.json"); +const exampleBackRef = require("./data/back-reference.json"); +const resultBackRef = require("./data/back-reference.openapi3.json"); + describe("Examples", () => { test("csdl-16.1", () => { const openapi = lib.csdl2openapi(example1, { diagram: true }); @@ -111,6 +114,11 @@ describe("Examples", () => { const openapi = lib.csdl2openapi(example13, { url: "https://localhost/service-root" }); check(openapi, result13); }); + + test("back-reference", () => { + const openapi = lib.csdl2openapi(exampleBackRef, { diagram: false }); + check(openapi, resultBackRef); + }); }); describe("Edge cases", () => { diff --git a/test/lib/compile/data/back-reference.json b/test/lib/compile/data/back-reference.json new file mode 100644 index 0000000..ba41d19 --- /dev/null +++ b/test/lib/compile/data/back-reference.json @@ -0,0 +1,60 @@ +{ + "$Version": "4.01", + "$Reference": { + "https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Core.V1.json": { + "$Include": [ + { + "$Namespace": "Org.OData.Core.V1", + "$Alias": "Core", + "@Core.DefaultNamespace": true + } + ] + }, + "https://oasis-tcs.github.io/odata-vocabularies/vocabularies/Org.OData.Capabilities.V1.json": { + "$Include": [ + { + "$Namespace": "Org.OData.Capabilities.V1", + "$Alias": "Capabilities" + } + ] + } + }, + "$EntityContainer": "UsageService.Container", + "UsageService": { + "$Alias": "self", + "UsageRecords": { + "$Kind": "EntityType", + "$Key": ["ID"], + "ID": { "$Type": "Edm.Guid" }, + "description": { "$Nullable": true }, + "customReferences": { + "$Kind": "NavigationProperty", + "$Collection": true, + "$Type": "self.UsageRecordCustomReferences", + "$ContainsTarget": true, + "$Partner": "usageRecord" + } + }, + "UsageRecordCustomReferences": { + "$Kind": "EntityType", + "$Key": ["ID"], + "ID": { "$Type": "Edm.Guid" }, + "type": { "$Nullable": true }, + "value": { "$Nullable": true }, + "usageRecord": { + "$Kind": "NavigationProperty", + "$Type": "self.UsageRecords", + "$Nullable": false, + "$Partner": "customReferences" + } + }, + "Container": { + "$Kind": "EntityContainer", + "@Core.Description": "Usage Record Service", + "UsageRecords": { + "$Collection": true, + "$Type": "self.UsageRecords" + } + } + } +} diff --git a/test/lib/compile/data/back-reference.openapi3.json b/test/lib/compile/data/back-reference.openapi3.json new file mode 100644 index 0000000..66d7602 --- /dev/null +++ b/test/lib/compile/data/back-reference.openapi3.json @@ -0,0 +1,911 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Use the title annotation on your CDS service to provide a meaningful title.", + "description": "Usage Record Service", + "version": "" + }, + "x-sap-api-type": "ODATAV4", + "x-odata-version": "4.01", + "x-sap-shortText": "Usage Record Service", + "servers": [ + { + "url": "https://localhost/service-root" + } + ], + "tags": [ + { + "name": "Usage Records" + } + ], + "paths": { + "/$batch": { + "post": { + "summary": "Sends a group of requests", + "description": "Group multiple requests into a single request payload, see [Batch Requests](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_BatchRequests).\n\n*Please note that \"Try it out\" is not supported for this request.*", + "tags": [ + "Batch Requests" + ], + "requestBody": { + "required": true, + "description": "Batch request", + "content": { + "multipart/mixed;boundary=request-separator": { + "schema": { + "type": "string" + }, + "example": "--request-separator\nContent-Type: application/http\nContent-Transfer-Encoding: binary\n\nGET UsageRecords HTTP/1.1\nAccept: application/json\n\n\n--request-separator--" + } + } + }, + "responses": { + "200": { + "description": "Batch response", + "content": { + "multipart/mixed": { + "schema": { + "type": "string" + }, + "example": "--response-separator\nContent-Type: application/http\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{...}\n--response-separator--" + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, + "/UsageRecords": { + "get": { + "summary": "Retrieves a list of usage records.", + "tags": [ + "Usage Records" + ], + "parameters": [ + { + "$ref": "#/components/parameters/top" + }, + { + "$ref": "#/components/parameters/skip" + }, + { + "$ref": "#/components/parameters/search" + }, + { + "name": "$filter", + "description": "Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/count" + }, + { + "name": "$orderby", + "description": "Order items by property values, see [Sorting](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionorderby)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "ID desc", + "description", + "description desc" + ] + } + } + }, + { + "name": "$select", + "description": "Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "description" + ] + } + } + }, + { + "name": "$expand", + "description": "The value of $expand query option is a comma-separated list of navigation property names, stream property names, or $value indicating the stream content of a media-entity. The corresponding related entities and stream values will be represented inline, see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "*", + "customReferences" + ] + } + } + } + ], + "responses": { + "200": { + "description": "Retrieved usage records", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Collection of UsageRecords", + "properties": { + "@count": { + "$ref": "#/components/schemas/count" + }, + "value": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageService.UsageRecords" + } + } + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "post": { + "summary": "Creates a single usage record.", + "tags": [ + "Usage Records" + ], + "requestBody": { + "description": "New usage record", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecords-create" + } + } + } + }, + "responses": { + "201": { + "description": "Created usage record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecords" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, + "/UsageRecords({ID})": { + "parameters": [ + { + "description": "key: ID", + "in": "path", + "name": "ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + } + ], + "get": { + "summary": "Retrieves a single usage record.", + "tags": [ + "Usage Records" + ], + "parameters": [ + { + "name": "$select", + "description": "Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "description" + ] + } + } + }, + { + "name": "$expand", + "description": "The value of $expand query option is a comma-separated list of navigation property names, stream property names, or $value indicating the stream content of a media-entity. The corresponding related entities and stream values will be represented inline, see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "*", + "customReferences" + ] + } + } + } + ], + "responses": { + "200": { + "description": "Retrieved usage record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecords" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "patch": { + "summary": "Changes a single usage record.", + "tags": [ + "Usage Records" + ], + "requestBody": { + "description": "New property values", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecords-update" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "delete": { + "summary": "Deletes a single usage record.", + "tags": [ + "Usage Records" + ], + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, + "/UsageRecords({ID})/customReferences": { + "parameters": [ + { + "description": "key: ID", + "in": "path", + "name": "ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + } + ], + "get": { + "summary": "Retrieves a list of custom references of a usage record.", + "tags": [ + "Usage Records" + ], + "parameters": [ + { + "$ref": "#/components/parameters/top" + }, + { + "$ref": "#/components/parameters/skip" + }, + { + "$ref": "#/components/parameters/search" + }, + { + "name": "$filter", + "description": "Filter items by property values, see [Filtering](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter)", + "in": "query", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/count" + }, + { + "name": "$orderby", + "description": "Order items by property values, see [Sorting](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionorderby)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "ID desc", + "type", + "type desc", + "value", + "value desc" + ] + } + } + }, + { + "name": "$select", + "description": "Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "type", + "value" + ] + } + } + }, + { + "name": "$expand", + "description": "The value of $expand query option is a comma-separated list of navigation property names, stream property names, or $value indicating the stream content of a media-entity. The corresponding related entities and stream values will be represented inline, see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "*", + "usageRecord" + ] + } + } + } + ], + "responses": { + "200": { + "description": "Retrieved custom references", + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Collection of UsageRecordCustomReferences", + "properties": { + "@count": { + "$ref": "#/components/schemas/count" + }, + "value": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences" + } + } + } + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "post": { + "summary": "Creates a single custom reference of a usage record.", + "tags": [ + "Usage Records" + ], + "requestBody": { + "description": "New custom reference", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences-create" + } + } + } + }, + "responses": { + "201": { + "description": "Created custom reference", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, + "/UsageRecords({ID})/customReferences({ID_1})": { + "parameters": [ + { + "description": "key: ID", + "in": "path", + "name": "ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + }, + { + "description": "key: ID", + "in": "path", + "name": "ID_1", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + } + ], + "get": { + "summary": "Retrieves a single custom reference of a usage record.", + "tags": [ + "Usage Records" + ], + "parameters": [ + { + "name": "$select", + "description": "Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "type", + "value" + ] + } + } + }, + { + "name": "$expand", + "description": "The value of $expand query option is a comma-separated list of navigation property names, stream property names, or $value indicating the stream content of a media-entity. The corresponding related entities and stream values will be represented inline, see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "*", + "usageRecord" + ] + } + } + } + ], + "responses": { + "200": { + "description": "Retrieved custom reference", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "patch": { + "summary": "Changes a single custom reference of a usage record.", + "tags": [ + "Usage Records" + ], + "requestBody": { + "description": "New property values", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences-update" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "delete": { + "summary": "Deletes a single custom reference of a usage record.", + "tags": [ + "Usage Records" + ], + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, + "/UsageRecords({ID})/customReferences({ID_1})/usageRecord": { + "parameters": [ + { + "description": "key: ID", + "in": "path", + "name": "ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + }, + { + "description": "key: ID", + "in": "path", + "name": "ID_1", + "required": true, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + } + } + ], + "get": { + "summary": "Retrieves usage record of a usage record.", + "tags": [ + "Usage Records" + ], + "parameters": [ + { + "name": "$select", + "description": "Select properties to be returned, see [Select](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionselect)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "ID", + "description" + ] + } + } + }, + { + "name": "$expand", + "description": "The value of $expand query option is a comma-separated list of navigation property names, stream property names, or $value indicating the stream content of a media-entity. The corresponding related entities and stream values will be represented inline, see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionexpand)", + "in": "query", + "explode": false, + "schema": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "*", + "customReferences" + ] + } + } + } + ], + "responses": { + "200": { + "description": "Retrieved usage record", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsageService.UsageRecords" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + } + }, + "components": { + "schemas": { + "UsageService.UsageRecordCustomReferences": { + "title": "UsageRecordCustomReferences", + "type": "object", + "properties": { + "ID": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "type": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + } + }, + "UsageService.UsageRecordCustomReferences-create": { + "title": "UsageRecordCustomReferences (for create)", + "type": "object", + "properties": { + "ID": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "type": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + }, + "required": [ + "ID" + ] + }, + "UsageService.UsageRecordCustomReferences-update": { + "title": "UsageRecordCustomReferences (for update)", + "type": "object", + "properties": { + "type": { + "type": "string", + "nullable": true + }, + "value": { + "type": "string", + "nullable": true + } + } + }, + "UsageService.UsageRecords": { + "title": "UsageRecords", + "type": "object", + "properties": { + "ID": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "description": { + "type": "string", + "nullable": true + }, + "customReferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences" + } + }, + "customReferences@count": { + "$ref": "#/components/schemas/count" + } + } + }, + "UsageService.UsageRecords-create": { + "title": "UsageRecords (for create)", + "type": "object", + "properties": { + "ID": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "description": { + "type": "string", + "nullable": true + }, + "customReferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences-create" + } + } + }, + "required": [ + "ID" + ] + }, + "UsageService.UsageRecords-update": { + "title": "UsageRecords (for update)", + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true + }, + "customReferences": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UsageService.UsageRecordCustomReferences-create" + } + } + } + }, + "count": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ], + "description": "The number of entities in the collection. Available when using the [$count](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptioncount) query option." + }, + "error": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "target": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "target": { + "type": "string" + } + } + } + }, + "innererror": { + "type": "object", + "description": "The structure of this object is service-specific" + } + } + } + } + } + }, + "parameters": { + "top": { + "name": "$top", + "in": "query", + "description": "Show only the first n items, see [Paging - Top](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptiontop)", + "schema": { + "type": "integer", + "minimum": 0 + }, + "example": 50 + }, + "skip": { + "name": "$skip", + "in": "query", + "description": "Skip the first n items, see [Paging - Skip](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionskip)", + "schema": { + "type": "integer", + "minimum": 0 + } + }, + "count": { + "name": "$count", + "in": "query", + "description": "Include count of items, see [Count](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptioncount)", + "schema": { + "type": "boolean" + } + }, + "search": { + "name": "$search", + "in": "query", + "description": "Search items by search phrases, see [Searching](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionsearch)", + "schema": { + "type": "string" + } + } + }, + "responses": { + "error": { + "description": "Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/error" + } + } + } + } + } + } +} \ No newline at end of file From 0c8a11f9eeef645674b138f0c5081f3003bbf3ce Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:04:19 +0200 Subject: [PATCH 2/8] Introduce helpers --- lib/compile/csdl2openapi.js | 23 +++++------------- lib/compile/property-util.js | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 lib/compile/property-util.js diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 3bf4efc..9468533 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -5,7 +5,7 @@ const cds = require('@sap/cds'); const { CSDLMeta, nameParts, isIdentifier } = require('./csdl'); const { camelCaseToWords: splitName } = require('./string-util'); const { Diagram } = require('./diagram') -const pluralize = require('pluralize') +const propertyUtil = require('./property-util');const pluralize = require('pluralize') const DEBUG = cds.debug('openapi'); // Initialize cds.debug with the 'openapi' //TODO @@ -109,6 +109,7 @@ module.exports.csdl2openapi = function ( csdl = structuredClone(csdl) csdl.$Version = odataVersion ? odataVersion : '4.01' const meta = new CSDLMeta(csdl) + const { isNavBackReference, isNavWritable, isScalarReadOnly, isScalarWritable } = propertyUtil(meta); serviceRoot = serviceRoot ?? (`${scheme}://${host}${basePath}`); const queryOptionPrefix = csdl.$Version <= '4.01' ? '$' : ''; const typesToInline = {}; // filled in schema() and used in inlineTypes() @@ -2109,16 +2110,6 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot schemas[`${qualifier}.${name}`] = s; } - /** - * Construct Schema Objects for a structured type - * @param {object} options - * @param {*} options.schemas Map of Schema Objects to augment - * @param {string} options.qualifier Qualifier for structured type - * @param {string} options.name Simple name of structured type - * @param {*} options.type Structured type - * @param {string} options.suffix Suffix for read/create/update - * @return {*} Map of Schemas Objects - */ function schemasForStructuredType({ schemas, qualifier, name, type, suffix }) { const schemaName = `${qualifier}.${name}${suffix}`; const baseName = `${qualifier}.${name}`; @@ -2142,14 +2133,12 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const property = properties[iName]; if ((Object.prototype.hasOwnProperty.call(property, '@Common.FieldControl')) && property['@Common.FieldControl'] === 'Mandatory') { required.push(iName) } if (property.$Kind == 'NavigationProperty') { - const isBackReference = property.$Partner - && meta.modelElement(property.$Type)?.[property.$Partner]?.$ContainsTarget; - if (!isBackReference) { + if (!isNavBackReference(property)) { if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); if (property.$Collection && suffix === "" && isCount === true) { schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); } - if (property[meta.voc.Core.Permissions] != "Read" && !property[meta.voc.Core.Computed] && (property.$ContainsTarget || property.$OnDelete === 'Cascade')) { + if (isNavWritable(property)) { if (suffix === SUFFIX.create) { schemaProperties[iName] = getSchema(property, SUFFIX.create); } @@ -2160,11 +2149,11 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } } else { if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); - if (property[meta.voc.Core.Permissions] === "Read" || property[meta.voc.Core.Computed] || property[meta.voc.Core.ComputedDefaultValue]) { + if (isScalarReadOnly(property)) { const index = required.indexOf(iName); if (index != -1) required.splice(index, 1); } - if (!(property[meta.voc.Core.Permissions] === "Read" || property[meta.voc.Core.Computed])) { + if (isScalarWritable(property)) { if (suffix === SUFFIX.create) schemaProperties[iName] = getSchema(property, SUFFIX.create); if (suffix === SUFFIX.update && !isKey[iName] && !property[meta.voc.Core.Immutable]) diff --git a/lib/compile/property-util.js b/lib/compile/property-util.js new file mode 100644 index 0000000..bc42387 --- /dev/null +++ b/lib/compile/property-util.js @@ -0,0 +1,45 @@ +/** @typedef {import('./csdl').CSDLMeta} CSDLMeta */ + +/** + * @typedef {object} CSDLProperty + * @property {string} [$Kind] + * @property {string} [$Type] + * @property {string} [$Partner] + * @property {boolean} [$ContainsTarget] + * @property {boolean} [$Collection] + * @property {string} [$OnDelete] + */ + +/** + * @typedef {object} PropertyPredicates + * @property {(property: CSDLProperty) => boolean} isNavBackReference - True when the nav property is the back-reference side of a composition (child -> parent) + * @property {(property: CSDLProperty) => boolean} isNavWritable - True when the nav property may be written by the client (create/update) + * @property {(property: CSDLProperty) => boolean} isScalarReadOnly - True when the scalar property is read-only or computed (must be removed from required) + * @property {(property: CSDLProperty) => boolean} isScalarWritable - True when the scalar property may be written by the client (create/update) + */ + +/** + * Returns property predicate functions bound to the given CSDL metadata instance. + * @param {CSDLMeta} meta + * @returns {PropertyPredicates} + */ +module.exports = (meta) => ({ + isNavBackReference: (property) => + !!property.$Partner + && !!property.$Type + && !!meta.modelElement(property.$Type)?.[property.$Partner]?.$ContainsTarget, + + isNavWritable: (property) => + property[meta.voc.Core.Permissions] != "Read" + && !property[meta.voc.Core.Computed] + && (property.$ContainsTarget || property.$OnDelete === 'Cascade'), + + isScalarReadOnly: (property) => + property[meta.voc.Core.Permissions] === "Read" + || property[meta.voc.Core.Computed] + || property[meta.voc.Core.ComputedDefaultValue], + + isScalarWritable: (property) => + property[meta.voc.Core.Permissions] !== "Read" + && !property[meta.voc.Core.Computed], +}); From 445ba09170da71937403b522e4d62a5be2596728 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:08:37 +0200 Subject: [PATCH 3/8] Simplify structure --- lib/compile/csdl2openapi.js | 42 +++++++++++++------------------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 9468533..6fa7bdc 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2127,38 +2127,24 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const expandRestrictions = type[meta.voc.Capabilities.ExpandRestrictions] ?? {}; const nonExpandableProperties = expandRestrictions.NonExpandableProperties ?? []; Object.keys(properties).forEach(iName => { - if (nonExpandableProperties.includes(iName)) { - return; - } + if (nonExpandableProperties.includes(iName)) return; + const property = properties[iName]; - if ((Object.prototype.hasOwnProperty.call(property, '@Common.FieldControl')) && property['@Common.FieldControl'] === 'Mandatory') { required.push(iName) } + + if (Object.prototype.hasOwnProperty.call(property, '@Common.FieldControl') && property['@Common.FieldControl'] === 'Mandatory') required.push(iName); + if (property.$Kind == 'NavigationProperty') { - if (!isNavBackReference(property)) { - if (suffix === SUFFIX.read) schemaProperties[iName] = getSchema(property); - if (property.$Collection && suffix === "" && isCount === true) { - schemaProperties[`${iName}@${csdl.$Version === '4.0' ? 'odata.' : ''}count`] = ref('count'); - } - if (isNavWritable(property)) { - if (suffix === SUFFIX.create) { - schemaProperties[iName] = getSchema(property, SUFFIX.create); - } - if (suffix === SUFFIX.update && !property[meta.voc.Core.Immutable]) { - schemaProperties[iName] = getSchema(property, SUFFIX.create); - } - } - } + 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 index = required.indexOf(iName); - if (index != -1) required.splice(index, 1); - } - if (isScalarWritable(property)) { - if (suffix === SUFFIX.create) - schemaProperties[iName] = getSchema(property, SUFFIX.create); - if (suffix === SUFFIX.update && !isKey[iName] && !property[meta.voc.Core.Immutable]) - schemaProperties[iName] = getSchema(property, SUFFIX.update); - } + if (isScalarReadOnly(property)) required.splice(required.indexOf(iName), 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); } }); From 400c6ed532697e4de743e0bf79736a36979c63e5 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:09:58 +0200 Subject: [PATCH 4/8] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7be42e..9107986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Removed ### Fixed - Entities that are transitively autoexposed and should still be considered readonly, do not generate documentation for write endpoints anymore +- Back-reference navigation properties on child entities in compositions (the generated `parent` nav pointing back up) are no longer emitted in the OpenAPI read schema ### Security ## [1.6.0] - 2026-08-04 From e65f8affe83d13590f3906c115c8b0fb90f4a4bc Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:13:39 +0200 Subject: [PATCH 5/8] Import --- lib/compile/property-util.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/compile/property-util.js b/lib/compile/property-util.js index bc42387..04f5a17 100644 --- a/lib/compile/property-util.js +++ b/lib/compile/property-util.js @@ -1,5 +1,3 @@ -/** @typedef {import('./csdl').CSDLMeta} CSDLMeta */ - /** * @typedef {object} CSDLProperty * @property {string} [$Kind] @@ -20,7 +18,7 @@ /** * Returns property predicate functions bound to the given CSDL metadata instance. - * @param {CSDLMeta} meta + * @param {InstanceType} meta * @returns {PropertyPredicates} */ module.exports = (meta) => ({ From 702f79ab2e57da64662f0037a2219a10618ddbf2 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:17:39 +0200 Subject: [PATCH 6/8] Linebreak --- lib/compile/csdl2openapi.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 6fa7bdc..9870366 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -5,7 +5,8 @@ const cds = require('@sap/cds'); const { CSDLMeta, nameParts, isIdentifier } = require('./csdl'); const { camelCaseToWords: splitName } = require('./string-util'); const { Diagram } = require('./diagram') -const propertyUtil = require('./property-util');const pluralize = require('pluralize') +const propertyUtil = require('./property-util'); +const pluralize = require('pluralize') const DEBUG = cds.debug('openapi'); // Initialize cds.debug with the 'openapi' //TODO From 3e7808b99f36ed01cbac788ff6eccc0d905b4982 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:20:21 +0200 Subject: [PATCH 7/8] Add missing types back --- lib/compile/csdl2openapi.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 9870366..d6e3fc9 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2111,6 +2111,16 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot schemas[`${qualifier}.${name}`] = s; } + /** + * Construct Schema Objects for a structured type + * @param {object} options + * @param {*} options.schemas Map of Schema Objects to augment + * @param {string} options.qualifier Qualifier for structured type + * @param {string} options.name Simple name of structured type + * @param {*} options.type Structured type + * @param {string} options.suffix Suffix for read/create/update + * @return {*} Map of Schemas Objects + */ function schemasForStructuredType({ schemas, qualifier, name, type, suffix }) { const schemaName = `${qualifier}.${name}${suffix}`; const baseName = `${qualifier}.${name}`; From 567bca8f06eaf49b100e7f8babd60233bb0227e4 Mon Sep 17 00:00:00 2001 From: Daniel O'Grady Date: Wed, 2 Sep 2026 15:26:18 +0200 Subject: [PATCH 8/8] More helpers --- lib/compile/csdl2openapi.js | 6 +++--- lib/compile/property-util.js | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index d6e3fc9..d3c8fdf 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -110,7 +110,7 @@ module.exports.csdl2openapi = function ( csdl = structuredClone(csdl) csdl.$Version = odataVersion ? odataVersion : '4.01' const meta = new CSDLMeta(csdl) - const { isNavBackReference, isNavWritable, isScalarReadOnly, isScalarWritable } = propertyUtil(meta); + const { isNavBackReference, isNavWritable, isScalarReadOnly, isScalarWritable, isMandatoryField, isDeltaSupported } = propertyUtil(meta); serviceRoot = serviceRoot ?? (`${scheme}://${host}${basePath}`); const queryOptionPrefix = csdl.$Version <= '4.01' ? '$' : ''; const typesToInline = {}; // filled in schema() and used in inlineTypes() @@ -786,7 +786,7 @@ module.exports.csdl2openapi = function ( isCount: !countRestrictions }) }; - const deltaSupported = element[meta.voc.Capabilities.ChangeTracking] && element[meta.voc.Capabilities.ChangeTracking].Supported; + const deltaSupported = isDeltaSupported(element); if (!byKey && deltaSupported) { // @ts-expect-error - set above operation.responses[200].content['application/json'].schema.properties['@odata.deltaLink'] = { @@ -2142,7 +2142,7 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot const property = properties[iName]; - if (Object.prototype.hasOwnProperty.call(property, '@Common.FieldControl') && property['@Common.FieldControl'] === 'Mandatory') required.push(iName); + if (isMandatoryField(property)) required.push(iName); if (property.$Kind == 'NavigationProperty') { if (isNavBackReference(property)) return; diff --git a/lib/compile/property-util.js b/lib/compile/property-util.js index 04f5a17..a014473 100644 --- a/lib/compile/property-util.js +++ b/lib/compile/property-util.js @@ -14,6 +14,8 @@ * @property {(property: CSDLProperty) => boolean} isNavWritable - True when the nav property may be written by the client (create/update) * @property {(property: CSDLProperty) => boolean} isScalarReadOnly - True when the scalar property is read-only or computed (must be removed from required) * @property {(property: CSDLProperty) => boolean} isScalarWritable - True when the scalar property may be written by the client (create/update) + * @property {(property: CSDLProperty) => boolean} isMandatoryField - True when the property is annotated as a mandatory field control + * @property {(element: CSDLProperty) => boolean} isDeltaSupported - True when the element has change tracking with delta support enabled */ /** @@ -28,7 +30,7 @@ module.exports = (meta) => ({ && !!meta.modelElement(property.$Type)?.[property.$Partner]?.$ContainsTarget, isNavWritable: (property) => - property[meta.voc.Core.Permissions] != "Read" + property[meta.voc.Core.Permissions] !== "Read" && !property[meta.voc.Core.Computed] && (property.$ContainsTarget || property.$OnDelete === 'Cascade'), @@ -40,4 +42,10 @@ module.exports = (meta) => ({ isScalarWritable: (property) => property[meta.voc.Core.Permissions] !== "Read" && !property[meta.voc.Core.Computed], + + isMandatoryField: (property) => + property['@Common.FieldControl'] === 'Mandatory', + + isDeltaSupported: (element) => + !!element[meta.voc.Capabilities.ChangeTracking]?.Supported, });