From a3698a7f1680d7dfb913c6253f8685ebbdd132ca Mon Sep 17 00:00:00 2001 From: Lubos Date: Mon, 18 Nov 2024 18:06:02 +0800 Subject: [PATCH] fix: handle file-like content media type without explicit schema --- .changeset/many-tomatoes-happen.md | 5 ++ .../src/ir/__tests__/mediaType.test.ts | 70 +++++++++++++++++++ packages/openapi-ts/src/ir/ir.d.ts | 1 + packages/openapi-ts/src/ir/mediaType.ts | 11 +++ .../src/openApi/3.0.x/parser/mediaType.ts | 42 ++++++++++- .../src/openApi/3.0.x/parser/operation.ts | 17 ++--- .../src/openApi/3.1.x/parser/mediaType.ts | 36 +++++++++- .../src/openApi/3.1.x/parser/operation.ts | 5 +- packages/openapi-ts/test/3.0.x.spec.ts | 7 ++ packages/openapi-ts/test/3.1.x.spec.ts | 7 ++ .../3.0.x/content-binary/index.ts | 2 + .../3.0.x/content-binary/types.gen.ts | 13 ++++ .../3.1.x/content-binary/index.ts | 2 + .../3.1.x/content-binary/types.gen.ts | 13 ++++ packages/openapi-ts/test/sample.cjs | 2 +- .../test/spec/3.0.x/content-binary.json | 37 ++++++++++ .../test/spec/3.1.x/content-binary.json | 37 ++++++++++ 17 files changed, 291 insertions(+), 16 deletions(-) create mode 100644 .changeset/many-tomatoes-happen.md create mode 100644 packages/openapi-ts/src/ir/__tests__/mediaType.test.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/index.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/types.gen.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/index.ts create mode 100644 packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/types.gen.ts create mode 100644 packages/openapi-ts/test/spec/3.0.x/content-binary.json create mode 100644 packages/openapi-ts/test/spec/3.1.x/content-binary.json diff --git a/.changeset/many-tomatoes-happen.md b/.changeset/many-tomatoes-happen.md new file mode 100644 index 000000000..e53cb88f3 --- /dev/null +++ b/.changeset/many-tomatoes-happen.md @@ -0,0 +1,5 @@ +--- +'@hey-api/openapi-ts': patch +--- + +fix: handle file-like content media type without explicit schema diff --git a/packages/openapi-ts/src/ir/__tests__/mediaType.test.ts b/packages/openapi-ts/src/ir/__tests__/mediaType.test.ts new file mode 100644 index 000000000..0153205df --- /dev/null +++ b/packages/openapi-ts/src/ir/__tests__/mediaType.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { isMediaTypeFileLike } from '../mediaType'; + +describe('isMediaTypeFileLike', () => { + const scenarios: Array<{ + fileLike: ReturnType; + mediaType: Parameters[0]['mediaType']; + }> = [ + { + fileLike: false, + mediaType: 'application/json', + }, + { + fileLike: true, + mediaType: 'application/json+download', + }, + { + fileLike: false, + mediaType: 'application/json; charset=ascii', + }, + { + fileLike: true, + mediaType: 'application/octet-stream', + }, + { + fileLike: true, + mediaType: 'application/pdf', + }, + { + fileLike: true, + mediaType: 'application/xml; charset=utf-8', + }, + { + fileLike: true, + mediaType: 'application/zip', + }, + { + fileLike: false, + mediaType: 'image/jpeg', + }, + { + fileLike: false, + mediaType: 'image/jpeg; charset=utf-8', + }, + { + fileLike: false, + mediaType: 'text/html; charset=utf-8', + }, + { + fileLike: true, + mediaType: 'text/javascript; charset=ISO-8859-1', + }, + { + fileLike: true, + mediaType: 'text/plain; charset=utf-8', + }, + { + fileLike: true, + mediaType: 'video/mp4', + }, + ]; + + it.each(scenarios)( + 'detects $mediaType as file-like? $fileLike', + async ({ mediaType, fileLike }) => { + expect(isMediaTypeFileLike({ mediaType })).toEqual(fileLike); + }, + ); +}); diff --git a/packages/openapi-ts/src/ir/ir.d.ts b/packages/openapi-ts/src/ir/ir.d.ts index 0609ba516..33925fad8 100644 --- a/packages/openapi-ts/src/ir/ir.d.ts +++ b/packages/openapi-ts/src/ir/ir.d.ts @@ -106,6 +106,7 @@ export interface IRResponsesObject { export interface IRResponseObject { // TODO: parser - handle headers, links, and possibly other media types? + mediaType?: string; schema: IRSchemaObject; } diff --git a/packages/openapi-ts/src/ir/mediaType.ts b/packages/openapi-ts/src/ir/mediaType.ts index bac16e97f..bbb4d8ff0 100644 --- a/packages/openapi-ts/src/ir/mediaType.ts +++ b/packages/openapi-ts/src/ir/mediaType.ts @@ -1,3 +1,5 @@ +const fileLikeRegExp = + /^(application\/(pdf|rtf|msword|vnd\.(ms-|openxmlformats-officedocument\.)|zip|x-(7z|tar|rar|zip|iso)|octet-stream|gzip|x-msdownload|json\+download|xml|x-yaml|x-7z-compressed|x-tar)|text\/(plain|yaml|css|javascript)|audio\/(mpeg|wav)|video\/(mp4|x-matroska)|image\/(vnd\.adobe\.photoshop|svg\+xml))(; ?charset=[^;]+)?$/i; const jsonMimeRegExp = /^application\/(.*\+)?json(;.*)?$/i; const multipartFormDataMimeRegExp = /^multipart\/form-data(;.*)?$/i; const xWwwFormUrlEncodedMimeRegExp = @@ -5,6 +7,15 @@ const xWwwFormUrlEncodedMimeRegExp = export type IRMediaType = 'form-data' | 'json' | 'url-search-params'; +export const isMediaTypeFileLike = ({ + mediaType, +}: { + mediaType: string; +}): boolean => { + fileLikeRegExp.lastIndex = 0; + return fileLikeRegExp.test(mediaType); +}; + export const mediaTypeToIrMediaType = ({ mediaType, }: { diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/mediaType.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/mediaType.ts index a75e2ef39..fb8307763 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/mediaType.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/mediaType.ts @@ -1,5 +1,8 @@ import type { IRMediaType } from '../../../ir/mediaType'; -import { mediaTypeToIrMediaType } from '../../../ir/mediaType'; +import { + isMediaTypeFileLike, + mediaTypeToIrMediaType, +} from '../../../ir/mediaType'; import type { MediaTypeObject, ReferenceObject, @@ -12,6 +15,43 @@ interface Content { type: IRMediaType | undefined; } +export const contentToSchema = ({ + content, +}: { + content: Content; +}): SchemaObject | undefined => { + const { mediaType, schema } = content; + + if (schema && '$ref' in schema) { + return { + allOf: [{ ...schema }], + }; + } + + if (!schema) { + if (isMediaTypeFileLike({ mediaType })) { + return { + format: 'binary', + type: 'string', + }; + } + return; + } + + if ( + schema.type === 'string' && + !schema.format && + isMediaTypeFileLike({ mediaType }) + ) { + return { + ...schema, + format: 'binary', + }; + } + + return schema; +}; + export const mediaTypeObject = ({ content, }: { diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts index 5e76d44e9..4e762a123 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/operation.ts @@ -8,7 +8,7 @@ import type { ResponseObject, SchemaObject, } from '../types/spec'; -import { mediaTypeObject } from './mediaType'; +import { contentToSchema, mediaTypeObject } from './mediaType'; import { paginationField } from './pagination'; import { schemaToIrSchema } from './schema'; @@ -134,18 +134,13 @@ const operationToIrOperation = ({ if (content) { irOperation.responses[name] = { + mediaType: content.mediaType, schema: schemaToIrSchema({ context, - schema: - content.schema && '$ref' in content.schema - ? { - allOf: [{ ...content.schema }], - description: responseObject.description, - } - : { - description: responseObject.description, - ...content.schema, - }, + schema: { + description: responseObject.description, + ...contentToSchema({ content }), + }, }), }; } else { diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/mediaType.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/mediaType.ts index f178b8696..2f0357049 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/mediaType.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/mediaType.ts @@ -1,5 +1,8 @@ import type { IRMediaType } from '../../../ir/mediaType'; -import { mediaTypeToIrMediaType } from '../../../ir/mediaType'; +import { + isMediaTypeFileLike, + mediaTypeToIrMediaType, +} from '../../../ir/mediaType'; import type { MediaTypeObject, SchemaObject } from '../types/spec'; interface Content { @@ -8,6 +11,37 @@ interface Content { type: IRMediaType | undefined; } +export const contentToSchema = ({ + content, +}: { + content: Content; +}): SchemaObject | undefined => { + const { mediaType, schema } = content; + + if (!schema) { + if (isMediaTypeFileLike({ mediaType })) { + return { + format: 'binary', + type: 'string', + }; + } + return; + } + + if ( + schema.type === 'string' && + !schema.format && + isMediaTypeFileLike({ mediaType }) + ) { + return { + ...schema, + format: 'binary', + }; + } + + return schema; +}; + export const mediaTypeObject = ({ content, }: { diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts index 856dde7af..02ff7df28 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/operation.ts @@ -8,7 +8,7 @@ import type { ResponseObject, SchemaObject, } from '../types/spec'; -import { mediaTypeObject } from './mediaType'; +import { contentToSchema, mediaTypeObject } from './mediaType'; import { paginationField } from './pagination'; import { schemaToIrSchema } from './schema'; @@ -128,11 +128,12 @@ const operationToIrOperation = ({ if (content) { irOperation.responses[name] = { + mediaType: content.mediaType, schema: schemaToIrSchema({ context, schema: { description: responseObject.description, - ...content.schema, + ...contentToSchema({ content }), }, }), }; diff --git a/packages/openapi-ts/test/3.0.x.spec.ts b/packages/openapi-ts/test/3.0.x.spec.ts index 76ba59d59..fdbf5bb4a 100644 --- a/packages/openapi-ts/test/3.0.x.spec.ts +++ b/packages/openapi-ts/test/3.0.x.spec.ts @@ -56,6 +56,13 @@ describe(`OpenAPI ${VERSION}`, () => { description: 'generates correct array when items are oneOf array with single item', }, + { + config: createConfig({ + input: 'content-binary.json', + output: 'content-binary', + }), + description: 'handles binary content', + }, { config: createConfig({ input: 'discriminator-all-of.yaml', diff --git a/packages/openapi-ts/test/3.1.x.spec.ts b/packages/openapi-ts/test/3.1.x.spec.ts index 1a0133379..77e7d5859 100644 --- a/packages/openapi-ts/test/3.1.x.spec.ts +++ b/packages/openapi-ts/test/3.1.x.spec.ts @@ -56,6 +56,13 @@ describe(`OpenAPI ${VERSION}`, () => { description: 'generates correct array when items are oneOf array with single item', }, + { + config: createConfig({ + input: 'content-binary.json', + output: 'content-binary', + }), + description: 'handles binary content', + }, { config: createConfig({ input: 'discriminator-all-of.yaml', diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/index.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/index.ts new file mode 100644 index 000000000..56bade120 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/index.ts @@ -0,0 +1,2 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/types.gen.ts new file mode 100644 index 000000000..00c4d3129 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.0.x/content-binary/types.gen.ts @@ -0,0 +1,13 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooResponses = { + 200: string; +}; + +export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; + +export type GetBarResponses = { + 200: Blob | File; +}; + +export type GetBarResponse = GetBarResponses[keyof GetBarResponses]; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/index.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/index.ts new file mode 100644 index 000000000..56bade120 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/index.ts @@ -0,0 +1,2 @@ +// This file is auto-generated by @hey-api/openapi-ts +export * from './types.gen'; \ No newline at end of file diff --git a/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/types.gen.ts b/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/types.gen.ts new file mode 100644 index 000000000..00c4d3129 --- /dev/null +++ b/packages/openapi-ts/test/__snapshots__/3.1.x/content-binary/types.gen.ts @@ -0,0 +1,13 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type GetFooResponses = { + 200: string; +}; + +export type GetFooResponse = GetFooResponses[keyof GetFooResponses]; + +export type GetBarResponses = { + 200: Blob | File; +}; + +export type GetBarResponse = GetBarResponses[keyof GetBarResponses]; \ No newline at end of file diff --git a/packages/openapi-ts/test/sample.cjs b/packages/openapi-ts/test/sample.cjs index c2a9b425d..02e9debb6 100644 --- a/packages/openapi-ts/test/sample.cjs +++ b/packages/openapi-ts/test/sample.cjs @@ -13,7 +13,7 @@ const main = async () => { input: { // include: // '^(#/components/schemas/import|#/paths/api/v{api-version}/simple/options)$', - path: './test/spec/3.0.x/full.json', + path: './test/spec/3.0.x/content-binary.json', // path: 'https://mongodb-mms-prod-build-server.s3.amazonaws.com/openapi/2caffd88277a4e27c95dcefc7e3b6a63a3b03297-v2-2023-11-15.json', }, // name: 'foo', diff --git a/packages/openapi-ts/test/spec/3.0.x/content-binary.json b/packages/openapi-ts/test/spec/3.0.x/content-binary.json new file mode 100644 index 000000000..311ef48af --- /dev/null +++ b/packages/openapi-ts/test/spec/3.0.x/content-binary.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "OpenAPI 3.0.0 content binary example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "content": { + "image/png": { + "schema": { + "type": "string", + "contentMediaType": "image/png", + "contentEncoding": "base64" + } + } + } + } + } + } + }, + "/bar": { + "get": { + "responses": { + "200": { + "content": { + "application/zip": {} + } + } + } + } + } + } +} diff --git a/packages/openapi-ts/test/spec/3.1.x/content-binary.json b/packages/openapi-ts/test/spec/3.1.x/content-binary.json new file mode 100644 index 000000000..822573fea --- /dev/null +++ b/packages/openapi-ts/test/spec/3.1.x/content-binary.json @@ -0,0 +1,37 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "OpenAPI 3.1.0 content binary example", + "version": "1" + }, + "paths": { + "/foo": { + "get": { + "responses": { + "200": { + "content": { + "image/png": { + "schema": { + "type": "string", + "contentMediaType": "image/png", + "contentEncoding": "base64" + } + } + } + } + } + } + }, + "/bar": { + "get": { + "responses": { + "200": { + "content": { + "application/zip": {} + } + } + } + } + } + } +}