From 7235fd2ead05b83775b7270fc246cfbdc2596bca Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 10 Dec 2021 14:49:39 -0800 Subject: [PATCH 1/4] Fix binary response openapi --- packages/openapi3/src/openapi.ts | 24 +++-- packages/openapi3/test/test-openapi-output.ts | 89 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 5943e8da379..55b68b5f975 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -293,18 +293,21 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } function emitResponseObject(responseModel: Type, statusCode: string = "200") { - let contentType = "application/json"; + let contentType: string | undefined; if ( responseModel.kind === "Model" && !responseModel.baseModel && responseModel.properties.size === 0 ) { - const schema = mapCadlTypeToOpenAPI(responseModel); + const isBinary = responseModel.name === "bytes"; + const schema = isBinary + ? { type: "string", format: "binary" } + : mapCadlTypeToOpenAPI(responseModel); if (schema) { currentEndpoint.responses[statusCode] = { description: getResponseDescription(responseModel, statusCode), content: { - [contentType]: { + [isBinary ? "application/octet-stream" : "application/json"]: { schema: schema, }, }, @@ -354,12 +357,18 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } } - contentEntry.schema = getSchemaOrRef(bodyModel); + const isBinary = bodyModel.kind === "Model" && bodyModel.name === "bytes"; + if (contentType === undefined && isBinary) { + contentType = "application/octet-stream"; + } + contentEntry.schema = isBinary + ? { type: "string", format: "binary" } + : getSchemaOrRef(bodyModel); const response: any = { description: getResponseDescription(responseModel, statusCode), content: { - [contentType]: contentEntry, + [contentType ?? "application/json"]: contentEntry, }, }; if (Object.keys(headers).length > 0) { @@ -517,7 +526,8 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } const bodyParam = bodyParams[0]; const bodyType = bodyParam.type; - const bodySchema = getSchemaOrRef(bodyType); + const isBinary = bodyType.kind === "Model" && bodyType.name === "bytes"; + const bodySchema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyType); const requestBody: any = { description: getDoc(program, bodyParam), @@ -529,7 +539,7 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { ); const contentTypes = contentTypeParam ? getContentTypes(contentTypeParam) - : ["application/json"]; + : [isBinary ? "application/octet-stream" : "application/json"]; for (let contentType of contentTypes) { const contentEntry: any = { schema: bodySchema, diff --git a/packages/openapi3/test/test-openapi-output.ts b/packages/openapi3/test/test-openapi-output.ts index 294050e2228..7b9ae5bf423 100644 --- a/packages/openapi3/test/test-openapi-output.ts +++ b/packages/openapi3/test/test-openapi-output.ts @@ -523,6 +523,95 @@ describe("openapi3: responses", () => { "string" ); }); + + describe("binary responses", () => { + it("bytes responses should default to application/octet-stream", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @get op read(): bytes; + } + ` + ); + + const response = res.paths["/"].get.responses["200"]; + ok(response); + ok(response.content); + strictEqual(response.content["application/octet-stream"].schema.type, "string"); + strictEqual(response.content["application/octet-stream"].schema.format, "binary"); + }); + + it("@body body: bytes responses should default to application/octet-stream", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @get op read(): {@body body: bytes}; + } + ` + ); + + const response = res.paths["/"].get.responses["200"]; + ok(response); + ok(response.content); + strictEqual(response.content["application/octet-stream"].schema.type, "string"); + strictEqual(response.content["application/octet-stream"].schema.format, "binary"); + }); + + it("@header contentType should override binary content type", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @get op read(): {@header contentType: "image/png", @body body: bytes}; + } + ` + ); + + const response = res.paths["/"].get.responses["200"]; + ok(response); + ok(response.content); + strictEqual(response.content["image/png"].schema.type, "string"); + strictEqual(response.content["image/png"].schema.format, "binary"); + }); + }); +}); + +describe("openapi3: request", () => { + describe("binary request", () => { + it("bytes request should default to application/octet-stream", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @post op read(@body body: bytes): {}; + } + ` + ); + + const requestBody = res.paths["/"].post.requestBody; + ok(requestBody); + strictEqual(requestBody.content["application/octet-stream"].schema.type, "string"); + strictEqual(requestBody.content["application/octet-stream"].schema.format, "binary"); + }); + + it("bytes request should respect @header contentType", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @post op read(@header contentType: "image/png", @body body: bytes): {}; + } + ` + ); + + const requestBody = res.paths["/"].post.requestBody; + ok(requestBody); + strictEqual(requestBody.content["image/png"].schema.type, "string"); + strictEqual(requestBody.content["image/png"].schema.format, "binary"); + }); + }); }); describe("openapi3: extension decorator", () => { From 0aa9c9910eca4bc41e9c175965bc46d7f4f9407c Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 10 Dec 2021 14:57:00 -0800 Subject: [PATCH 2/4] Changelog --- .../openapi3/fix-binary-response_2021-12-10-22-56.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 common/changes/@cadl-lang/openapi3/fix-binary-response_2021-12-10-22-56.json diff --git a/common/changes/@cadl-lang/openapi3/fix-binary-response_2021-12-10-22-56.json b/common/changes/@cadl-lang/openapi3/fix-binary-response_2021-12-10-22-56.json new file mode 100644 index 00000000000..7a88c0ea44b --- /dev/null +++ b/common/changes/@cadl-lang/openapi3/fix-binary-response_2021-12-10-22-56.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@cadl-lang/openapi3", + "comment": "**Fix** issue with @body body: bytes producing `type: string, format: bytes` instead of `type: string, format: binary` for requests and responses", + "type": "patch" + } + ], + "packageName": "@cadl-lang/openapi3" +} \ No newline at end of file From c54c5a0299685b28e8ad13f56773775016ffaae0 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Wed, 15 Dec 2021 19:14:08 +0100 Subject: [PATCH 3/4] Update to respect contenttype --- packages/openapi3/src/openapi.ts | 26 ++++++++----- packages/openapi3/test/test-openapi-output.ts | 39 +++++++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/packages/openapi3/src/openapi.ts b/packages/openapi3/src/openapi.ts index 0100fe88c91..d7beda9898a 100644 --- a/packages/openapi3/src/openapi.ts +++ b/packages/openapi3/src/openapi.ts @@ -326,14 +326,23 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } } + function isBinaryPayload(body: Type, contentType: string) { + return ( + body.kind === "Model" && + body.name === "bytes" && + contentType !== "application/json" && + contentType !== "text/plain" + ); + } + function emitResponseObject(responseModel: Type, statusCode: string = "200") { - let contentType: string | undefined; + let contentType: string = "application/json"; if ( responseModel.kind === "Model" && !responseModel.baseModel && responseModel.properties.size === 0 ) { - const isBinary = responseModel.name === "bytes"; + const isBinary = isBinaryPayload(responseModel, contentType); const schema = isBinary ? { type: "string", format: "binary" } : mapCadlTypeToOpenAPI(responseModel); @@ -341,7 +350,7 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { currentEndpoint.responses[statusCode] = { description: getResponseDescription(responseModel, statusCode), content: { - [isBinary ? "application/octet-stream" : "application/json"]: { + [contentType]: { schema: schema, }, }, @@ -391,10 +400,7 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } } - const isBinary = bodyModel.kind === "Model" && bodyModel.name === "bytes"; - if (contentType === undefined && isBinary) { - contentType = "application/octet-stream"; - } + const isBinary = isBinaryPayload(bodyModel, contentType); contentEntry.schema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyModel); @@ -560,8 +566,6 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { } const bodyParam = bodyParams[0]; const bodyType = bodyParam.type; - const isBinary = bodyType.kind === "Model" && bodyType.name === "bytes"; - const bodySchema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyType); const requestBody: any = { description: getDoc(program, bodyParam), @@ -573,8 +577,10 @@ function createOAPIEmitter(program: Program, options: OpenAPIEmitterOptions) { ); const contentTypes = contentTypeParam ? getContentTypes(contentTypeParam) - : [isBinary ? "application/octet-stream" : "application/json"]; + : ["application/json"]; for (let contentType of contentTypes) { + const isBinary = isBinaryPayload(bodyType, contentType); + const bodySchema = isBinary ? { type: "string", format: "binary" } : getSchemaOrRef(bodyType); const contentEntry: any = { schema: bodySchema, }; diff --git a/packages/openapi3/test/test-openapi-output.ts b/packages/openapi3/test/test-openapi-output.ts index eaa7a20d4b7..8bbc395e311 100644 --- a/packages/openapi3/test/test-openapi-output.ts +++ b/packages/openapi3/test/test-openapi-output.ts @@ -717,7 +717,7 @@ describe("openapi3: responses", () => { }); describe("binary responses", () => { - it("bytes responses should default to application/octet-stream", async () => { + it("bytes responses should default to application/json with byte format", async () => { const res = await openApiFor( ` @route("/") @@ -730,11 +730,11 @@ describe("openapi3: responses", () => { const response = res.paths["/"].get.responses["200"]; ok(response); ok(response.content); - strictEqual(response.content["application/octet-stream"].schema.type, "string"); - strictEqual(response.content["application/octet-stream"].schema.format, "binary"); + strictEqual(response.content["application/json"].schema.type, "string"); + strictEqual(response.content["application/json"].schema.format, "byte"); }); - it("@body body: bytes responses should default to application/octet-stream", async () => { + it("@body body: bytes responses default to application/json with bytes format", async () => { const res = await openApiFor( ` @route("/") @@ -747,11 +747,28 @@ describe("openapi3: responses", () => { const response = res.paths["/"].get.responses["200"]; ok(response); ok(response.content); - strictEqual(response.content["application/octet-stream"].schema.type, "string"); - strictEqual(response.content["application/octet-stream"].schema.format, "binary"); + strictEqual(response.content["application/json"].schema.type, "string"); + strictEqual(response.content["application/json"].schema.format, "byte"); }); - it("@header contentType should override binary content type", async () => { + it("@header contentType text/plain should keep format to byte", async () => { + const res = await openApiFor( + ` + @route("/") + namespace root { + @get op read(): {@header contentType: "text/plain", @body body: bytes}; + } + ` + ); + + const response = res.paths["/"].get.responses["200"]; + ok(response); + ok(response.content); + strictEqual(response.content["text/plain"].schema.type, "string"); + strictEqual(response.content["text/plain"].schema.format, "byte"); + }); + + it("@header contentType not json or text should set format to binary", async () => { const res = await openApiFor( ` @route("/") @@ -772,7 +789,7 @@ describe("openapi3: responses", () => { describe("openapi3: request", () => { describe("binary request", () => { - it("bytes request should default to application/octet-stream", async () => { + it("bytes request should default to application/json byte", async () => { const res = await openApiFor( ` @route("/") @@ -784,11 +801,11 @@ describe("openapi3: request", () => { const requestBody = res.paths["/"].post.requestBody; ok(requestBody); - strictEqual(requestBody.content["application/octet-stream"].schema.type, "string"); - strictEqual(requestBody.content["application/octet-stream"].schema.format, "binary"); + strictEqual(requestBody.content["application/json"].schema.type, "string"); + strictEqual(requestBody.content["application/json"].schema.format, "byte"); }); - it("bytes request should respect @header contentType", async () => { + it("bytes request should respect @header contentType and use binary format when not json or text", async () => { const res = await openApiFor( ` @route("/") From c0be8458e959917a4385de21ffdd7aa46c39a25b Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Tue, 4 Jan 2022 14:32:39 -0800 Subject: [PATCH 4/4] Binary samples --- packages/samples/binary/binary.cadl | 33 +++++ packages/samples/binary/main.cadl | 1 + .../samples/test/output/binary/openapi.json | 140 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 packages/samples/binary/binary.cadl create mode 100644 packages/samples/binary/main.cadl create mode 100644 packages/samples/test/output/binary/openapi.json diff --git a/packages/samples/binary/binary.cadl b/packages/samples/binary/binary.cadl new file mode 100644 index 00000000000..32400a6ec9f --- /dev/null +++ b/packages/samples/binary/binary.cadl @@ -0,0 +1,33 @@ +import "@cadl-lang/rest"; + +using Cadl.Http; + +model HasBytes { + bytes: bytes; +} + +model BytesBody { + @body body: bytes; + @header contentType: ContentType; +} + +@route("/test") +namespace BytesMethod { + @route("base64") + @post + op jsonWithBase64(@body body: HasBytes): HasBytes; + + @route("textPlain") + @post + op textPlain(...BytesBody<"text/plain">): BytesBody<"text/plain">; + + @route("binaryFile") + @post + op genericBinaryFile( + ...BytesBody<"application/octect-stream"> + ): BytesBody<"application/octect-stream">; + + @route("imagePng") + @post + op image(...BytesBody<"image/png">): BytesBody<"image/png">; +} diff --git a/packages/samples/binary/main.cadl b/packages/samples/binary/main.cadl new file mode 100644 index 00000000000..46fd9a7a21f --- /dev/null +++ b/packages/samples/binary/main.cadl @@ -0,0 +1 @@ +import "./binary.cadl"; diff --git a/packages/samples/test/output/binary/openapi.json b/packages/samples/test/output/binary/openapi.json new file mode 100644 index 00000000000..4af335731ab --- /dev/null +++ b/packages/samples/test/output/binary/openapi.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "(title)", + "version": "0000-00-00" + }, + "tags": [], + "paths": { + "/test/base64": { + "post": { + "operationId": "BytesMethod_jsonWithBase64", + "parameters": [], + "responses": { + "200": { + "description": "A successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HasBytes" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HasBytes" + } + } + } + } + } + }, + "/test/textPlain": { + "post": { + "operationId": "BytesMethod_textPlain", + "parameters": [], + "responses": { + "200": { + "description": "A successful response", + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + }, + "requestBody": { + "content": { + "text/plain": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + }, + "/test/binaryFile": { + "post": { + "operationId": "BytesMethod_genericBinaryFile", + "parameters": [], + "responses": { + "200": { + "description": "A successful response", + "content": { + "application/octect-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "requestBody": { + "content": { + "application/octect-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "/test/imagePng": { + "post": { + "operationId": "BytesMethod_image", + "parameters": [], + "responses": { + "200": { + "description": "A successful response", + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + }, + "requestBody": { + "content": { + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "components": { + "schemas": { + "HasBytes": { + "type": "object", + "properties": { + "bytes": { + "type": "string", + "format": "byte" + } + }, + "required": [ + "bytes" + ] + } + } + } +}