diff --git a/CHANGELOG.md b/CHANGELOG.md index def3f97..9e42a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/). ## [Unreleased] ### Added +- Generate media stream paths (GET and PUT) for media entities and `Edm.Stream` properties ### Changed ### Deprecated ### Removed diff --git a/lib/compile/csdl.js b/lib/compile/csdl.js index ae0b5eb..0b113d5 100644 --- a/lib/compile/csdl.js +++ b/lib/compile/csdl.js @@ -12,7 +12,7 @@ const CDS_TERMS = Object.freeze({ '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'], + 'MediaType', 'OptionalParameter', 'Permissions', 'SchemaVersion'], JSON: ['Schema'], Validation: ['AllowedValues', 'Exclusive', 'Maximum', 'Minimum', 'Pattern'] }) diff --git a/lib/compile/csdl2openapi.js b/lib/compile/csdl2openapi.js index d5f57ae..0e7b9f3 100644 --- a/lib/compile/csdl2openapi.js +++ b/lib/compile/csdl2openapi.js @@ -653,6 +653,7 @@ module.exports.csdl2openapi = function ( level, navigationPrefix: navigationPath }); + pathItemsForMediaStream({ paths, prefix: path, prefixParameters: parameters, type, name, sourceName }); if (Object.keys(pathItem).filter((i) => i !== "parameters").length === 0) delete paths[path]; @@ -1300,6 +1301,91 @@ see [Expand](http://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-prot } } + /** + * Add path and Path Item Object for media stream access ($value) and stream properties of an entity type + * @param {object} options + * @param {object} options.paths The Paths Object to augment + * @param {string} options.prefix Prefix for path (the key-qualified entity path) + * @param {Array} options.prefixParameters Parameter Objects for prefix + * @param {object} options.type Entity type object + * @param {string} options.name Name of the entity set + * @param {string} options.sourceName Name of path source + */ + function pathItemsForMediaStream({ paths, prefix, prefixParameters, type, name, sourceName }) { + if (type.$HasStream) { + const mediaTypes = type[meta.voc.Core.AcceptableMediaTypes]?.map(t => t['$EnumMember'] ?? t) ?? []; + const contentTypes = mediaTypes.length > 0 ? mediaTypes : ['*/*']; + const mediaContent = Object.fromEntries(contentTypes.map(ct => [ct, { schema: { type: 'string', format: 'binary' } }])); + const lname = splitName(name); + const valuePath = `${prefix}/$value`; + const pathItem = prefixParameters.length > 0 ? { parameters: prefixParameters } : {}; + paths[valuePath] = pathItem; + + pathItem.get = { + summary: `Retrieves the media stream of a single ${pluralize.singular(lname)}.`, + tags: [normaliseTag(sourceName)], + responses: { + 200: { + description: `Retrieved ${pluralize.singular(lname)}`, + content: mediaContent + }, + '4XX': { $ref: '#/components/responses/error' } + } + }; + pathItem.put = { + summary: `Changes the media stream of a single ${pluralize.singular(lname)}.`, + tags: [normaliseTag(sourceName)], + requestBody: { + description: `New media stream for ${pluralize.singular(lname)}`, + required: true, + content: mediaContent + }, + responses: { + 204: { description: 'Success' }, + '4XX': { $ref: '#/components/responses/error' } + } + }; + } + + const properties = propertiesOfStructuredType(type); + Object.keys(properties).forEach(propName => { + const prop = properties[propName]; + if (prop.$Kind === 'NavigationProperty' || prop.$Type !== 'Edm.Stream') return; + const mediaTypeAnnotation = prop[meta.voc.Core.MediaType]; + const contentTypes = [typeof mediaTypeAnnotation === 'string' ? mediaTypeAnnotation : '*/*']; + const mediaContent = Object.fromEntries(contentTypes.map(ct => [ct, { schema: { type: 'string', format: 'binary' } }])); + const lname = splitName(name); + const propPath = `${prefix}/${propName}`; + const pathItem = prefixParameters.length > 0 ? { parameters: prefixParameters } : {}; + paths[propPath] = pathItem; + + pathItem.get = { + summary: `Retrieves ${splitName(propName)} of a single ${pluralize.singular(lname)}.`, + tags: [normaliseTag(sourceName)], + responses: { + 200: { + description: `Retrieved ${splitName(propName)}`, + content: mediaContent + }, + '4XX': { $ref: '#/components/responses/error' } + } + }; + pathItem.put = { + summary: `Changes ${splitName(propName)} of a single ${pluralize.singular(lname)}.`, + tags: [normaliseTag(sourceName)], + requestBody: { + description: `New value for ${splitName(propName)}`, + required: true, + content: mediaContent + }, + responses: { + 204: { description: 'Success' }, + '4XX': { $ref: '#/components/responses/error' } + } + }; + }); + } + /** * Add paths and Path Item Objects for navigation segments * @param {object} options diff --git a/test/lib/compile/csdl2openapi.test.js b/test/lib/compile/csdl2openapi.test.js index a830567..eec0126 100644 --- a/test/lib/compile/csdl2openapi.test.js +++ b/test/lib/compile/csdl2openapi.test.js @@ -2900,6 +2900,155 @@ it("Error Logging when name and title are missing", () => { console.log("Error handling executed successfully"); }); +describe("Media stream paths", () => { + it("generates /$value GET and PUT for media entity ($HasStream)", () => { + const csdl = { + $Version: "4.0", + $EntityContainer: "this.Container", + this: { + doc: { + $Kind: "EntityType", + $HasStream: true, + $Key: ["ID"], + ID: { $Type: "Edm.Guid" }, + name: {}, + }, + Container: { + $Kind: "EntityContainer", + docs: { $Collection: true, $Type: "this.doc", $ContainsTarget: true }, + }, + }, + }; + const openapi = lib.csdl2openapi(csdl, {}); + const valuePath = "/docs({ID})/$value"; + assert.ok(openapi.paths[valuePath], `Expected path ${valuePath}`); + assert.ok(openapi.paths[valuePath].get, "Expected GET on $value"); + assert.ok(openapi.paths[valuePath].put, "Expected PUT on $value"); + assert.deepStrictEqual( + Object.keys(openapi.paths[valuePath].get.responses[200].content), + ["*/*"], + "Default content type is */*" + ); + }); + + it("uses @Core.AcceptableMediaTypes for content type in /$value", () => { + const csdl = { + $Version: "4.0", + $EntityContainer: "this.Container", + $Reference: { + dummy: { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] }, + }, + this: { + doc: { + $Kind: "EntityType", + $HasStream: true, + "@Core.AcceptableMediaTypes": ["application/pdf", "image/png"], + $Key: ["ID"], + ID: { $Type: "Edm.Guid" }, + }, + Container: { + $Kind: "EntityContainer", + docs: { $Collection: true, $Type: "this.doc", $ContainsTarget: true }, + }, + }, + }; + const openapi = lib.csdl2openapi(csdl, {}); + const valuePath = "/docs({ID})/$value"; + assert.ok(openapi.paths[valuePath], `Expected path ${valuePath}`); + const contentKeys = Object.keys(openapi.paths[valuePath].get.responses[200].content); + assert.deepStrictEqual(contentKeys, ["application/pdf", "image/png"]); + }); + + it("generates GET and PUT for Edm.Stream property", () => { + const csdl = { + $Version: "4.0", + $EntityContainer: "this.Container", + this: { + doc: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Guid" }, + content: { $Type: "Edm.Stream" }, + }, + Container: { + $Kind: "EntityContainer", + docs: { $Collection: true, $Type: "this.doc", $ContainsTarget: true }, + }, + }, + }; + const openapi = lib.csdl2openapi(csdl, {}); + const propPath = "/docs({ID})/content"; + assert.ok(openapi.paths[propPath], `Expected path ${propPath}`); + assert.ok(openapi.paths[propPath].get, "Expected GET on stream property"); + assert.ok(openapi.paths[propPath].put, "Expected PUT on stream property"); + assert.deepStrictEqual( + Object.keys(openapi.paths[propPath].put.requestBody.content), + ["*/*"], + "Default content type is */*" + ); + }); + + it("uses @Core.MediaType annotation on Edm.Stream property", () => { + const csdl = { + $Version: "4.0", + $EntityContainer: "this.Container", + $Reference: { + dummy: { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] }, + }, + this: { + doc: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Guid" }, + content: { $Type: "Edm.Stream", "@Core.MediaType": "application/pdf" }, + }, + Container: { + $Kind: "EntityContainer", + docs: { $Collection: true, $Type: "this.doc", $ContainsTarget: true }, + }, + }, + }; + const openapi = lib.csdl2openapi(csdl, {}); + const propPath = "/docs({ID})/content"; + assert.ok(openapi.paths[propPath], `Expected path ${propPath}`); + assert.deepStrictEqual( + Object.keys(openapi.paths[propPath].get.responses[200].content), + ["application/pdf"] + ); + }); + + it("falls back to */* when @Core.MediaType is a path expression on Edm.Stream property", () => { + const csdl = { + $Version: "4.0", + $EntityContainer: "this.Container", + $Reference: { + dummy: { $Include: [{ $Namespace: "Org.OData.Core.V1", $Alias: "Core" }] }, + }, + this: { + doc: { + $Kind: "EntityType", + $Key: ["ID"], + ID: { $Type: "Edm.Guid" }, + content: { $Type: "Edm.Stream", "@Core.MediaType": { $Path: "mimeType" } }, + mimeType: { default: "application/octet-stream" }, + }, + Container: { + $Kind: "EntityContainer", + docs: { $Collection: true, $Type: "this.doc", $ContainsTarget: true }, + }, + }, + }; + const openapi = lib.csdl2openapi(csdl, {}); + const propPath = "/docs({ID})/content"; + assert.ok(openapi.paths[propPath], `Expected path ${propPath}`); + assert.deepStrictEqual( + Object.keys(openapi.paths[propPath].get.responses[200].content), + ["*/*"], + "Path expression MediaType falls back to */*" + ); + }); +}); + describe("CAP / CS01", () => { it("FilterRestrictions, NavigationRestrictions, and SortRestrictions", () => { const csdl = { diff --git a/test/lib/compile/data/TripPin.openapi3.json b/test/lib/compile/data/TripPin.openapi3.json index 90623be..31bb1a5 100644 --- a/test/lib/compile/data/TripPin.openapi3.json +++ b/test/lib/compile/data/TripPin.openapi3.json @@ -3231,6 +3231,76 @@ } } }, + "/Photos({Id})/$value": { + "parameters": [ + { + "description": "key: Id", + "in": "path", + "name": "Id", + "required": true, + "schema": { + "anyOf": [ + { + "type": "integer", + "format": "int64" + }, + { + "type": "string" + } + ], + "example": "42" + } + } + ], + "get": { + "summary": "Retrieves the media stream of a single photo.", + "tags": [ + "Photos" + ], + "responses": { + "200": { + "description": "Retrieved photo", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "put": { + "summary": "Changes the media stream of a single photo.", + "tags": [ + "Photos" + ], + "requestBody": { + "description": "New media stream for photo", + "required": true, + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, "/ResetDataSource": { "post": { "summary": "Invokes action ResetDataSource", diff --git a/test/lib/compile/data/csdl-16.1.openapi3.json b/test/lib/compile/data/csdl-16.1.openapi3.json index 0405b44..9daaa5b 100644 --- a/test/lib/compile/data/csdl-16.1.openapi3.json +++ b/test/lib/compile/data/csdl-16.1.openapi3.json @@ -1274,6 +1274,67 @@ } } }, + "/Products('{ID}')/$value": { + "parameters": [ + { + "description": "key: ID", + "in": "path", + "name": "ID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "get": { + "summary": "Retrieves the media stream of a single product.", + "tags": [ + "Products" + ], + "responses": { + "200": { + "description": "Retrieved product", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + }, + "put": { + "summary": "Changes the media stream of a single product.", + "tags": [ + "Products" + ], + "requestBody": { + "description": "New media stream for product", + "required": true, + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "204": { + "description": "Success" + }, + "4XX": { + "$ref": "#/components/responses/error" + } + } + } + }, "/Products('{ID}')/Category": { "parameters": [ {