diff --git a/CHANGELOG.md b/CHANGELOG.md index c5dd38a..58af4bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,10 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ### Deprecated ### Removed ### Fixed -- Collection responses now use `@odata.count` and `@odata.nextLink` instead of the OData 4.01 short forms `@count` and `@nextLink`, matching what CAP actually returns at runtime. Navigation property expansion count is likewise corrected from `nav@count` to `nav@odata.count`. +- REST collection responses now accurately document both response shapes using `oneOf`: a bare array (returned without `$count`) and an object with `count` + `value` (returned with `$count=true`). Previously the spec always described an object wrapper that didn't match the bare-array response. +- REST collection responses now use `count` (not `@count`) as the count property name, matching the actual CAP REST runtime response. +- Navigation property expansion count is corrected from `nav@count` to `nav@odata.count` for both OData and REST protocols. +- OData collection responses now use `@odata.count` and `@odata.nextLink` instead of the OData 4.01 short forms `@count` and `@nextLink`, matching what CAP actually returns at runtime. - Compositions of `@readonly` entities no longer advertise write operations (POST/PATCH/DELETE) on their navigation paths ### Security diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index 2050715..57944c2 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -2099,9 +2099,10 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot description } }; - const countProperty = { '@odata.count': ref('count') }; + const isRest = !isODataProtocol(protocol); + const countProperty = isRest ? { count: ref('count') } : { '@odata.count': ref('count') }; const nextLinkKey = '@odata.nextLink'; - const nextLinkProperty = { + const nextLinkProperty = isRest ? {} : { [nextLinkKey]: { type: 'string', format: 'uri', @@ -2115,16 +2116,37 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot }; if (type.$Collection) { - // @ts-ignore - not undefined, we set it above - r[code].content['application/json'].schema = { - type: 'object', - title: `Collection of ${nameParts(type.$Type ? type.$Type : 'Edm.String').name}`, - properties: { - ...(isCount && countProperty), - value: s, - ...nextLinkProperty - } - }; + const title = `Collection of ${nameParts(type.$Type ? type.$Type : 'Edm.String').name}`; + if (isRest) { + // CAP REST returns two distinct shapes depending on whether $count=true is passed: + // a bare array without it, or {count, value} with it. OpenAPI cannot condition a + // response schema on a query parameter value, so oneOf is used to document both. + // @ts-ignore - not undefined, we set it above + r[code].content['application/json'].schema = { + title, + oneOf: [ + s, + { + type: 'object', + properties: { + ...(isCount && countProperty), + value: s, + } + } + ] + }; + } else { + // @ts-ignore - not undefined, we set it above + r[code].content['application/json'].schema = { + type: 'object', + title, + properties: { + ...(isCount && countProperty), + value: s, + ...nextLinkProperty + } + }; + } } else if ( diff --git a/lib/compile/types.d.ts b/lib/compile/types.d.ts index 7bad69e..949819f 100644 --- a/lib/compile/types.d.ts +++ b/lib/compile/types.d.ts @@ -48,7 +48,8 @@ type SingleSchema = (StringSchema | NumberSchema | BooleanSchema | ArraySchema | type AnyOf = { anyOf: Schema[] } & Meta type AllOf = { allOf: Schema[] } & Meta -type MultiSchema = AnyOf | AllOf +type OneOf = { oneOf: Schema[] } & Meta +type MultiSchema = AnyOf | AllOf | OneOf export type Schema = (SingleSchema | MultiSchema) @@ -113,7 +114,7 @@ type Response = { required?: boolean, description?: string, content?: {[mime in MIMEType]?: { - schema?: SingleSchema & { title?: string }, + schema?: Schema & { title?: string }, example?: string }}, responses?: Record, diff --git a/test/lib/compile/csdl2openapi.test.js b/test/lib/compile/csdl2openapi.test.js index 577b2e5..f7eba5a 100644 --- a/test/lib/compile/csdl2openapi.test.js +++ b/test/lib/compile/csdl2openapi.test.js @@ -3659,6 +3659,66 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot }); }); +describe("REST protocol", () => { + const csdl = { + $EntityContainer: "this.Container", + this: { + Book: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Int32" }, + title: { $Nullable: true }, + authors: { $Kind: "NavigationProperty", $Type: "this.Author", $Collection: true }, + }, + Author: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Int32" }, + }, + Container: { + Books: { $Type: "this.Book", $Collection: true }, + }, + }, + }; + + test("REST collection response uses oneOf: bare array OR object with count+value", () => { + const openapi = lib.csdl2openapi(csdl, { protocol: "rest" }); + const schema = openapi.paths["/Books"].get.responses[200].content["application/json"].schema; + assert.ok(Array.isArray(schema.oneOf), "REST collection response schema must use oneOf"); + assert.strictEqual(schema.oneOf.length, 2, "oneOf must have exactly 2 variants"); + + const [arrayVariant, objectVariant] = schema.oneOf; + assert.strictEqual(arrayVariant.type, "array", "first variant must be bare array"); + + assert.strictEqual(objectVariant.type, "object", "second variant must be object"); + assert.ok("value" in objectVariant.properties, "object variant must have value property"); + assert.ok("count" in objectVariant.properties, "object variant must use 'count' (not '@count') for REST"); + assert.ok(!("@count" in objectVariant.properties), "object variant must not use '@count'"); + assert.ok(!("@odata.count" in objectVariant.properties), "object variant must not use '@odata.count'"); + }); + + test("REST collection response has no @odata.nextLink", () => { + const openapi = lib.csdl2openapi(csdl, { protocol: "rest" }); + const schema = openapi.paths["/Books"].get.responses[200].content["application/json"].schema; + const schemaStr = JSON.stringify(schema); + assert.ok(!schemaStr.includes("nextLink"), "REST collection response must not include nextLink"); + }); + + test("REST nav-property expansion uses @odata.count", () => { + const openapi = lib.csdl2openapi(csdl, { protocol: "rest" }); + const bookSchema = openapi.components.schemas["this.Book"].properties; + assert.ok("authors@odata.count" in bookSchema, "nav-property expansion must use authors@odata.count"); + assert.ok(!("authors@count" in bookSchema), "must not emit bare authors@count"); + }); + + test("OData collection response is unaffected by REST changes", () => { + const openapi = lib.csdl2openapi(csdl, {}); + const schema = openapi.paths["/Books"].get.responses[200].content["application/json"].schema; + assert.ok(!schema.oneOf, "OData collection response must not use oneOf"); + assert.strictEqual(schema.type, "object", "OData collection response must be an object"); + }); +}); + function check(actual, expected) { assert.deepStrictEqual(paths(actual), paths(expected), "Paths"); assert.deepStrictEqual(