diff --git a/.changeset/hip-times-draw.md b/.changeset/hip-times-draw.md new file mode 100644 index 0000000000..882a572ce4 --- /dev/null +++ b/.changeset/hip-times-draw.md @@ -0,0 +1,6 @@ +--- +'@sap-cloud-sdk/openapi': minor +'@sap-cloud-sdk/openapi-generator': minor +--- + +[New Functionality] Support request bodies with content type "multipart/form-data". diff --git a/packages/openapi-generator/package.json b/packages/openapi-generator/package.json index f0a1e08953..af8b11d16d 100644 --- a/packages/openapi-generator/package.json +++ b/packages/openapi-generator/package.json @@ -44,12 +44,14 @@ "@sap-cloud-sdk/generator-common": "^4.4.0", "@sap-cloud-sdk/openapi": "^4.4.0", "@sap-cloud-sdk/util": "^4.4.0", + "content-type": "^1.0.5", "js-yaml": "^4.1.1", "openapi-types": "^12.1.3", "swagger2openapi": "^7.0.4" }, "devDependencies": { "@apidevtools/json-schema-ref-parser": "^14.1.1", + "@types/content-type": "^1.1.9", "@types/js-yaml": "^4.0.9", "mock-fs": "^5.5.0", "prettier": "^3.8.1", diff --git a/packages/openapi-generator/src/file-serializer/__snapshots__/api-file.spec.ts.snap b/packages/openapi-generator/src/file-serializer/__snapshots__/api-file.spec.ts.snap index feb4589212..42950737fa 100644 --- a/packages/openapi-generator/src/file-serializer/__snapshots__/api-file.spec.ts.snap +++ b/packages/openapi-generator/src/file-serializer/__snapshots__/api-file.spec.ts.snap @@ -21,8 +21,8 @@ export const TestApi = { "/test/{id}", { pathParameters: { id }, - queryParameters, - headerParameters + headerParameters, + queryParameters }, TestApi._defaultBasePath ), @@ -91,8 +91,8 @@ export const TestApi = { "/test/{id}", { pathParameters: { id }, - queryParameters, - headerParameters + headerParameters, + queryParameters }, TestApi._defaultBasePath ), @@ -133,8 +133,8 @@ export const TestApi = { "/test/{id}", { pathParameters: { id }, - queryParameters, - headerParameters + headerParameters, + queryParameters }, TestApi._defaultBasePath ), diff --git a/packages/openapi-generator/src/file-serializer/operation.spec.ts b/packages/openapi-generator/src/file-serializer/operation.spec.ts index 386371bc4e..7b8eea8810 100644 --- a/packages/openapi-generator/src/file-serializer/operation.spec.ts +++ b/packages/openapi-generator/src/file-serializer/operation.spec.ts @@ -53,25 +53,25 @@ describe('serializeOperation', () => { pathPattern: '/test/{id}/{subId}' }; expect(serializeOperation(operation, apiName)).toMatchInlineSnapshot(` - "/** - * Create a request builder for execution of get requests to the '/test/{id}/{subId}' endpoint. - * @param id - Path parameter. - * @param subId - Path parameter. - * @param queryParameters - Object containing the following keys: limit. - * @param headerParameters - Object containing the following keys: resource-group. - * @returns The request builder, use the \`execute()\` method to trigger the request. - */ - getFn: (id: string, subId: string, queryParameters?: {'limit'?: number}, headerParameters?: {'resource-group'?: string}) => new OpenApiRequestBuilder( - 'get', - "/test/{id}/{subId}", - { - pathParameters: { id, subId }, - queryParameters, - headerParameters - }, - TestApi._defaultBasePath - )" - `); +"/** + * Create a request builder for execution of get requests to the '/test/{id}/{subId}' endpoint. + * @param id - Path parameter. + * @param subId - Path parameter. + * @param queryParameters - Object containing the following keys: limit. + * @param headerParameters - Object containing the following keys: resource-group. + * @returns The request builder, use the \`execute()\` method to trigger the request. + */ +getFn: (id: string, subId: string, queryParameters?: {'limit'?: number}, headerParameters?: {'resource-group'?: string}) => new OpenApiRequestBuilder( + 'get', + "/test/{id}/{subId}", + { + pathParameters: { id, subId }, + headerParameters, + queryParameters + }, + TestApi._defaultBasePath +)" +`); }); it('serializes operation with path and header parameters', () => { @@ -236,22 +236,22 @@ describe('serializeOperation', () => { }; expect(serializeOperation(operation, apiName)).toMatchInlineSnapshot(` - "/** - * Create a request builder for execution of get requests to the '/test' endpoint. - * @param queryParameters - Object containing the following keys: limit, page. - * @param headerParameters - Object containing the following keys: resource-group. - * @returns The request builder, use the \`execute()\` method to trigger the request. - */ - getFn: (queryParameters: {'limit'?: number, 'page'?: number}, headerParameters: {'resource-group': string}) => new OpenApiRequestBuilder( - 'get', - "/test", - { - queryParameters, - headerParameters - }, - TestApi._defaultBasePath - )" - `); +"/** + * Create a request builder for execution of get requests to the '/test' endpoint. + * @param queryParameters - Object containing the following keys: limit, page. + * @param headerParameters - Object containing the following keys: resource-group. + * @returns The request builder, use the \`execute()\` method to trigger the request. + */ +getFn: (queryParameters: {'limit'?: number, 'page'?: number}, headerParameters: {'resource-group': string}) => new OpenApiRequestBuilder( + 'get', + "/test", + { + headerParameters, + queryParameters + }, + TestApi._defaultBasePath +)" +`); }); it('serializes operation with optional query and optional + required header parameters', () => { @@ -302,22 +302,22 @@ describe('serializeOperation', () => { }; expect(serializeOperation(operation, apiName)).toMatchInlineSnapshot(` - "/** - * Create a request builder for execution of get requests to the '/test' endpoint. - * @param queryParameters - Object containing the following keys: limit, page. - * @param headerParameters - Object containing the following keys: authentication, resource-group. - * @returns The request builder, use the \`execute()\` method to trigger the request. - */ - getFn: (queryParameters: {'limit'?: number, 'page'?: number}, headerParameters: {'authentication': string, 'resource-group'?: string}) => new OpenApiRequestBuilder( - 'get', - "/test", - { - queryParameters, - headerParameters - }, - TestApi._defaultBasePath - )" - `); +"/** + * Create a request builder for execution of get requests to the '/test' endpoint. + * @param queryParameters - Object containing the following keys: limit, page. + * @param headerParameters - Object containing the following keys: authentication, resource-group. + * @returns The request builder, use the \`execute()\` method to trigger the request. + */ +getFn: (queryParameters: {'limit'?: number, 'page'?: number}, headerParameters: {'authentication': string, 'resource-group'?: string}) => new OpenApiRequestBuilder( + 'get', + "/test", + { + headerParameters, + queryParameters + }, + TestApi._defaultBasePath +)" +`); }); it('serializes operation with required query and optional header parameters', () => { @@ -352,22 +352,22 @@ describe('serializeOperation', () => { }; expect(serializeOperation(operation, apiName)).toMatchInlineSnapshot(` - "/** - * Create a request builder for execution of get requests to the '/test' endpoint. - * @param queryParameters - Object containing the following keys: limit. - * @param headerParameters - Object containing the following keys: resource-group. - * @returns The request builder, use the \`execute()\` method to trigger the request. - */ - getFn: (queryParameters: {'limit': number}, headerParameters?: {'resource-group'?: string}) => new OpenApiRequestBuilder( - 'get', - "/test", - { - queryParameters, - headerParameters - }, - TestApi._defaultBasePath - )" - `); +"/** + * Create a request builder for execution of get requests to the '/test' endpoint. + * @param queryParameters - Object containing the following keys: limit. + * @param headerParameters - Object containing the following keys: resource-group. + * @returns The request builder, use the \`execute()\` method to trigger the request. + */ +getFn: (queryParameters: {'limit': number}, headerParameters?: {'resource-group'?: string}) => new OpenApiRequestBuilder( + 'get', + "/test", + { + headerParameters, + queryParameters + }, + TestApi._defaultBasePath +)" +`); }); it('serializes operation with required query and required header parameters', () => { @@ -410,22 +410,22 @@ describe('serializeOperation', () => { }; expect(serializeOperation(operation, apiName)).toMatchInlineSnapshot(` - "/** - * Create a request builder for execution of get requests to the '/test' endpoint. - * @param queryParameters - Object containing the following keys: limit, page. - * @param headerParameters - Object containing the following keys: resource-group. - * @returns The request builder, use the \`execute()\` method to trigger the request. - */ - getFn: (queryParameters: {'limit': number, 'page'?: number}, headerParameters: {'resource-group': string}) => new OpenApiRequestBuilder( - 'get', - "/test", - { - queryParameters, - headerParameters - }, - TestApi._defaultBasePath - )" - `); +"/** + * Create a request builder for execution of get requests to the '/test' endpoint. + * @param queryParameters - Object containing the following keys: limit, page. + * @param headerParameters - Object containing the following keys: resource-group. + * @returns The request builder, use the \`execute()\` method to trigger the request. + */ +getFn: (queryParameters: {'limit': number, 'page'?: number}, headerParameters: {'resource-group': string}) => new OpenApiRequestBuilder( + 'get', + "/test", + { + headerParameters, + queryParameters + }, + TestApi._defaultBasePath +)" +`); }); it('serializes operation with only query parameters', () => { diff --git a/packages/openapi-generator/src/file-serializer/operation.ts b/packages/openapi-generator/src/file-serializer/operation.ts index 593e650afd..69bef8334d 100644 --- a/packages/openapi-generator/src/file-serializer/operation.ts +++ b/packages/openapi-generator/src/file-serializer/operation.ts @@ -108,6 +108,18 @@ function serializeParamsForSignature( } } +function getHeaderParameters(operation: OpenApiOperation): string | undefined { + if (operation.requestBody?.mediaType) { + const contentTypeStr = `'content-type': '${operation.requestBody.mediaType}'`; + return operation.headerParameters.length + ? `headerParameters: {${contentTypeStr}, ...headerParameters}` + : `headerParameters: {${contentTypeStr}}`; + } + if (operation.headerParameters.length) { + return 'headerParameters'; + } +} + function serializeParamsForRequestBuilder( operation: OpenApiOperation ): string | undefined { @@ -121,13 +133,24 @@ function serializeParamsForRequestBuilder( } if (operation.requestBody) { params.push('body'); + if ( + operation.requestBody.encoding && + Object.keys(operation.requestBody.encoding).length + ) { + params.push( + `_encoding: ${JSON.stringify(operation.requestBody.encoding)}` + ); + } + } + + const headerParam = getHeaderParameters(operation); + if (headerParam) { + params.push(headerParam); } if (operation.queryParameters.length) { params.push('queryParameters'); } - if (operation.headerParameters.length) { - params.push('headerParameters'); - } + if (params.length) { return codeBlock`{ ${params.join(',\n')} diff --git a/packages/openapi-generator/src/openapi-types.ts b/packages/openapi-generator/src/openapi-types.ts index c23943eeb0..15f9266498 100644 --- a/packages/openapi-generator/src/openapi-types.ts +++ b/packages/openapi-generator/src/openapi-types.ts @@ -153,12 +153,41 @@ export interface OpenApiRequestBody { */ schema: OpenApiSchema; + /** + * Media type of the body. + */ + mediaType: string; + /** * Description of the body. */ description?: string; + + /** + * Encoding options for multipart/form-data properties. + * Maps property names to their encoding configuration (e.g., contentType). + */ + encoding?: Record< + string, + { + contentType: string; + isImplicit: boolean; + parsedContentTypes: { + type: string; + parameters: { [key: string]: string }; + }[]; + } + >; } +/** + * Representation of a media type. + * @internal + */ +export type OpenApiMediaTypeObject = OpenAPIV3.MediaTypeObject & { + mediaType: string; +}; + /** * Represents all possible Types of schemas. * @internal diff --git a/packages/openapi-generator/src/parser/document.spec.ts b/packages/openapi-generator/src/parser/document.spec.ts index 69ef2a6cad..c0225f0a06 100644 --- a/packages/openapi-generator/src/parser/document.spec.ts +++ b/packages/openapi-generator/src/parser/document.spec.ts @@ -6,6 +6,10 @@ import type { OpenAPIV3 } from 'openapi-types'; const options = { strictNaming: true, schemaPrefix: '', resolveExternal: true }; describe('parseOpenApiDocument()', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('does not modify input service specification', () => { const input: OpenAPIV3.Document = { ...emptyDocument, diff --git a/packages/openapi-generator/src/parser/media-type.spec.ts b/packages/openapi-generator/src/parser/media-type.spec.ts index 933d90b873..66719090e9 100644 --- a/packages/openapi-generator/src/parser/media-type.spec.ts +++ b/packages/openapi-generator/src/parser/media-type.spec.ts @@ -6,6 +6,44 @@ const defaultOptions = { schemaPrefix: '', resolveExternal: true }; + +function createMultipartFormContent(schema: any, encoding?: any) { + return { + content: { + 'multipart/form-data': { + schema, + ...(encoding && { encoding }) + } + } + }; +} + +function createImplicitMultipartFormEncoding(contentType: string): { + contentType: string; + isImplicit: true; + parsedContentTypes: any[]; +} { + return { + contentType, + isImplicit: true, + parsedContentTypes: [{ type: contentType, parameters: {} }] + }; +} + +function createExplicitMultipartFormEncoding( + contentType: string, + parameters: Record = {} +): { + contentType: string; + isImplicit: false; + parsedContentTypes: any[]; +} { + return { + contentType, + isImplicit: false, + parsedContentTypes: [{ type: contentType.split(';')[0].trim(), parameters }] + }; +} describe('parseTopLevelMediaType', () => { it('returns undefined if the media type is not supported', async () => { expect( @@ -30,7 +68,11 @@ describe('parseTopLevelMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual(emptyObjectSchema); + ).toEqual({ + schema: emptyObjectSchema, + mediaType: 'application/json', + encoding: undefined + }); }); it('returns parsed schema for supported media type application/merge-patch+json', async () => { @@ -44,7 +86,11 @@ describe('parseTopLevelMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual(emptyObjectSchema); + ).toEqual({ + schema: emptyObjectSchema, + mediaType: 'application/merge-patch+json', + encoding: undefined + }); }); it('returns parsed schema for supported media type text/plain', async () => { @@ -58,7 +104,11 @@ describe('parseTopLevelMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ type: 'number' }); + ).toEqual({ + schema: { type: 'number' }, + mediaType: 'text/plain', + encoding: undefined + }); }); it('returns parsed schema for supported media type application/octet-stream', async () => { @@ -74,7 +124,410 @@ describe('parseTopLevelMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ type: 'string' }); + ).toEqual({ + schema: { type: 'Blob' }, + mediaType: 'application/octet-stream', + encoding: undefined + }); + }); + + it('returns undefined encoding for non-multipart media types', async () => { + const result = parseTopLevelMediaType( + { content: { 'application/json': { schema: { type: 'object' } } } }, + await createTestRefs(), + defaultOptions + ); + expect(result?.encoding).toBeUndefined(); + }); + + it('parses encoding with contentType for multipart/form-data', async () => { + const schema = { + type: 'object', + properties: { profileImage: { type: 'string', format: 'binary' } } + }; + const encoding = { profileImage: { contentType: 'image/png, image/jpeg' } }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + profileImage: { + contentType: 'image/png, image/jpeg', + isImplicit: false, + parsedContentTypes: [ + { type: 'image/png', parameters: {} }, + { type: 'image/jpeg', parameters: {} } + ] + } + }); + }); + + it('returns undefined encoding when encoding object is empty', async () => { + const result = parseTopLevelMediaType( + createMultipartFormContent({ type: 'object' }), + await createTestRefs(), + defaultOptions + ); + expect(result?.encoding).toBeUndefined(); + }); + + it('maps string with format binary to Blob type for multipart/form-data', async () => { + const schema = { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + metadata: { type: 'string' } + } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + await createTestRefs(), + defaultOptions + ); + + expect(result?.schema).toEqual({ + properties: [ + { + schema: { type: 'Blob' }, + description: undefined, + nullable: false, + name: 'file', + required: false, + schemaProperties: { format: 'binary' } + }, + { + schema: { type: 'string' }, + description: undefined, + nullable: false, + name: 'metadata', + required: false, + schemaProperties: {} + } + ], + additionalProperties: { type: 'any' } + }); + expect(result?.mediaType).toBe('multipart/form-data'); + expect(result?.encoding).toEqual({ + file: createImplicitMultipartFormEncoding('application/octet-stream'), + metadata: createImplicitMultipartFormEncoding('text/plain') + }); + }); + + it('respects explicit encoding over auto-inferred encoding for binary properties', async () => { + const schema = { + type: 'object', + properties: { image: { type: 'string', format: 'binary' } } + }; + const encoding = { image: { contentType: 'image/png' } }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + image: createExplicitMultipartFormEncoding('image/png') + }); + }); + + it('auto-infers text/plain for primitive types in multipart/form-data', async () => { + const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + score: { type: 'number' }, + active: { type: 'boolean' } + } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + await createTestRefs(), + defaultOptions + ); + + const textPlainEncoding = createImplicitMultipartFormEncoding('text/plain'); + expect(result?.encoding).toEqual({ + name: textPlainEncoding, + age: textPlainEncoding, + score: textPlainEncoding, + active: textPlainEncoding + }); + }); + + it('auto-infers content type for arrays based on item type in multipart/form-data', async () => { + const schema = { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' } }, + files: { type: 'array', items: { type: 'string', format: 'binary' } } + } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + tags: createImplicitMultipartFormEncoding('text/plain'), + files: createImplicitMultipartFormEncoding('application/octet-stream') + }); + }); + + it('auto-infers application/json for object types in multipart/form-data', async () => { + const schema = { + type: 'object', + properties: { + metadata: { type: 'object', properties: { key: { type: 'string' } } } + } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + metadata: createImplicitMultipartFormEncoding('application/json') + }); + }); + + it('handles multipart/form-data with $ref schema', async () => { + const schema = { $ref: '#/components/schemas/Body_predict_parquet' }; + const refs = await createTestRefs({ + schemas: { + Body_predict_parquet: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + target_columns: { type: 'string' }, + metadata: { + type: 'object', + properties: { key: { type: 'string' } } + } + } + } + } + }); + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + refs, + defaultOptions + ); + + expect(result?.schema).toEqual({ + $ref: '#/components/schemas/Body_predict_parquet', + schemaName: 'BodyPredictParquet', + fileName: 'body-predict-parquet' + }); + expect(result?.mediaType).toBe('multipart/form-data'); + expect(result?.encoding).toEqual({ + file: createImplicitMultipartFormEncoding('application/octet-stream'), + target_columns: createImplicitMultipartFormEncoding('text/plain'), + metadata: createImplicitMultipartFormEncoding('application/json') + }); + }); + + it('handles multipart/form-data with $ref schema containing nested $refs', async () => { + const schema = { $ref: '#/components/schemas/FormData' }; + const refs = await createTestRefs({ + schemas: { + FormData: { + type: 'object', + properties: { + image: { $ref: '#/components/schemas/ImageFile' }, + description: { type: 'string' } + } + }, + ImageFile: { type: 'string', format: 'binary' } + } + }); + const result = parseTopLevelMediaType( + createMultipartFormContent(schema), + refs, + defaultOptions + ); + + expect(result?.encoding).toEqual({ + image: createImplicitMultipartFormEncoding('application/octet-stream'), + description: createImplicitMultipartFormEncoding('text/plain') + }); + }); + + it('parses content type with charset parameter', async () => { + const schema = { + type: 'object', + properties: { textData: { type: 'string' } } + }; + const encoding = { textData: { contentType: 'text/plain; charset=utf-8' } }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + textData: { + contentType: 'text/plain; charset=utf-8', + isImplicit: false, + parsedContentTypes: [ + { type: 'text/plain', parameters: { charset: 'utf-8' } } + ] + } + }); + }); + + it('parses multiple comma-separated content types', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + document: { type: 'string', format: 'binary' } + } + }, + encoding: { + document: { + contentType: 'application/pdf, application/msword, text/plain' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + document: { + contentType: 'application/pdf, application/msword, text/plain', + isImplicit: false, + parsedContentTypes: [ + { type: 'application/pdf', parameters: {} }, + { type: 'application/msword', parameters: {} }, + { type: 'text/plain', parameters: {} } + ] + } + }); + }); + + it('handles content type with multiple parameters', async () => { + const schema = { + type: 'object', + properties: { xmlData: { type: 'string' } } + }; + const encoding = { + xmlData: { + contentType: 'application/xml; charset=iso-8859-1; boundary=something' + } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + xmlData: { + contentType: 'application/xml; charset=iso-8859-1; boundary=something', + isImplicit: false, + parsedContentTypes: [ + { + type: 'application/xml', + parameters: { charset: 'iso-8859-1', boundary: 'something' } + } + ] + } + }); + }); + + it('combines explicit encoding with charset and auto-inferred encoding', async () => { + const schema = { + type: 'object', + properties: { + customText: { type: 'string' }, + normalField: { type: 'string' } + } + }; + const encoding = { + customText: { contentType: 'text/plain; charset=utf-16' } + }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + customText: { + contentType: 'text/plain; charset=utf-16', + isImplicit: false, + parsedContentTypes: [ + { type: 'text/plain', parameters: { charset: 'utf-16' } } + ] + }, + normalField: createImplicitMultipartFormEncoding('text/plain') + }); + }); + + it('throws error with malformed content type', async () => { + const schema = { + type: 'object', + properties: { file: { type: 'string', format: 'binary' } } + }; + const encoding = { file: { contentType: 'image/png;;invalid' } }; + const refs = await createTestRefs(); + + expect(() => + parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + refs, + defaultOptions + ) + ).toThrow(/invalid content type.*image\/png;;invalid.*file/i); + }); + + it('handles wildcard content types correctly', async () => { + const schema = { + type: 'object', + properties: { data: { type: 'string', format: 'binary' } } + }; + const encoding = { data: { contentType: 'image/*' } }; + const result = parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + data: createExplicitMultipartFormEncoding('image/*') + }); + }); + + it('throws error with completely invalid content type format', async () => { + const schema = { + type: 'object', + properties: { attachment: { type: 'string', format: 'binary' } } + }; + const encoding = { + attachment: { contentType: 'not-a-valid-content-type-at-all' } + }; + const refs = await createTestRefs(); + + expect(() => + parseTopLevelMediaType( + createMultipartFormContent(schema, encoding), + refs, + defaultOptions + ) + ).toThrow( + /invalid content type.*not-a-valid-content-type-at-all.*attachment/i + ); }); }); @@ -94,7 +547,10 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ type: 'any' }); + ).toEqual({ + schema: { type: 'any' }, + mediaType: 'application/json' + }); }); it('returns parsed schema if there is only application/json', async () => { @@ -106,7 +562,11 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual(emptyObjectSchema); + ).toEqual({ + schema: emptyObjectSchema, + mediaType: 'application/json', + encoding: undefined + }); }); it('returns parsed schema if there is only application/merge-patch+json', async () => { @@ -120,7 +580,11 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual(emptyObjectSchema); + ).toEqual({ + schema: emptyObjectSchema, + mediaType: 'application/merge-patch+json', + encoding: undefined + }); }); it('returns parsed schema if there is only wildcard media type */*', async () => { @@ -134,7 +598,11 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ type: 'string' }); + ).toEqual({ + schema: { type: 'string' }, + mediaType: '*/*', + encoding: undefined + }); }); it('returns parsed schema if there is both application/json and application/merge-patch+json', async () => { @@ -149,7 +617,11 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual(emptyObjectSchema); + ).toEqual({ + schema: emptyObjectSchema, + mediaType: 'application/json', + encoding: undefined + }); }); it('returns anyOf schema if there are unsupported media type and supported media type', async () => { @@ -164,6 +636,9 @@ describe('parseMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ anyOf: [emptyObjectSchema, { type: 'any' }] }); + ).toEqual({ + schema: { anyOf: [emptyObjectSchema, { type: 'any' }] }, + mediaType: 'application/json' + }); }); }); diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index 212fa080b7..6455798389 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -1,7 +1,8 @@ -import { createLogger } from '@sap-cloud-sdk/util'; +import { createLogger, ErrorWithCause } from '@sap-cloud-sdk/util'; +import { parse as parseContentType, type ParsedMediaType } from 'content-type'; import { parseSchema } from './schema'; import type { OpenAPIV3 } from 'openapi-types'; -import type { OpenApiSchema } from '../openapi-types'; +import type { OpenApiMediaTypeObject, OpenApiSchema } from '../openapi-types'; import type { OpenApiDocumentRefs } from './refs'; import type { ParserOptions } from './options'; @@ -11,8 +12,183 @@ const allowedMediaTypes = [ 'application/merge-patch+json', 'application/octet-stream', 'text/plain', + 'multipart/form-data', '*/*' ]; + +interface EncodingInfo { + contentType: string; + isImplicit: boolean; + parsedContentTypes: ParsedMediaType[]; +} + +type EncodingMap = Record; + +/** + * Parse content types from a comma-separated content type string. + * @param contentType - Comma-separated content types from encoding object. + * @param propName - Property name for error messages. + * @returns Array of parsed content types. + */ +function parseContentTypes( + contentType: string, + propName: string +): ParsedMediaType[] { + return contentType + .split(',') + .map(ct => ct.trim()) + .map(ct => { + try { + return parseContentType(ct); + } catch (error: any) { + throw new ErrorWithCause( + `Invalid content type '${ct}' for property '${propName}' in OpenAPI specification. ` + + "Content types must follow the format 'type/subtype' (e.g., 'image/png', 'text/plain'). " + + 'Please fix your OpenAPI document.', + error + ); + } + }); +} + +/** + * Infer content type based on OpenAPI schema type. + * @param s - The schema object to infer content type from. + * @returns The inferred content type, or undefined if cannot be determined. + */ +function inferContentTypeFromSchema( + s: OpenAPIV3.SchemaObject +): string | undefined { + // Binary format -> application/octet-stream + if (s.type === 'string' && s.format === 'binary') { + return 'application/octet-stream'; + } + // Primitive types -> text/plain + if ( + s.type === 'string' || + s.type === 'number' || + s.type === 'integer' || + s.type === 'boolean' + ) { + return 'text/plain'; + } + // Arrays -> check item type + if ( + s.type === 'array' && + s.items && + typeof s.items === 'object' && + !('$ref' in s.items) + ) { + return inferContentTypeFromSchema(s.items); + } + // Objects and others -> application/json + return 'application/json'; +} + +/** + * Infer content types for multipart form-data properties that don't have explicit encoding metadata. + * Content types are inferred based on the OpenAPI schema type of each property. + * @param resolvedEncodings - Explicitly defined encodings that have already been resolved. + * @param resolvedSchema - The resolved schema object. + * @param refs - Object representing cross references throughout the document. + * @returns Encoding map with inferred content types added, or undefined if no encodings. + * @internal + */ +function inferMultipartFormEncodings( + resolvedEncodings: string[], + resolvedSchema: OpenAPIV3.SchemaObject, + refs: OpenApiDocumentRefs +): EncodingMap | undefined { + if (!resolvedSchema.properties) { + return; + } + + const inferredEncodings = Object.entries(resolvedSchema.properties) + .map(([propName, propSchema]) => { + if (resolvedEncodings.includes(propName)) { + return; + } + if (!propSchema || typeof propSchema !== 'object') { + return; + } + + // Resolve $ref for property schema + const resolvedPropSchema = refs.resolveObject(propSchema); + if ('$ref' in resolvedPropSchema) { + return; + } + + const contentType = inferContentTypeFromSchema(resolvedPropSchema); + if (!contentType) { + return; + } + + return [ + propName, + { + contentType, + isImplicit: true, + parsedContentTypes: parseContentTypes(contentType, propName) + } + ]; + }) + .filter(Boolean) as [string, EncodingInfo][]; + return Object.fromEntries(inferredEncodings); +} + +/** + * Parse encoding metadata for multipart/form-data content type. + * Extracts explicit encoding configurations from the OpenAPI encoding object and automatically + * infers content types for properties without explicit encoding based on their schema types. + * @param mediaTypeObject - The media type object containing encoding and schema. + * @param refs - Object representing cross references throughout the document. + * @returns Encoding configuration mapping property names to their content types and metadata, or undefined if no encodings. + * @internal + */ +function parseMultipartFormEncodings( + mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, + refs: OpenApiDocumentRefs +): EncodingMap | undefined { + const explicitEncodings: EncodingMap = mediaTypeObject?.encoding + ? Object.fromEntries( + Object.entries(mediaTypeObject.encoding) + .filter(([, encodingObj]) => encodingObj.contentType) + .map(([propName, encodingObj]) => [ + propName, + { + contentType: encodingObj.contentType!, + isImplicit: false, + parsedContentTypes: parseContentTypes( + encodingObj.contentType!, + propName + ) + } + ]) + ) + : {}; + + const schema = mediaTypeObject?.schema; + + if (!schema) { + return Object.keys(explicitEncodings).length + ? explicitEncodings + : undefined; + } + + // Resolve $ref if present + const resolvedSchema = refs.resolveObject(schema); + + // Auto-infer missing content types based on schema types + const implicitEncodings = + inferMultipartFormEncodings( + Object.keys(explicitEncodings), + resolvedSchema, + refs + ) || {}; + + const allEncodings = { ...implicitEncodings, ...explicitEncodings }; + return Object.keys(allEncodings).length ? allEncodings : undefined; +} /** * Parse the type of a resolved request body or response object. * @param bodyOrResponseObject - The request body or response object to parse the type from. @@ -28,15 +204,29 @@ export function parseTopLevelMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): OpenApiSchema | undefined { +): + | { + schema: OpenApiSchema; + mediaType: string; + encoding?: EncodingMap; + } + | undefined { if (bodyOrResponseObject) { - const mediaType = getMediaTypeObject( + const mediaTypeObject = getMediaTypeObject( bodyOrResponseObject, allowedMediaTypes ); - const schema = mediaType?.schema; - if (schema) { - return parseSchema(schema, refs, options); + + if (mediaTypeObject) { + const encoding = mediaTypeObject.mediaType.startsWith('multipart/') + ? parseMultipartFormEncodings(mediaTypeObject, refs) + : undefined; + + return { + schema: parseSchema(mediaTypeObject.schema, refs, options), + mediaType: mediaTypeObject.mediaType, + encoding + }; } } } @@ -51,32 +241,42 @@ export function parseMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): OpenApiSchema | undefined { +): + | { + schema: OpenApiSchema; + mediaType: string; + encoding?: EncodingMap; + } + | undefined { const allMediaTypes = getMediaTypes(bodyOrResponseObject); if (allMediaTypes.length) { - const parsedMediaType = parseTopLevelMediaType( + const parsedSchema = parseTopLevelMediaType( bodyOrResponseObject, refs, options ); - if (!parsedMediaType) { + if (!parsedSchema) { logger.warn( `Could not parse '${allMediaTypes}', because it is not supported. Generation will continue with 'any'. This might lead to errors at runtime.` ); - return { type: 'any' }; + return { schema: { type: 'any' }, mediaType: 'application/json' }; } // There is only one media type if (allMediaTypes.length === 1) { - return parsedMediaType; + return parsedSchema; } return allMediaTypes.every(type => allowedMediaTypes.includes(type)) - ? parsedMediaType - : { anyOf: [parsedMediaType, { type: 'any' }] }; + ? parsedSchema + : { + schema: { anyOf: [parsedSchema.schema, { type: 'any' }] }, + mediaType: parsedSchema.mediaType + }; } } + /** * @internal */ @@ -101,11 +301,17 @@ function getMediaTypeObject( | OpenAPIV3.ResponseObject | undefined, contentType: string[] -): OpenAPIV3.MediaTypeObject | undefined { +): OpenApiMediaTypeObject | undefined { if (bodyOrResponseObject?.content) { - return Object.entries(bodyOrResponseObject.content).find(([key]) => - contentType.includes(key.split(';')[0]) - )?.[1]; + const mediaTypeEntry = Object.entries(bodyOrResponseObject.content).find( + ([key]) => contentType.includes(key.split(';')[0]) + ); + if (mediaTypeEntry) { + return { + ...mediaTypeEntry[1], + mediaType: mediaTypeEntry[0].split(';')[0] + }; + } } return undefined; } diff --git a/packages/openapi-generator/src/parser/operation.ts b/packages/openapi-generator/src/parser/operation.ts index a1ece53c2c..efdb678bb3 100644 --- a/packages/openapi-generator/src/parser/operation.ts +++ b/packages/openapi-generator/src/parser/operation.ts @@ -156,11 +156,11 @@ export function parsePathParameters( * @internal */ export function parseParameters( - pathParameters: OpenAPIV3.ParameterObject[], + parameters: OpenAPIV3.ParameterObject[], refs: OpenApiDocumentRefs, options: ParserOptions ): OpenApiParameter[] { - return pathParameters.map(param => ({ + return parameters.map(param => ({ ...param, originalName: param.name, schema: parseSchema(param.schema, refs, options), diff --git a/packages/openapi-generator/src/parser/request-body.spec.ts b/packages/openapi-generator/src/parser/request-body.spec.ts index b39821d8d2..8308f32c53 100644 --- a/packages/openapi-generator/src/parser/request-body.spec.ts +++ b/packages/openapi-generator/src/parser/request-body.spec.ts @@ -42,6 +42,9 @@ describe('getRequestBody', () => { ) ).toEqual({ schema: { type: 'string' }, + mediaType: 'application/json', + encoding: undefined, + description: undefined, required: false }); }); @@ -70,6 +73,9 @@ describe('getRequestBody', () => { ) ).toEqual({ schema: { ...schema, ...schemaNaming }, + mediaType: 'application/json', + encoding: undefined, + description: undefined, required: true }); }); diff --git a/packages/openapi-generator/src/parser/request-body.ts b/packages/openapi-generator/src/parser/request-body.ts index 46437ab391..a3713f0c41 100644 --- a/packages/openapi-generator/src/parser/request-body.ts +++ b/packages/openapi-generator/src/parser/request-body.ts @@ -16,12 +16,13 @@ export function parseRequestBody( options: ParserOptions ): OpenApiRequestBody | undefined { const resolvedRequestBody = refs.resolveObject(requestBody); - const schema = parseMediaType(resolvedRequestBody, refs, options); - if (schema && resolvedRequestBody) { + const mediaType = parseMediaType(resolvedRequestBody, refs, options); + + if (resolvedRequestBody && mediaType) { return { required: !!resolvedRequestBody.required, description: resolvedRequestBody.description, - schema + ...mediaType }; } } diff --git a/packages/openapi-generator/src/parser/responses.ts b/packages/openapi-generator/src/parser/responses.ts index 574114b4d5..584f124676 100644 --- a/packages/openapi-generator/src/parser/responses.ts +++ b/packages/openapi-generator/src/parser/responses.ts @@ -16,7 +16,7 @@ export function parseResponses( const responseSchemas = Object.entries(responses) .filter(([statusCode]) => statusCode.startsWith('2')) .map(([, response]) => refs.resolveObject(response)) - .map(response => parseMediaType(response, refs, options)) + .map(response => parseMediaType(response, refs, options)?.schema) // Undefined responses are filtered .filter(response => response) as OpenApiSchema[]; if (responseSchemas.length) { diff --git a/packages/openapi-generator/src/parser/schema.ts b/packages/openapi-generator/src/parser/schema.ts index 3eed4f7fb3..c79fe09b33 100644 --- a/packages/openapi-generator/src/parser/schema.ts +++ b/packages/openapi-generator/src/parser/schema.ts @@ -76,7 +76,7 @@ export function parseSchema( } return { - type: getType(schema.type) + type: getType(schema.type, schema.format) }; } diff --git a/packages/openapi-generator/src/parser/type-mapping.spec.ts b/packages/openapi-generator/src/parser/type-mapping.spec.ts index f499bce024..55081df720 100644 --- a/packages/openapi-generator/src/parser/type-mapping.spec.ts +++ b/packages/openapi-generator/src/parser/type-mapping.spec.ts @@ -19,4 +19,12 @@ describe('getType', () => { expect(getType('int')).toEqual('number'); expect(getType('integer')).toEqual('number'); }); + + it('returns Blob for string with format binary', () => { + expect(getType('string', 'binary')).toEqual('Blob'); + }); + + it('returns Blob when type is already Blob', () => { + expect(getType('Blob')).toEqual('Blob'); + }); }); diff --git a/packages/openapi-generator/src/parser/type-mapping.ts b/packages/openapi-generator/src/parser/type-mapping.ts index 4b855d931e..dc56b417cf 100644 --- a/packages/openapi-generator/src/parser/type-mapping.ts +++ b/packages/openapi-generator/src/parser/type-mapping.ts @@ -26,9 +26,10 @@ const typeMapping = { map: 'any', date: 'string', DateTime: 'string', - binary: 'any', - File: 'any', - file: 'any', + binary: 'Blob', + Blob: 'Blob', + File: 'Blob', + file: 'Blob', ByteArray: 'string', UUID: 'string', URI: 'string', @@ -40,10 +41,17 @@ const typeMapping = { /** * Get the mapped TypeScript type for the given original OpenAPI type. * @param originalType - Original OpenAPI type, to get a mapping for. + * @param format - Optional format of the OpenAPI type. * @returns The mapped TypeScript type. * @internal */ -export function getType(originalType: string | undefined): string { +export function getType( + originalType: string | undefined, + format?: string +): string { + if (originalType === 'string' && format === 'binary') { + return 'Blob'; + } const type = originalType ? typeMapping[originalType] : 'any'; if (!type) { logger.verbose( diff --git a/packages/openapi/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 13c6e72763..e202897689 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -146,6 +146,37 @@ describe('openapi-request-builder', () => { ); }); + it('executes a request with multipart body using executeRaw', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + limit: 100 + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + limit: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + const data = new FormData(); + data.append('limit', '100'); + expect(httpClient.executeHttpRequest).toHaveBeenCalledWith( + sanitizeDestination(destination), + { + method: 'post', + middleware: [expect.any(Function)], // this is the csrf token middleware + url: '/test', + headers: { requestConfig: { 'content-type': 'multipart/form-data' } }, + params: { requestConfig: {} }, + data + }, + { fetchCsrfToken: true } + ); + }); + it('executes a request using the timeout', async () => { const delayInResponse = 2000; const slowDestination = { url: 'https://example.com' }; @@ -361,6 +392,400 @@ describe('openapi-request-builder', () => { ); }); + it('executes a request with multipart body and charset encoding', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + textData: 'Hello World' + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + textData: { + contentType: 'text/plain; charset=utf-8', + isImplicit: false, + parsedContentTypes: [ + { type: 'text/plain', parameters: { charset: 'utf-8' } } + ] + } + } + }); + await requestBuilder.executeRaw(destination); + + // Verify that the form data was built correctly with charset handling + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('executes a request with multipart body containing Blob without type', async () => { + const blob = new Blob(['test content']); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + file: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + file: { + contentType: 'application/pdf', + isImplicit: false, + parsedContentTypes: [{ type: 'application/pdf', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('executes a request with multipart body containing multiple content types', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + document: new Blob(['doc'], { type: 'application/pdf' }) + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + document: { + contentType: 'application/pdf, application/msword', + isImplicit: false, + parsedContentTypes: [ + { type: 'application/pdf', parameters: {} }, + { type: 'application/msword', parameters: {} } + ] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('handles Blob content type that differs from encoding specification', async () => { + // Test that a blob with a different content type than expected still gets processed + const blob = new Blob(['test'], { type: 'image/jpeg' }); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + image: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + image: { + contentType: 'image/png', + isImplicit: false, + parsedContentTypes: [{ type: 'image/png', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('does not warn for implicit encoding mismatches', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const blob = new Blob(['test'], { type: 'image/jpeg' }); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + image: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + image: { + contentType: 'image/png', + isImplicit: true, + parsedContentTypes: [{ type: 'image/png', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Content type mismatch') + ); + consoleSpy.mockRestore(); + }); + + it('should preserve null and undefined values in multipart body', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + field1: null, + field2: undefined + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + field1: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + field2: { + contentType: 'application/json', + isImplicit: true, + parsedContentTypes: [{ type: 'application/json', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + const entries = Array.from(formData.entries()) as [string, string | Blob][]; + expect(entries).toContainEqual(['field1', 'null']); + expect(entries).toContainEqual(['field2', 'undefined']); + expect(entries.length).toBe(2); + }); + + it('handles wildcard content types without warnings', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const blob = new Blob(['test'], { type: 'image/jpeg' }); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + file: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + file: { + contentType: 'image/*', + isImplicit: false, + parsedContentTypes: [{ type: 'image/*', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Content type mismatch') + ); + consoleSpy.mockRestore(); + }); + + it('skips null and undefined values in multipart body', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + field1: 'value1', + field2: null, + field3: undefined, + field4: 'value4' + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + field1: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + field2: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + field3: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + field4: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('uses JSON.stringify for application/json content type in multipart', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + jsonData: { key: 'value', nested: { prop: 123 } } + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + jsonData: { + contentType: 'application/json', + isImplicit: true, + parsedContentTypes: [{ type: 'application/json', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('uses String() for non-JSON content types in multipart', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + textData: 12345 + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + textData: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('handles content type with semicolon in complex type checking', async () => { + const blob = new Blob(['test'], { type: 'application/json' }); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + data: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + data: { + contentType: 'application/json; charset=utf-8', + isImplicit: false, + parsedContentTypes: [ + { type: 'application/json', parameters: { charset: 'utf-8' } } + ] + } + } + }); + await requestBuilder.executeRaw(destination); + + const callArgs = httpClient.executeHttpRequest['mock'].calls[0]; + const formData = callArgs[1].data; + expect(formData).toBeInstanceOf(FormData); + }); + + it('applies content type from encoding to Blob without type', async () => { + const blob = new Blob(['file content']); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + fileField: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + fileField: { + contentType: 'image/png', + isImplicit: false, + parsedContentTypes: [{ type: 'image/png', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const formData = httpSpy.mock.calls[0]![1].data!; + const entries = Array.from(formData.entries()) as [string, string | Blob][]; + + expect(entries).toHaveLength(1); + expect(entries[0][0]).toBe('fileField'); + expect(entries[0][1]).toBeInstanceOf(Blob); + expect((entries[0][1] as Blob).type).toBe('image/png'); + }); + + it('creates Blob for charset-encoded text in FormData', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + textData: 'Hello \u4e2d\u6587' + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + textData: { + contentType: 'text/plain; charset=utf-8', + isImplicit: false, + parsedContentTypes: [ + { type: 'text/plain', parameters: { charset: 'utf-8' } } + ] + } + } + }); + await requestBuilder.executeRaw(destination); + + const formData = httpSpy.mock.calls[0]![1].data!; + const entries = Array.from(formData.entries()) as [string, string | Blob][]; + + expect(entries).toHaveLength(1); + expect(entries[0][0]).toBe('textData'); + expect(entries[0][1]).toBeInstanceOf(Blob); + expect((entries[0][1] as Blob).type).toBe('text/plain; charset=utf-8'); + }); + + it('stringifies JSON content type in FormData entries', async () => { + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + jsonData: { key: 'value', nested: { prop: 123 } } + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + jsonData: { + contentType: 'application/json', + isImplicit: true, + parsedContentTypes: [{ type: 'application/json', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const formData = httpSpy.mock.calls[0]![1].data!; + const entries = Array.from(formData.entries()) as [string, string | Blob][]; + + expect(entries).toHaveLength(1); + expect(entries[0][0]).toBe('jsonData'); + expect(entries[0][1]).toBe('{"key":"value","nested":{"prop":123}}'); + }); + + it('handles multiple fields with different types in FormData', async () => { + const blob = new Blob(['file'], { type: 'application/pdf' }); + const requestBuilder = new OpenApiRequestBuilder('post', '/test', { + body: { + textField: 'text value', + numberField: 42, + fileField: blob + }, + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + textField: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + numberField: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] + }, + fileField: { + contentType: 'application/pdf', + isImplicit: false, + parsedContentTypes: [{ type: 'application/pdf', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + const formData = httpSpy.mock.calls[0]![1].data!; + const entries = Array.from(formData.entries()) as [string, string | Blob][]; + const entryMap = new Map(entries); + + expect(entries).toHaveLength(3); + expect(entryMap.get('textField')).toBe('text value'); + expect(entryMap.get('numberField')).toBe('42'); + expect(entryMap.get('fileField')).toBeInstanceOf(Blob); + expect((entryMap.get('fileField') as Blob).type).toBe('application/pdf'); + }); + describe('requestConfig', () => { it('should overwrite default request config with filtered custom request config', async () => { const requestBuilder = new OpenApiRequestBuilder('get', '/test'); diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index eda223b35f..78be20e337 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -1,9 +1,13 @@ /* eslint-disable max-classes-per-file */ // eslint-disable-next-line import/named import { + createLogger, + ErrorWithCause, isNullish, + pickValueIgnoreCase, removeSlashes, - transformVariadicArgumentToArray + transformVariadicArgumentToArray, + unique } from '@sap-cloud-sdk/util'; import { useOrFetchDestination } from '@sap-cloud-sdk/connectivity'; import { @@ -25,6 +29,208 @@ import type { import type { HttpDestinationOrFetchOptions } from '@sap-cloud-sdk/connectivity'; import type { AxiosResponse } from 'axios'; +const logger = createLogger({ + package: 'openapi', + messageContext: 'openapi-request-builder' +}); + +/** + * @internal + */ +interface EncodingMetadata { + contentType: string; + isImplicit: boolean; + parsedContentTypes: { + type: string; + parameters: { [key: string]: string }; + }[]; +} + +/** + * Builder class for constructing FormData from a request body with encoding metadata. + * @internal + */ +class FormDataBuilder { + constructor( + private readonly body: Record, + private readonly encoding: Record + ) {} + + /** + * Build and return a FormData object from the body and encoding metadata. + * @returns The constructed FormData object. + */ + build(): FormData { + const formData = new FormData(); + + for (const [key, value] of Object.entries(this.body ?? {})) { + if (!this?.encoding[key]) { + throw new Error( + `Missing encoding metadata for property '${key}'. ` + + 'This indicates a code generation issue. ' + + 'Please regenerate your API client.' + ); + } + + const metadata = this.encoding[key]; + const allowedTypes = new Set( + metadata.parsedContentTypes.map(ct => ct.type.toLowerCase()) + ); + + const encoded: string | Blob = + value instanceof Blob + ? this.encodeBlob({ key, value }, metadata) + : this.encodeString({ key, value }, metadata, allowedTypes); + + formData.append(key, encoded); + } + + return formData; + } + + private checkIsFlexibleContentType( + metadata: EncodingMetadata, + allowedTypes: Set + ): boolean { + const { contentType: targetContentType, parsedContentTypes } = metadata; + return ( + Boolean(targetContentType) && + (targetContentType.includes('*') || + parsedContentTypes.length > 1 || + allowedTypes.has('any')) + ); + } + + /** + * Encode a Blob value for FormData with appropriate content type handling. + * @param params - The key and value to encode. + * @param params.key - The field name. + * @param params.value - The Blob value to encode. + * @param metadata - Encoding metadata for this field. + * @returns Blob - The encoded Blob value. + */ + private encodeBlob( + { + key, + value + }: { + key: string; + value: Blob; + }, + metadata: EncodingMetadata + ): Blob { + const { + contentType: targetContentType, + isImplicit: targetIsImplicit, + parsedContentTypes + } = metadata; + const allowedTypes = new Set( + parsedContentTypes.map(ct => ct.type.toLowerCase()) + ); + + const isFlexibleContentType = this.checkIsFlexibleContentType( + metadata, + allowedTypes + ); + + // If `Blob` has no type, we use value from the specification unless the target content type is complex (multiple choices or wildcards) + if ( + !value.type && + !isFlexibleContentType && + // If the encoding has additional requirements regarding parameters (e.g. charset) + // we don't want to add a content type that may not meet those requirements, + // even if the main type should match - in that case the user should provide a Blob with appropriate content type themselves + !Object.keys(parsedContentTypes[0].parameters).length + ) { + logger.debug( + `Adding missing content type '${targetContentType}' to Blob for key '${key}' as per encoding specification.` + ); + const withType = new Blob([value], { + type: targetContentType + }); + return withType; + } + + // If `Blob` has a type, we do a surface-level check to warn users about potential mismatches with the specification + // unless the target content type is complex (multiple choices or wildcards) + // or the encoding is implicit (in which case we are more lenient as there was no specific request from the spec) + const valueContentTypeBase = value.type.split(';')[0].trim(); + if ( + // Don't warn about implicit encodings (less likely to be relevant) + !targetIsImplicit && + // We do not handle more complex content types + !isFlexibleContentType && + // Do the actual comparison + valueContentTypeBase.toLowerCase() !== targetContentType.toLowerCase() + ) { + logger.warn( + `Content type mismatch for key '${key}': value has type '${value.type}' but encoding specifies '${targetContentType}'.` + ); + } + return value; + } + + /** + * Encode a string value for FormData with appropriate content type and charset handling. + * @param params - The key and value to encode. + * @param params.key - The field name. + * @param params.value - The value to encode. + * @param metadata - Encoding metadata for this field. + * @param allowedTypes - Set of allowed content types for this field. + * @returns Blob or string - The encoded value. + */ + private encodeString( + { + key, + value + }: { + key: string; + value: any; + }, + metadata: EncodingMetadata, + allowedTypes: Set + ): string | Blob { + // Handle string data + // Only use JSON.stringify for application/json content type - otherwise may unduly escape e.g. stringified XML + // To avoid stringifying pre-stringified JSON, users should use `Blob` or raw `FormData` + const stringValue = allowedTypes.has('application/json') + ? JSON.stringify(value) + : String(value); + + // If a charset is specified in the encoding, we encode the string accordingly (if unambiguous) + const targetCharset = unique( + metadata.parsedContentTypes.map(ct => ct.parameters.charset) + ); + + if (targetCharset.length !== 1 || !targetCharset[0]) { + return stringValue; + } + const targetCharsetValue = targetCharset[0]; + + // Wrap in try-catch to provide better error message if charset encoding fails (e.g. due to unsupported charset or invalid characters for the charset) + let buffer: Buffer; + try { + buffer = Buffer.from(stringValue, targetCharsetValue as BufferEncoding); + } catch (e: any) { + throw new ErrorWithCause( + `Failed to encode form data field '${key}' with charset '${targetCharsetValue}'.`, + e + ); + } + + // Encode as Blob with appropriate content type if unambiguous + const isFlexibleContentType = this.checkIsFlexibleContentType( + metadata, + allowedTypes + ); + const maybeContentType = !isFlexibleContentType + ? { type: metadata.contentType } + : undefined; + const blob = new Blob([buffer], maybeContentType); + return blob; + } +} + /** * Request builder for OpenAPI requests. * @template ResponseT - Type of the response for the request. @@ -152,8 +358,8 @@ export class OpenApiRequestBuilder { } /** - * Get http request config. - * @returns Promise of http request config with origin. + * Get HTTP request config. + * @returns Promise of the HTTP request config with origin. */ protected async requestConfig(): Promise { const defaultConfig = { @@ -162,7 +368,7 @@ export class OpenApiRequestBuilder { headers: this.getHeaders(), params: this.getParameters(), middleware: this._middlewares, - data: this.parameters?.body + data: this.getBody() }; return { ...defaultConfig, @@ -182,6 +388,23 @@ export class OpenApiRequestBuilder { return { requestConfig: this.parameters?.queryParameters || {} }; } + private getBody(): any { + const body = this.parameters?.body; + const contentType = pickValueIgnoreCase( + this.parameters?.headerParameters, + 'content-type' + ); + + // Handle multipart/form-data body unless the body is already a FormData instance + if (contentType === 'multipart/form-data' && !(body instanceof FormData)) { + const encoding = this.parameters!._encoding!; + const builder = new FormDataBuilder(body, encoding); + return builder.build(); + } + + return body; + } + private getPath(): string { const pathParameters = this.parameters?.pathParameters || {}; @@ -220,6 +443,21 @@ export interface OpenApiRequestParameters { * Request body typically used with "create" and "update" operations (POST, PUT, PATCH). */ body?: any; + /** + * Encoding metadata for multipart/form-data properties. + * @internal + */ + _encoding?: Record< + string, + { + contentType: string; + isImplicit: boolean; + parsedContentTypes: { + type: string; + parameters: { [key: string]: string }; + }[]; + } + >; } function isAxiosResponse(val: any): val is AxiosResponse { diff --git a/test-packages/e2e-tests/openapi.js b/test-packages/e2e-tests/openapi.js index 8cedff1181..2e7eabaa8a 100644 --- a/test-packages/e2e-tests/openapi.js +++ b/test-packages/e2e-tests/openapi.js @@ -2,9 +2,13 @@ const OpenAPIBackend = require('openapi-backend').default; const express = require('express'); const SwaggerParser = require('@apidevtools/swagger-parser'); +const multer = require('multer'); const jsf = require('json-schema-faker'); +// Configure multer for multipart/form-data parsing +const upload = multer({ storage: multer.memoryStorage() }); + async function getSchemas() { // SchemaObject const document = await SwaggerParser.dereference( @@ -71,6 +75,25 @@ async function createApi() { } return res.status(400).json({ err: 'Invalid or missing CSRF token.' }); }, + testCasePostMultipartBody: (c, req, res) => { + // For multipart requests, multer populates req.body with text fields + const stringProperty = req.body?.stringProperty; + if (!stringProperty) { + return res.status(400).json({ err: 'stringProperty is required' }); + } + return res.status(201).json({ received: { stringProperty } }); + }, + testCasePatchMultipartBodyWithHeaders: (c, req, res) => { + const stringProperty = req.body?.stringProperty; + const optionalHeader = c.request.headers['optionalheaderparam']; + if (!stringProperty) { + return res.status(400).json({ err: 'stringProperty is required' }); + } + return res.status(200).json({ + received: { stringProperty }, + header: optionalHeader || null + }); + }, validationFail: (c, req, res) => { res.status(400).json({ err: c.validation.errors }); }, @@ -87,6 +110,7 @@ module.exports = { const api = await createApi(); const app = express(); app.use(express.json()); + app.use(upload.any()); // Parse multipart/form-data app.use(async (req, res) => api.handleRequest(req, req, res)); return app; diff --git a/test-packages/e2e-tests/package.json b/test-packages/e2e-tests/package.json index a4ef59d421..9d71b75bb5 100644 --- a/test-packages/e2e-tests/package.json +++ b/test-packages/e2e-tests/package.json @@ -41,6 +41,7 @@ "json-schema-faker": "^0.5.9", "mock-fs": "^5.5.0", "moment": "^2.30.1", + "multer": "^2.0.2", "openapi-backend": "^5.15.0", "pm2": "^6.0.14", "sqlite3": "^5.1.7" diff --git a/test-packages/e2e-tests/test/openapi.spec.ts b/test-packages/e2e-tests/test/openapi.spec.ts index 8a3f85e73f..e149f9cbf1 100644 --- a/test-packages/e2e-tests/test/openapi.spec.ts +++ b/test-packages/e2e-tests/test/openapi.spec.ts @@ -1,6 +1,12 @@ -import { EntityApi } from '@sap-cloud-sdk/test-services-openapi/test-service'; +import { + EntityApi, + TestCaseApi +} from '@sap-cloud-sdk/test-services-openapi/test-service'; import { destination } from './test-util'; -import type { TestEntity } from '@sap-cloud-sdk/test-services-openapi/test-service'; +import type { + TestEntity, + SimpleTestEntity +} from '@sap-cloud-sdk/test-services-openapi/test-service'; // TODO: How do I handle paths in rest requests? // TODO: Transpilation needed + tsconfig needs dom typings @@ -37,6 +43,14 @@ describe('openapi request builder', () => { }).execute(restDestination); expect(response.length).toBeGreaterThanOrEqual(4); }); + + it('executes POST request with multipart body', async () => { + const body: SimpleTestEntity = { stringProperty: 'test multipart value' }; + const response = await TestCaseApi.testCasePostMultipartBody(body) + .skipCsrfTokenFetching() + .execute(restDestination); + expect(response.received.stringProperty).toBe('test multipart value'); + }); }); function countEntities(): Promise { diff --git a/test-packages/test-services-openapi/swagger-yaml-service/default-api.js b/test-packages/test-services-openapi/swagger-yaml-service/default-api.js index ea2bbdfd9f..72cd89e7fa 100644 --- a/test-packages/test-services-openapi/swagger-yaml-service/default-api.js +++ b/test-packages/test-services-openapi/swagger-yaml-service/default-api.js @@ -31,7 +31,8 @@ exports.DefaultApi = { */ patchEntity: (pathParam, body) => new openapi_1.OpenApiRequestBuilder('patch', '/entities/{pathParam}', { pathParameters: { pathParam }, - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.DefaultApi._defaultBasePath) }; //# sourceMappingURL=default-api.js.map \ No newline at end of file diff --git a/test-packages/test-services-openapi/swagger-yaml-service/default-api.js.map b/test-packages/test-services-openapi/swagger-yaml-service/default-api.js.map index 029c4321ef..05ac956710 100644 --- a/test-packages/test-services-openapi/swagger-yaml-service/default-api.js.map +++ b/test-packages/test-services-openapi/swagger-yaml-service/default-api.js.map @@ -1 +1 @@ -{"version":3,"file":"default-api.js","sourceRoot":"","sources":["default-api.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAE/D;;;GAGG;AACU,QAAA,UAAU,GAAG;IACxB,gBAAgB,EAAE,SAAS;IAC3B;;;;;OAKG;IACH,UAAU,EAAE,CAAC,SAAiB,EAAE,eAAyC,EAAE,EAAE,CAC3E,IAAI,+BAAqB,CACvB,MAAM,EACN,uBAAuB,EACvB;QACE,cAAc,EAAE,EAAE,SAAS,EAAE;QAC7B,eAAe;KAChB,EACD,kBAAU,CAAC,gBAAgB,CAC5B;IACH;;;;;OAKG;IACH,WAAW,EAAE,CAAC,SAAiB,EAAE,IAA4B,EAAE,EAAE,CAC/D,IAAI,+BAAqB,CACvB,OAAO,EACP,uBAAuB,EACvB;QACE,cAAc,EAAE,EAAE,SAAS,EAAE;QAC7B,IAAI;KACL,EACD,kBAAU,CAAC,gBAAgB,CAC5B;CACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"default-api.js","sourceRoot":"","sources":["default-api.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAE/D;;;GAGG;AACU,QAAA,UAAU,GAAG;IACxB,gBAAgB,EAAE,SAAS;IAC3B;;;;;OAKG;IACH,UAAU,EAAE,CAAC,SAAiB,EAAE,eAAyC,EAAE,EAAE,CAC3E,IAAI,+BAAqB,CACvB,MAAM,EACN,uBAAuB,EACvB;QACE,cAAc,EAAE,EAAE,SAAS,EAAE;QAC7B,eAAe;KAChB,EACD,kBAAU,CAAC,gBAAgB,CAC5B;IACH;;;;;OAKG;IACH,WAAW,EAAE,CAAC,SAAiB,EAAE,IAA4B,EAAE,EAAE,CAC/D,IAAI,+BAAqB,CACvB,OAAO,EACP,uBAAuB,EACvB;QACE,cAAc,EAAE,EAAE,SAAS,EAAE;QAC7B,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,kBAAU,CAAC,gBAAgB,CAC5B;CACJ,CAAC"} \ No newline at end of file diff --git a/test-packages/test-services-openapi/swagger-yaml-service/default-api.ts b/test-packages/test-services-openapi/swagger-yaml-service/default-api.ts index 0919846b86..106360c096 100644 --- a/test-packages/test-services-openapi/swagger-yaml-service/default-api.ts +++ b/test-packages/test-services-openapi/swagger-yaml-service/default-api.ts @@ -39,7 +39,8 @@ export const DefaultApi = { '/entities/{pathParam}', { pathParameters: { pathParam }, - body + body, + headerParameters: { 'content-type': 'application/json' } }, DefaultApi._defaultBasePath ) diff --git a/test-packages/test-services-openapi/test-service/entity-api.js b/test-packages/test-services-openapi/test-service/entity-api.js index b59ab5f249..44df8a6942 100644 --- a/test-packages/test-services-openapi/test-service/entity-api.js +++ b/test-packages/test-services-openapi/test-service/entity-api.js @@ -27,7 +27,8 @@ exports.EntityApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ updateEntityWithPut: (body) => new openapi_1.OpenApiRequestBuilder('put', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.EntityApi._defaultBasePath), /** * Create entity @@ -35,7 +36,8 @@ exports.EntityApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ createEntity: (body) => new openapi_1.OpenApiRequestBuilder('post', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.EntityApi._defaultBasePath), /** * Create a request builder for execution of patch requests to the '/entities' endpoint. @@ -43,7 +45,8 @@ exports.EntityApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ updateEntity: (body) => new openapi_1.OpenApiRequestBuilder('patch', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.EntityApi._defaultBasePath), /** * Create a request builder for execution of delete requests to the '/entities' endpoint. @@ -51,7 +54,8 @@ exports.EntityApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ deleteEntity: (body) => new openapi_1.OpenApiRequestBuilder('delete', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.EntityApi._defaultBasePath), /** * Head request of entities diff --git a/test-packages/test-services-openapi/test-service/entity-api.js.map b/test-packages/test-services-openapi/test-service/entity-api.js.map index 3be29a091b..39a4bed92e 100644 --- a/test-packages/test-services-openapi/test-service/entity-api.js.map +++ b/test-packages/test-services-openapi/test-service/entity-api.js.map @@ -1 +1 @@ -{"version":3,"file":"entity-api.js","sourceRoot":"","sources":["entity-api.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAE/D;;;GAGG;AACU,QAAA,SAAS,GAAG;IACvB,gBAAgB,EAAE,SAAS;IAC3B;;;;OAIG;IACH,cAAc,EAAE,CAAC,eAShB,EAAE,EAAE,CACH,IAAI,+BAAqB,CACvB,KAAK,EACL,WAAW,EACX;QACE,eAAe;KAChB,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,mBAAmB,EAAE,CAAC,IAA8B,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,KAAK,EACL,WAAW,EACX;QACE,IAAI;KACL,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAA4B,EAAE,EAAE,CAC7C,IAAI,+BAAqB,CACvB,MAAM,EACN,WAAW,EACX;QACE,IAAI;KACL,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAAqC,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,OAAO,EACP,WAAW,EACX;QACE,IAAI;KACL,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAA0B,EAAE,EAAE,CAC3C,IAAI,+BAAqB,CACvB,QAAQ,EACR,WAAW,EACX;QACE,IAAI;KACL,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;OAGG;IACH,YAAY,EAAE,GAAG,EAAE,CACjB,IAAI,+BAAqB,CACvB,MAAM,EACN,WAAW,EACX,EAAE,EACF,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,cAAc,EAAE,CAAC,QAAgB,EAAE,EAAE,CACnC,IAAI,+BAAqB,CACvB,KAAK,EACL,sBAAsB,EACtB;QACE,cAAc,EAAE,EAAE,QAAQ,EAAE;KAC7B,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;OAGG;IACH,aAAa,EAAE,GAAG,EAAE,CAClB,IAAI,+BAAqB,CACvB,KAAK,EACL,iBAAiB,EACjB,EAAE,EACF,iBAAS,CAAC,gBAAgB,CAC3B;CACJ,CAAC"} \ No newline at end of file +{"version":3,"file":"entity-api.js","sourceRoot":"","sources":["entity-api.ts"],"names":[],"mappings":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAE/D;;;GAGG;AACU,QAAA,SAAS,GAAG;IACvB,gBAAgB,EAAE,SAAS;IAC3B;;;;OAIG;IACH,cAAc,EAAE,CAAC,eAShB,EAAE,EAAE,CACH,IAAI,+BAAqB,CACvB,KAAK,EACL,WAAW,EACX;QACE,eAAe;KAChB,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,mBAAmB,EAAE,CAAC,IAA8B,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,KAAK,EACL,WAAW,EACX;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAA4B,EAAE,EAAE,CAC7C,IAAI,+BAAqB,CACvB,MAAM,EACN,WAAW,EACX;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAAqC,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,OAAO,EACP,WAAW,EACX;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,YAAY,EAAE,CAAC,IAA0B,EAAE,EAAE,CAC3C,IAAI,+BAAqB,CACvB,QAAQ,EACR,WAAW,EACX;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;OAGG;IACH,YAAY,EAAE,GAAG,EAAE,CACjB,IAAI,+BAAqB,CACvB,MAAM,EACN,WAAW,EACX,EAAE,EACF,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;;OAIG;IACH,cAAc,EAAE,CAAC,QAAgB,EAAE,EAAE,CACnC,IAAI,+BAAqB,CACvB,KAAK,EACL,sBAAsB,EACtB;QACE,cAAc,EAAE,EAAE,QAAQ,EAAE;KAC7B,EACD,iBAAS,CAAC,gBAAgB,CAC3B;IACH;;;OAGG;IACH,aAAa,EAAE,GAAG,EAAE,CAClB,IAAI,+BAAqB,CACvB,KAAK,EACL,iBAAiB,EACjB,EAAE,EACF,iBAAS,CAAC,gBAAgB,CAC3B;CACJ,CAAC"} \ No newline at end of file diff --git a/test-packages/test-services-openapi/test-service/entity-api.ts b/test-packages/test-services-openapi/test-service/entity-api.ts index 11e8cd8155..35aebec146 100644 --- a/test-packages/test-services-openapi/test-service/entity-api.ts +++ b/test-packages/test-services-openapi/test-service/entity-api.ts @@ -44,7 +44,8 @@ export const EntityApi = { 'put', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, EntityApi._defaultBasePath ), @@ -58,7 +59,8 @@ export const EntityApi = { 'post', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, EntityApi._defaultBasePath ), @@ -72,7 +74,8 @@ export const EntityApi = { 'patch', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, EntityApi._defaultBasePath ), @@ -86,7 +89,8 @@ export const EntityApi = { 'delete', '/entities', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, EntityApi._defaultBasePath ), diff --git a/test-packages/test-services-openapi/test-service/test-case-api.d.ts b/test-packages/test-services-openapi/test-service/test-case-api.d.ts index 357e8b2c65..f9b561de25 100644 --- a/test-packages/test-services-openapi/test-service/test-case-api.d.ts +++ b/test-packages/test-services-openapi/test-service/test-case-api.d.ts @@ -164,6 +164,26 @@ export declare const TestCaseApi: { schemaNameInteger: ( body: Schema123456 | undefined ) => OpenApiRequestBuilder; + /** + * Create a request builder for execution of post requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePostMultipartBody: ( + body: SimpleTestEntity + ) => OpenApiRequestBuilder; + /** + * Create a request builder for execution of patch requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @param headerParameters - Object containing the following keys: optionalHeaderParam. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePatchMultipartBodyWithHeaders: ( + body: SimpleTestEntity, + headerParameters?: { + optionalHeaderParam?: string; + } + ) => OpenApiRequestBuilder; /** * Create a request builder for execution of get requests to the '/test-cases/no-operation-id' endpoint. * @returns The request builder, use the `execute()` method to trigger the request. diff --git a/test-packages/test-services-openapi/test-service/test-case-api.js b/test-packages/test-services-openapi/test-service/test-case-api.js index ffa003604d..4c380658d5 100644 --- a/test-packages/test-services-openapi/test-service/test-case-api.js +++ b/test-packages/test-services-openapi/test-service/test-case-api.js @@ -23,6 +23,7 @@ exports.TestCaseApi = { testCaseGetRequiredParameters: (requiredPathItemPathParam, body, queryParameters) => new openapi_1.OpenApiRequestBuilder('get', '/test-cases/parameters/required-parameters/{requiredPathItemPathParam}', { pathParameters: { requiredPathItemPathParam }, body, + headerParameters: { 'content-type': 'application/json' }, queryParameters }, exports.TestCaseApi._defaultBasePath), /** @@ -35,6 +36,7 @@ exports.TestCaseApi = { testCasePostRequiredParameters: (requiredPathItemPathParam, body, queryParameters) => new openapi_1.OpenApiRequestBuilder('post', '/test-cases/parameters/required-parameters/{requiredPathItemPathParam}', { pathParameters: { requiredPathItemPathParam }, body, + headerParameters: { 'content-type': 'application/json' }, queryParameters }, exports.TestCaseApi._defaultBasePath), /** @@ -44,8 +46,8 @@ exports.TestCaseApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ testCaseRequiredQueryOptionalHeader: (queryParameters, headerParameters) => new openapi_1.OpenApiRequestBuilder('get', '/test-cases/parameters', { - queryParameters, - headerParameters + headerParameters, + queryParameters }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of post requests to the '/test-cases/parameters' endpoint. @@ -56,8 +58,11 @@ exports.TestCaseApi = { */ testCaseOptionalQueryRequiredHeader: (body, queryParameters, headerParameters) => new openapi_1.OpenApiRequestBuilder('post', '/test-cases/parameters', { body, - queryParameters, - headerParameters + headerParameters: { + 'content-type': 'application/json', + ...headerParameters + }, + queryParameters }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of patch requests to the '/test-cases/parameters' endpoint. @@ -68,8 +73,11 @@ exports.TestCaseApi = { */ testCaseOptionalQueryOptionalHeader: (body, queryParameters, headerParameters) => new openapi_1.OpenApiRequestBuilder('patch', '/test-cases/parameters', { body, - queryParameters, - headerParameters + headerParameters: { + 'content-type': 'application/json', + ...headerParameters + }, + queryParameters }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of get requests to the '/test-cases/parameters/{duplicateParam}' endpoint. @@ -117,7 +125,8 @@ exports.TestCaseApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ complexSchemas: (body) => new openapi_1.OpenApiRequestBuilder('get', '/test-cases/complex-schemas', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of post requests to the '/test-cases/complex-schemas' endpoint. @@ -125,7 +134,8 @@ exports.TestCaseApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ useNameWithSymbols: (body) => new openapi_1.OpenApiRequestBuilder('post', '/test-cases/complex-schemas', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of get requests to the '/test-cases/schema-name-integer' endpoint. @@ -133,7 +143,44 @@ exports.TestCaseApi = { * @returns The request builder, use the `execute()` method to trigger the request. */ schemaNameInteger: (body) => new openapi_1.OpenApiRequestBuilder('get', '/test-cases/schema-name-integer', { - body + body, + headerParameters: { 'content-type': 'application/json' } + }, exports.TestCaseApi._defaultBasePath), + /** + * Create a request builder for execution of post requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePostMultipartBody: (body) => new openapi_1.OpenApiRequestBuilder('post', '/test-cases/multipart-body', { + body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] + } + }, + headerParameters: { 'content-type': 'multipart/form-data' } + }, exports.TestCaseApi._defaultBasePath), + /** + * Create a request builder for execution of patch requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @param headerParameters - Object containing the following keys: optionalHeaderParam. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePatchMultipartBodyWithHeaders: (body, headerParameters) => new openapi_1.OpenApiRequestBuilder('patch', '/test-cases/multipart-body', { + body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] + } + }, + headerParameters: { + 'content-type': 'multipart/form-data', + ...headerParameters + } }, exports.TestCaseApi._defaultBasePath), /** * Create a request builder for execution of get requests to the '/test-cases/no-operation-id' endpoint. diff --git a/test-packages/test-services-openapi/test-service/test-case-api.js.map b/test-packages/test-services-openapi/test-service/test-case-api.js.map index 80a212b474..081e64faea 100644 --- a/test-packages/test-services-openapi/test-service/test-case-api.js.map +++ b/test-packages/test-services-openapi/test-service/test-case-api.js.map @@ -1 +1 @@ -{"version":3,"file":"test-case-api.js","sourceRoot":"","sources":["test-case-api.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"test-case-api.js","sourceRoot":"","sources":["test-case-api.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/test-packages/test-services-openapi/test-service/test-case-api.ts b/test-packages/test-services-openapi/test-service/test-case-api.ts index fa9c5a0df6..3910545a45 100644 --- a/test-packages/test-services-openapi/test-service/test-case-api.ts +++ b/test-packages/test-services-openapi/test-service/test-case-api.ts @@ -39,6 +39,7 @@ export const TestCaseApi = { { pathParameters: { requiredPathItemPathParam }, body, + headerParameters: { 'content-type': 'application/json' }, queryParameters }, TestCaseApi._defaultBasePath @@ -66,6 +67,7 @@ export const TestCaseApi = { { pathParameters: { requiredPathItemPathParam }, body, + headerParameters: { 'content-type': 'application/json' }, queryParameters }, TestCaseApi._defaultBasePath @@ -84,8 +86,8 @@ export const TestCaseApi = { 'get', '/test-cases/parameters', { - queryParameters, - headerParameters + headerParameters, + queryParameters }, TestCaseApi._defaultBasePath ), @@ -106,8 +108,11 @@ export const TestCaseApi = { '/test-cases/parameters', { body, - queryParameters, - headerParameters + headerParameters: { + 'content-type': 'application/json', + ...headerParameters + }, + queryParameters }, TestCaseApi._defaultBasePath ), @@ -128,8 +133,11 @@ export const TestCaseApi = { '/test-cases/parameters', { body, - queryParameters, - headerParameters + headerParameters: { + 'content-type': 'application/json', + ...headerParameters + }, + queryParameters }, TestCaseApi._defaultBasePath ), @@ -222,7 +230,8 @@ export const TestCaseApi = { 'get', '/test-cases/complex-schemas', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, TestCaseApi._defaultBasePath ), @@ -236,7 +245,8 @@ export const TestCaseApi = { 'post', '/test-cases/complex-schemas', { - body + body, + headerParameters: { 'content-type': 'application/json' } }, TestCaseApi._defaultBasePath ), @@ -250,7 +260,59 @@ export const TestCaseApi = { 'get', '/test-cases/schema-name-integer', { - body + body, + headerParameters: { 'content-type': 'application/json' } + }, + TestCaseApi._defaultBasePath + ), + /** + * Create a request builder for execution of post requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePostMultipartBody: (body: SimpleTestEntity) => + new OpenApiRequestBuilder( + 'post', + '/test-cases/multipart-body', + { + body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] + } + }, + headerParameters: { 'content-type': 'multipart/form-data' } + }, + TestCaseApi._defaultBasePath + ), + /** + * Create a request builder for execution of patch requests to the '/test-cases/multipart-body' endpoint. + * @param body - Request body. + * @param headerParameters - Object containing the following keys: optionalHeaderParam. + * @returns The request builder, use the `execute()` method to trigger the request. + */ + testCasePatchMultipartBodyWithHeaders: ( + body: SimpleTestEntity, + headerParameters?: { optionalHeaderParam?: string } + ) => + new OpenApiRequestBuilder( + 'patch', + '/test-cases/multipart-body', + { + body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] + } + }, + headerParameters: { + 'content-type': 'multipart/form-data', + ...headerParameters + } }, TestCaseApi._defaultBasePath ), diff --git a/test-resources/openapi-service-specs/specifications/test-service.json b/test-resources/openapi-service-specs/specifications/test-service.json index 94e1bd82f1..e64c68c41c 100644 --- a/test-resources/openapi-service-specs/specifications/test-service.json +++ b/test-resources/openapi-service-specs/specifications/test-service.json @@ -740,6 +740,56 @@ } } } + }, + "/test-cases/multipart-body": { + "post": { + "tags": ["test case"], + "operationId": "testCasePostMultipartBody", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SimpleTestEntity" + } + } + } + }, + "responses": { + "201": { + "description": "no content" + } + } + }, + "patch": { + "tags": ["test case"], + "operationId": "testCasePatchMultipartBodyWithHeaders", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/SimpleTestEntity" + } + } + } + }, + "parameters": [ + { + "name": "optionalHeaderParam", + "in": "header", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "no content" + } + } + } } }, "components": { diff --git a/yarn.lock b/yarn.lock index ed9d1b0374..6b75c32193 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1745,6 +1745,11 @@ dependencies: "@babel/types" "^7.20.7" +"@types/content-type@^1.1.9": + version "1.1.9" + resolved "https://registry.npmjs.org/@types/content-type/-/content-type-1.1.9.tgz#a0240a8141b33549ac0ca6847f4b801580012bca" + integrity sha512-Hq9IMnfekuOCsEmYl4QX2HBrT+XsfXiupfrLLY8Dcf3Puf4BkBOxSbWYTITSOQAhJoYPBez+b4MJRpIYL65z8A== + "@types/eslint@^7.2.13": version "7.29.0" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz#e56ddc8e542815272720bb0b4ccc2aff9c3e1c78" @@ -2377,6 +2382,11 @@ app-module-path@^2.2.0: resolved "https://registry.npmjs.org/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" integrity sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ== +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + "aproba@^1.0.3 || ^2.0.0": version "2.0.0" resolved "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" @@ -2869,6 +2879,13 @@ buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +busboy@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -3217,6 +3234,16 @@ concat-map@0.0.1: resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" + integrity sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.0.2" + typedarray "^0.0.6" + console-control-strings@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" @@ -6792,7 +6819,7 @@ mkdirp@1.0.4, mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mkdirp@^0.5.1: +mkdirp@^0.5.1, mkdirp@^0.5.6: version "0.5.6" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -6854,6 +6881,19 @@ ms@2.1.3, ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multer@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz#08a8aa8255865388c387aaf041426b0c87bf58dd" + integrity sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw== + dependencies: + append-field "^1.0.0" + busboy "^1.6.0" + concat-stream "^2.0.0" + mkdirp "^0.5.6" + object-assign "^4.1.1" + type-is "^1.6.18" + xtend "^4.0.2" + multimatch@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz#932b800963cea7a31a033328fa1e0c3a1874dbe6" @@ -7131,6 +7171,11 @@ oas-validator@^5.0.8: should "^13.2.1" yaml "^1.10.0" +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + object-deep-merge@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/object-deep-merge/-/object-deep-merge-2.0.0.tgz#94d24cf713d4a7a143653500ff4488a2d494681f" @@ -8032,7 +8077,7 @@ read@^1.0.4: dependencies: mute-stream "~0.0.4" -readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0, readable-stream@^3.6.2: +readable-stream@^3.0.2, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.6.0, readable-stream@^3.6.2: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -8785,6 +8830,11 @@ stream-to-array@^2.3.0: dependencies: any-promise "^1.1.0" +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + streamx@^2.15.0, streamx@^2.21.0: version "2.22.1" resolved "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz#c97cbb0ce18da4f4db5a971dc9ab68ff5dc7f5a5" @@ -9357,6 +9407,14 @@ type-fest@^4.41.0: resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== +type-is@^1.6.18, type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + type-is@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" @@ -9366,14 +9424,6 @@ type-is@^2.0.1: media-typer "^1.1.0" mime-types "^3.0.0" -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - typed-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" @@ -9424,6 +9474,11 @@ typed-query-selector@^2.12.0: resolved "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz#92b65dbc0a42655fccf4aeb1a08b1dddce8af5f2" integrity sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg== +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + typedoc@^0.28.17: version "0.28.17" resolved "https://registry.npmjs.org/typedoc/-/typedoc-0.28.17.tgz#eab7c6649494d0a796e0b2fd2c9a5aea41b0a781" @@ -9848,6 +9903,11 @@ xml@^1.0.1: resolved "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" integrity sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw== +xtend@^4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"