From a0419da1c614ffd32a6671fe97d188ab4101e70a Mon Sep 17 00:00:00 2001 From: Marika Marszalkowski Date: Tue, 27 Jan 2026 11:29:32 +0100 Subject: [PATCH 01/15] support multipart --- .../src/file-serializer/operation.ts | 12 +++- .../openapi-generator/src/openapi-types.ts | 13 ++++ .../src/parser/media-type.ts | 48 +++++++++----- .../openapi-generator/src/parser/operation.ts | 4 +- .../src/parser/request-body.ts | 7 +- .../openapi-generator/src/parser/responses.ts | 2 +- .../src/openapi-request-builder.spec.ts | 24 +++++++ .../openapi/src/openapi-request-builder.ts | 23 ++++++- .../swagger-yaml-service/default-api.js | 3 +- .../swagger-yaml-service/default-api.js.map | 2 +- .../swagger-yaml-service/default-api.ts | 3 +- .../test-service/entity-api.js | 12 ++-- .../test-service/entity-api.js.map | 2 +- .../test-service/entity-api.ts | 12 ++-- .../test-service/test-case-api.d.ts | 20 ++++++ .../test-service/test-case-api.js | 51 +++++++++++--- .../test-service/test-case-api.js.map | 2 +- .../test-service/test-case-api.ts | 66 ++++++++++++++++--- .../specifications/test-service.json | 50 ++++++++++++++ 19 files changed, 296 insertions(+), 60 deletions(-) diff --git a/packages/openapi-generator/src/file-serializer/operation.ts b/packages/openapi-generator/src/file-serializer/operation.ts index 593e650afd..2a59c6d38e 100644 --- a/packages/openapi-generator/src/file-serializer/operation.ts +++ b/packages/openapi-generator/src/file-serializer/operation.ts @@ -121,13 +121,19 @@ function serializeParamsForRequestBuilder( } if (operation.requestBody) { params.push('body'); + const contentTypeStr = `'content-type': '${operation.requestBody.mediaType}'`; + if (operation.headerParameters.length) { + params.push(`headerParameters: {${contentTypeStr}, ...headerParameters}`); + } else { + params.push(`headerParameters: {${contentTypeStr}}`); + } + } else if (operation.headerParameters.length) { + params.push('headerParameters'); } 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..1ac08f63b8 100644 --- a/packages/openapi-generator/src/openapi-types.ts +++ b/packages/openapi-generator/src/openapi-types.ts @@ -153,12 +153,25 @@ export interface OpenApiRequestBody { */ schema: OpenApiSchema; + /** + * Media type of the body. + */ + mediaType: string; + /** * Description of the body. */ description?: 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/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index 212fa080b7..1523f7a677 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sap-cloud-sdk/util'; 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,6 +11,7 @@ const allowedMediaTypes = [ 'application/merge-patch+json', 'application/octet-stream', 'text/plain', + 'multipart/form-data', '*/*' ]; /** @@ -28,15 +29,18 @@ export function parseTopLevelMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): OpenApiSchema | undefined { +): { schema: OpenApiSchema; mediaType: string } | undefined { if (bodyOrResponseObject) { - const mediaType = getMediaTypeObject( + const mediaTypeObject = getMediaTypeObject( bodyOrResponseObject, allowedMediaTypes ); - const schema = mediaType?.schema; - if (schema) { - return parseSchema(schema, refs, options); + + if (mediaTypeObject) { + return { + schema: parseSchema(mediaTypeObject.schema, refs, options), + mediaType: mediaTypeObject.mediaType + }; } } } @@ -51,32 +55,36 @@ export function parseMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): OpenApiSchema | undefined { +): { schema: OpenApiSchema; mediaType: string } | 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 +109,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.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/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 13c6e72763..621dd28e3d 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -146,6 +146,30 @@ 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' } + }); + 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' }; diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index eda223b35f..91df01c5d4 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -2,6 +2,7 @@ // eslint-disable-next-line import/named import { isNullish, + pickValueIgnoreCase, removeSlashes, transformVariadicArgumentToArray } from '@sap-cloud-sdk/util'; @@ -152,8 +153,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 +163,7 @@ export class OpenApiRequestBuilder { headers: this.getHeaders(), params: this.getParameters(), middleware: this._middlewares, - data: this.parameters?.body + data: this.getBody() }; return { ...defaultConfig, @@ -182,6 +183,22 @@ export class OpenApiRequestBuilder { return { requestConfig: this.parameters?.queryParameters || {} }; } + private getBody(): any { + const body = this.parameters?.body; + if ( + pickValueIgnoreCase(this.parameters?.headerParameters, 'content-type') === + 'multipart/form-data' + ) { + const formData = new FormData(); + for (const key in body) { + // TODO: is it enough to just append the body[key] or does it need to be stringified? + formData.append(key, body[key]); + } + return formData; + } + return body; + } + private getPath(): string { const pathParameters = this.parameters?.pathParameters || {}; 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..38c7d94e66 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,30 @@ 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, + 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, + 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..fd238172f3 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":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAO/D;;;GAGG;AACU,QAAA,WAAW,GAAG;IACzB,gBAAgB,EAAE,SAAS;IAC3B;;;;;;OAMG;IACH,6BAA6B,EAAE,CAC7B,yBAAiC,EACjC,IAAkC,EAClC,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,8BAA8B,EAAE,CAC9B,yBAAiC,EACjC,IAAsB,EACtB,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,mCAAmC,EAAE,CACnC,eAA+C,EAC/C,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wBAAwB,EACxB;QACE,eAAe;QACf,gBAAgB;KACjB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAgD,EAChD,gBAAiD,EACjD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wBAAwB,EACxB;QACE,IAAI;QACJ,eAAe;QACf,gBAAgB;KACjB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAiD,EACjD,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,OAAO,EACP,wBAAwB,EACxB;QACE,IAAI;QACJ,eAAe;QACf,gBAAgB;KACjB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,8BAA8B,EAAE,CAC9B,cAAsB,EACtB,eAA2C,EAC3C,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,yCAAyC,EACzC;QACE,cAAc,EAAE,EAAE,cAAc,EAAE;QAClC,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,oBAAoB,EAAE,GAAG,EAAE,CACzB,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,uBAAuB,EAAE,GAAG,EAAE,CAC5B,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,sBAAsB,EAAE,GAAG,EAAE,CAC3B,IAAI,+BAAqB,CACvB,MAAM,EACN,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,qBAAqB,EAAE,GAAG,EAAE,CAC1B,IAAI,+BAAqB,CACvB,OAAO,EACP,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,MAAM,EAAE,CAAC,MAAc,EAAE,eAAkC,EAAE,EAAE,CAC7D,IAAI,+BAAqB,CACvB,KAAK,EACL,wCAAwC,EACxC;QACE,cAAc,EAAE,EAAE,MAAM,EAAE;QAC1B,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,cAAc,EAAE,CAAC,IAAmC,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B;QACE,IAAI;KACL,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,IAA6C,EAAE,EAAE,CACpE,IAAI,+BAAqB,CACvB,MAAM,EACN,6BAA6B,EAC7B;QACE,IAAI;KACL,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,IAA8B,EAAE,EAAE,CACpD,IAAI,+BAAqB,CACvB,KAAK,EACL,iCAAiC,EACjC;QACE,IAAI;KACL,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,yBAAyB,EAAE,GAAG,EAAE,CAC9B,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;CACJ,CAAC"} \ 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..40c2cdc62a 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,45 @@ 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, + 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, + 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": { From 2142e05d1ca2208bd9c133fcbfb59e3e2c019d93 Mon Sep 17 00:00:00 2001 From: Marika Marszalkowski Date: Tue, 27 Jan 2026 11:33:06 +0100 Subject: [PATCH 02/15] add changeset --- .changeset/hip-times-draw.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/hip-times-draw.md 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". From 6d8f8f9ddf3c934508dd2adc3ade9260ffbedca2 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Thu, 5 Feb 2026 16:52:21 +0100 Subject: [PATCH 03/15] wip --- packages/openapi-generator/package.json | 8 +- .../__snapshots__/api-file.spec.ts.snap | 12 +- .../src/file-serializer/operation.spec.ts | 166 ++--- .../src/file-serializer/operation.ts | 24 +- .../openapi-generator/src/openapi-types.ts | 16 + .../src/parser/media-type.spec.ts | 665 +++++++++++++++++- .../src/parser/media-type.ts | 194 ++++- .../src/parser/request-body.spec.ts | 6 + .../openapi-generator/src/parser/schema.ts | 64 +- .../src/parser/type-mapping.ts | 21 +- packages/openapi/package.json | 4 +- .../src/openapi-request-builder.spec.ts | 257 ++++++- .../openapi/src/openapi-request-builder.ts | 159 ++++- yarn.lock | 7 +- 14 files changed, 1450 insertions(+), 153 deletions(-) diff --git a/packages/openapi-generator/package.json b/packages/openapi-generator/package.json index 2a8c0f74ae..3030702bb4 100644 --- a/packages/openapi-generator/package.json +++ b/packages/openapi-generator/package.json @@ -29,27 +29,29 @@ "directory": "packages/openapi-generator" }, "scripts": { - "compile": "tsc -b", - "prepublishOnly": "yarn compile && yarn readme", + "compile": "tsc -b",, "test": "yarn test:unit", "test:unit": "yarn node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", "coverage": "jest --coverage", "lint": "eslint --ext .ts . && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -c", "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck . --ignores='@sap-cloud-sdk/openapi'", - "readme": "ts-node ../../scripts/replace-common-readme.ts" + "readme": "ts-node ../../scripts/replace-common-readme.ts", + "prepack": "tsc -b" }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@sap-cloud-sdk/generator-common": "^4.3.1", "@sap-cloud-sdk/openapi": "^4.3.1", "@sap-cloud-sdk/util": "^4.3.1", + "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 2a59c6d38e..f924cabdc8 100644 --- a/packages/openapi-generator/src/file-serializer/operation.ts +++ b/packages/openapi-generator/src/file-serializer/operation.ts @@ -121,11 +121,25 @@ function serializeParamsForRequestBuilder( } if (operation.requestBody) { params.push('body'); - const contentTypeStr = `'content-type': '${operation.requestBody.mediaType}'`; - if (operation.headerParameters.length) { - params.push(`headerParameters: {${contentTypeStr}, ...headerParameters}`); - } else { - params.push(`headerParameters: {${contentTypeStr}}`); + if ( + operation.requestBody.encoding && + Object.keys(operation.requestBody.encoding).length > 0 + ) { + params.push( + `_encoding: ${JSON.stringify(operation.requestBody.encoding)}` + ); + } + if (operation.requestBody.mediaType) { + const contentTypeStr = `'content-type': '${operation.requestBody.mediaType}'`; + if (operation.headerParameters.length) { + params.push( + `headerParameters: {${contentTypeStr}, ...headerParameters}` + ); + } else { + params.push(`headerParameters: {${contentTypeStr}}`); + } + } else if (operation.headerParameters.length) { + params.push('headerParameters'); } } else if (operation.headerParameters.length) { params.push('headerParameters'); diff --git a/packages/openapi-generator/src/openapi-types.ts b/packages/openapi-generator/src/openapi-types.ts index 1ac08f63b8..b4c7da3746 100644 --- a/packages/openapi-generator/src/openapi-types.ts +++ b/packages/openapi-generator/src/openapi-types.ts @@ -162,6 +162,22 @@ export interface OpenApiRequestBody { * 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; + contentTypeParsed: { + type: string; + parameters: { [key: string]: string }; + }[]; + } + >; } /** diff --git a/packages/openapi-generator/src/parser/media-type.spec.ts b/packages/openapi-generator/src/parser/media-type.spec.ts index 933d90b873..4bb7b811c8 100644 --- a/packages/openapi-generator/src/parser/media-type.spec.ts +++ b/packages/openapi-generator/src/parser/media-type.spec.ts @@ -30,7 +30,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 +48,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 +66,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 +86,618 @@ describe('parseTopLevelMediaType', () => { await createTestRefs(), defaultOptions ) - ).toEqual({ type: 'string' }); + ).toEqual({ + schema: { type: 'string' }, + 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 result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + profileImage: { type: 'string', format: 'binary' } + } + }, + encoding: { + profileImage: { + contentType: 'image/png, image/jpeg' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + profileImage: { + contentType: 'image/png, image/jpeg', + isImplicit: false, + contentTypeParsed: [ + { type: 'image/png', parameters: {} }, + { type: 'image/jpeg', parameters: {} } + ] + } + }); + }); + + it('returns undefined encoding when encoding object is empty', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { type: 'object' } + } + } + }, + await createTestRefs(), + defaultOptions + ); + expect(result?.encoding).toBeUndefined(); + }); + + it('maps string with format binary to Blob type for multipart/form-data', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + metadata: { type: 'string' } + } + } + } + } + }, + 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: { + contentType: 'application/octet-stream', + isImplicit: true, + contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] + }, + metadata: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + } + }); + }); + + it('respects explicit encoding over auto-inferred encoding for binary properties', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + image: { type: 'string', format: 'binary' } + } + }, + encoding: { + image: { + contentType: 'image/png' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + image: { + contentType: 'image/png', + isImplicit: false, + contentTypeParsed: [{ type: 'image/png', parameters: {} }] + } + }); + }); + + it('auto-infers text/plain for primitive types in multipart/form-data', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' }, + score: { type: 'number' }, + active: { type: 'boolean' } + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + name: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + age: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + score: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + active: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + } + }); + }); + + it('auto-infers content type for arrays based on item type in multipart/form-data', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + tags: { type: 'array', items: { type: 'string' } }, + files: { + type: 'array', + items: { type: 'string', format: 'binary' } + } + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + tags: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + files: { + contentType: 'application/octet-stream', + isImplicit: true, + contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] + } + }); + }); + + it('auto-infers application/json for object types in multipart/form-data', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + metadata: { + type: 'object', + properties: { key: { type: 'string' } } + } + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + metadata: { + contentType: 'application/json', + isImplicit: true, + contentTypeParsed: [{ type: 'application/json', parameters: {} }] + } + }); + }); + + it('handles multipart/form-data with $ref schema', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + $ref: '#/components/schemas/Body_predict_parquet' + } + } + } + }, + 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' } } + } + } + } + } + }), + 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: { + contentType: 'application/octet-stream', + isImplicit: true, + contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] + }, + target_columns: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + metadata: { + contentType: 'application/json', + isImplicit: true, + contentTypeParsed: [{ type: 'application/json', parameters: {} }] + } + }); + }); + + it('handles multipart/form-data with $ref schema containing nested $refs', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + $ref: '#/components/schemas/FormData' + } + } + } + }, + await createTestRefs({ + schemas: { + FormData: { + type: 'object', + properties: { + image: { $ref: '#/components/schemas/ImageFile' }, + description: { type: 'string' } + } + }, + ImageFile: { + type: 'string', + format: 'binary' + } + } + }), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + image: { + contentType: 'application/octet-stream', + isImplicit: true, + contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] + }, + description: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + } + }); + }); + + it('parses content type with charset parameter', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + textData: { type: 'string' } + } + }, + encoding: { + textData: { + contentType: 'text/plain; charset=utf-8' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + textData: { + contentType: 'text/plain; charset=utf-8', + isImplicit: false, + contentTypeParsed: [ + { + 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, + contentTypeParsed: [ + { type: 'application/pdf', parameters: {} }, + { type: 'application/msword', parameters: {} }, + { type: 'text/plain', parameters: {} } + ] + } + }); + }); + + it('handles content type with multiple parameters', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + xmlData: { type: 'string' } + } + }, + encoding: { + xmlData: { + contentType: 'application/xml; charset=iso-8859-1; boundary=something' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + xmlData: { + contentType: 'application/xml; charset=iso-8859-1; boundary=something', + isImplicit: false, + contentTypeParsed: [ + { + type: 'application/xml', + parameters: { + charset: 'iso-8859-1', + boundary: 'something' + } + } + ] + } + }); + }); + + it('combines explicit encoding with charset and auto-inferred encoding', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + customText: { type: 'string' }, + normalField: { type: 'string' } + } + }, + encoding: { + customText: { + contentType: 'text/plain; charset=utf-16' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + customText: { + contentType: 'text/plain; charset=utf-16', + isImplicit: false, + contentTypeParsed: [ + { + type: 'text/plain', + parameters: { charset: 'utf-16' } + } + ] + }, + normalField: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + } + }); + }); + + it('throws error with malformed content type', async () => { + const refs = await createTestRefs(); + expect(() => + parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' } + } + }, + encoding: { + file: { + contentType: 'image/png;;invalid' + } + } + } + } + }, + refs, + defaultOptions + ) + ).toThrow(/invalid content-type.*image\/png;;invalid.*file/i); + }); + + it('handles wildcard content types correctly', async () => { + const result = parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + data: { type: 'string', format: 'binary' } + } + }, + encoding: { + data: { + contentType: 'image/*' + } + } + } + } + }, + await createTestRefs(), + defaultOptions + ); + + expect(result?.encoding).toEqual({ + data: { + contentType: 'image/*', + isImplicit: false, + contentTypeParsed: [ + { + type: 'image/*', + parameters: {} + } + ] + } + }); + }); + + it('throws error with completely invalid content type format', async () => { + const refs = await createTestRefs(); + expect(() => + parseTopLevelMediaType( + { + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + attachment: { type: 'string', format: 'binary' } + } + }, + encoding: { + attachment: { + contentType: 'not-a-valid-content-type-at-all' + } + } + } + } + }, + refs, + defaultOptions + ) + ).toThrow(/invalid content-type.*not-a-valid-content-type-at-all.*attachment/i); }); }); @@ -94,7 +717,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 +732,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 +750,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 +768,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 +787,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 +806,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 1523f7a677..e9b50e9fc5 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -1,4 +1,6 @@ -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 { parse } from '@apidevtools/swagger-parser'; import { parseSchema } from './schema'; import type { OpenAPIV3 } from 'openapi-types'; import type { OpenApiMediaTypeObject, OpenApiSchema } from '../openapi-types'; @@ -14,6 +16,150 @@ const allowedMediaTypes = [ 'multipart/form-data', '*/*' ]; + +/** + * Parse encoding object from a media type, extracting contentType for each property. + * Also automatically infers content types for properties with binary format. + * @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 contentType, or undefined. + * @internal + */ +function parseEncoding( + mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, + refs: OpenApiDocumentRefs +): Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } +> | undefined { + const explicitEncoding: Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } + > = mediaTypeObject?.encoding + ? Object.entries(mediaTypeObject.encoding).reduce( + (acc, [propName, encodingObj]) => { + if (encodingObj.contentType) { + // OpenAPI allows comma-separated content types + const contentTypes = encodingObj.contentType.split(',').map(ct => ct.trim()); + const contentTypeParsed: ParsedMediaType[] = []; + + for (const ct of contentTypes) { + try { + contentTypeParsed.push(parseContentType(ct)); + } catch (error) { + 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 + ); + } + } + + return { + ...acc, + [propName]: { + contentType: encodingObj.contentType, + isImplicit: false, + contentTypeParsed + } + }; + } + return acc; + }, + {} + ) + : {}; + + // Auto-infer content types based on schema types + const schema = mediaTypeObject?.schema; + const autoEncoding: Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } + > = {}; + + if (!schema) { + const joined = { ...autoEncoding, ...explicitEncoding }; + return Object.keys(joined).length > 0 ? joined : undefined; + } + + // Resolve $ref if present + const resolvedSchema = refs.resolveObject(schema); + + if ('properties' in resolvedSchema && resolvedSchema.properties) { + Object.entries(resolvedSchema.properties).forEach( + ([propName, propSchema]) => { + // Skip if already has explicit encoding + if (explicitEncoding[propName]) { + return; + } + + if (!propSchema || typeof propSchema !== 'object') { + return; + } + + // Resolve $ref for property schema + const resolvedPropSchema = refs.resolveObject(propSchema); + + const 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'; + }; + + if (!('$ref' in resolvedPropSchema)) { + const contentType = inferContentTypeFromSchema(resolvedPropSchema); + if (contentType) { + const contentTypeParsed = parseContentType(contentType); + autoEncoding[propName] = { + contentType, + isImplicit: true, + contentTypeParsed: [contentTypeParsed] + }; + } + } + } + ); + } + + const combined = { ...autoEncoding, ...explicitEncoding }; + return Object.keys(combined).length > 0 ? combined : 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. @@ -29,7 +175,20 @@ export function parseTopLevelMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): { schema: OpenApiSchema; mediaType: string } | undefined { +): + | { + schema: OpenApiSchema; + mediaType: string; + encoding?: Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } + >; + } + | undefined { if (bodyOrResponseObject) { const mediaTypeObject = getMediaTypeObject( bodyOrResponseObject, @@ -37,9 +196,19 @@ export function parseTopLevelMediaType( ); if (mediaTypeObject) { + const encoding = mediaTypeObject.mediaType.startsWith('multipart/') + ? parseEncoding(mediaTypeObject, refs) + : undefined; + return { - schema: parseSchema(mediaTypeObject.schema, refs, options), - mediaType: mediaTypeObject.mediaType + schema: parseSchema( + mediaTypeObject.schema, + refs, + options, + mediaTypeObject.mediaType + ), + mediaType: mediaTypeObject.mediaType, + encoding }; } } @@ -55,7 +224,20 @@ export function parseMediaType( | undefined, refs: OpenApiDocumentRefs, options: ParserOptions -): { schema: OpenApiSchema; mediaType: string } | undefined { +): + | { + schema: OpenApiSchema; + mediaType: string; + encoding?: Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } + >; + } + | undefined { const allMediaTypes = getMediaTypes(bodyOrResponseObject); if (allMediaTypes.length) { const parsedSchema = parseTopLevelMediaType( @@ -68,7 +250,7 @@ export function parseMediaType( logger.warn( `Could not parse '${allMediaTypes}', because it is not supported. Generation will continue with 'any'. This might lead to errors at runtime.` ); - return { schema: { type: 'any' }, mediaType: 'application/json ' }; + return { schema: { type: 'any' }, mediaType: 'application/json' }; } // There is only one media type 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/schema.ts b/packages/openapi-generator/src/parser/schema.ts index 3eed4f7fb3..e0756323ce 100644 --- a/packages/openapi-generator/src/parser/schema.ts +++ b/packages/openapi-generator/src/parser/schema.ts @@ -22,13 +22,15 @@ const logger = createLogger('openapi-generator'); * @param schema - Originally provided schema or reference object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. + * @param mediaType - Optional media type context for proper type mapping (e.g., 'multipart/form-data'). * @returns The parsed schema. * @internal */ export function parseSchema( schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined, refs: OpenApiDocumentRefs, - options: ParserOptions + options: ParserOptions, + mediaType?: string ): OpenApiSchema { if (!schema) { logger.verbose("No schema provided, continuing with 'any'."); @@ -40,7 +42,7 @@ export function parseSchema( } if (schema.type === 'array') { - return parseArraySchema(schema, refs, options); + return parseArraySchema(schema, refs, options, mediaType); } if (schema.enum?.length) { @@ -48,15 +50,15 @@ export function parseSchema( } if (schema.oneOf?.length || schema.discriminator) { - return parseXOfSchema(schema, refs, 'oneOf', options); + return parseXOfSchema(schema, refs, 'oneOf', options, mediaType); } if (schema.allOf?.length) { - return parseXOfSchema(schema, refs, 'allOf', options); + return parseXOfSchema(schema, refs, 'allOf', options, mediaType); } if (schema.anyOf?.length) { - return parseXOfSchema(schema, refs, 'anyOf', options); + return parseXOfSchema(schema, refs, 'anyOf', options, mediaType); } // An object schema should be parsed after allOf, anyOf, oneOf. @@ -66,17 +68,17 @@ export function parseSchema( schema.properties || schema.additionalProperties ) { - return parseObjectSchema(schema, refs, options); + return parseObjectSchema(schema, refs, options, mediaType); } if (schema.not) { return { - not: parseSchema(schema.not, refs, options) + not: parseSchema(schema.not, refs, options, mediaType) }; } return { - type: getType(schema.type) + type: getType(schema.type, schema.format, mediaType) }; } @@ -95,16 +97,18 @@ function parseReferenceSchema( * @param schema - Original schema representing an array. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. + * @param mediaType - Optional media type context for proper type mapping. * @returns The recursively parsed array schema. */ function parseArraySchema( schema: OpenAPIV3.ArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions + options: ParserOptions, + mediaType?: string ): OpenApiArraySchema { return { uniqueItems: schema.uniqueItems, - items: parseSchema(schema.items, refs, options) + items: parseSchema(schema.items, refs, options, mediaType) }; } @@ -114,17 +118,24 @@ function parseArraySchema( * @param schema - Original schema representing an object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. + * @param mediaType - Optional media type context for proper type mapping. * @returns The recursively parsed object schema. */ export function parseObjectSchema( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions + options: ParserOptions, + mediaType?: string ): OpenApiObjectSchema { if (schema.discriminator) { - return parseXOfSchema(schema, refs, 'oneOf', options); + return parseXOfSchema(schema, refs, 'oneOf', options, mediaType); } - const properties = parseObjectSchemaProperties(schema, refs, options); + const properties = parseObjectSchemaProperties( + schema, + refs, + options, + mediaType + ); if (schema.additionalProperties === false) { if (!properties.length) { @@ -139,7 +150,7 @@ export function parseObjectSchema( const additionalProperties = typeof schema.additionalProperties === 'object' && Object.keys(schema.additionalProperties).length - ? parseSchema(schema.additionalProperties, refs, options) + ? parseSchema(schema.additionalProperties, refs, options, mediaType) : { type: 'any' }; return { @@ -153,18 +164,20 @@ export function parseObjectSchema( * @param schema - Original schema representing an object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. + * @param mediaType - Optional media type context for proper type mapping. * @returns The list of parsed property schemas. */ function parseObjectSchemaProperties( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions + options: ParserOptions, + mediaType?: string ): OpenApiObjectSchemaProperty[] { return Object.entries(schema.properties || {}).reduce( (props, [propName, propSchema]) => [ ...props, { - schema: parseSchema(propSchema, refs, options), + schema: parseSchema(propSchema, refs, options, mediaType), description: isReferenceObject(propSchema) ? undefined : propSchema.description, @@ -222,13 +235,15 @@ function getEnumStringValue(input: string): string { * @param refs - Object representing cross references throughout the document. * @param xOf - Key to identify which schema to parse. * @param options - Options that were set for service generation. + * @param mediaType - Optional media type context for proper type mapping. * @returns The parsed schema based on the given key. */ function parseXOfSchema( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, xOf: 'oneOf' | 'allOf' | 'anyOf', - options: ParserOptions + options: ParserOptions, + mediaType?: string ): any { const normalizedSchema = normalizeSchema(schema, xOf); @@ -245,7 +260,8 @@ function parseXOfSchema( ] }, refs, - options + options, + mediaType ) ) }; @@ -253,7 +269,7 @@ function parseXOfSchema( if (schema.discriminator && xOf !== 'allOf') { return { ...xOfSchema, - discriminator: parseDiscriminator(schema, refs, xOf, options) + discriminator: parseDiscriminator(schema, refs, xOf, options, mediaType) }; } @@ -264,7 +280,8 @@ function parseDiscriminator( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, xOf: 'oneOf' | 'anyOf', - options: ParserOptions + options: ParserOptions, + mediaType?: string ): OpenApiDiscriminator { const { discriminator } = schema; @@ -281,7 +298,12 @@ function parseDiscriminator( mapping: Object.entries(discriminatorMapping).reduce( (mapping, [propertyValue, schemaMapping]) => ({ ...mapping, - [propertyValue]: parseSchema({ $ref: schemaMapping }, refs, options) + [propertyValue]: parseSchema( + { $ref: schemaMapping }, + refs, + options, + mediaType + ) }), {} ) diff --git a/packages/openapi-generator/src/parser/type-mapping.ts b/packages/openapi-generator/src/parser/type-mapping.ts index 4b855d931e..ff16849fd2 100644 --- a/packages/openapi-generator/src/parser/type-mapping.ts +++ b/packages/openapi-generator/src/parser/type-mapping.ts @@ -26,9 +26,9 @@ const typeMapping = { map: 'any', date: 'string', DateTime: 'string', - binary: 'any', - File: 'any', - file: 'any', + binary: 'Blob', + File: 'Blob', + file: 'Blob', ByteArray: 'string', UUID: 'string', URI: 'string', @@ -40,10 +40,23 @@ 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. + * @param requestMediaType - Optional media type of the request. * @returns The mapped TypeScript type. * @internal */ -export function getType(originalType: string | undefined): string { +export function getType( + originalType: string | undefined, + format?: string, + requestMediaType?: string +): string { + if ( + originalType === 'string' && + format === 'binary' && + requestMediaType === 'multipart/form-data' + ) { + return 'Blob'; + } const type = originalType ? typeMapping[originalType] : 'any'; if (!type) { logger.verbose( diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 58a5f8104b..74e841d1e8 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -30,14 +30,14 @@ }, "scripts": { "compile": "tsc -b", - "prepublishOnly": "yarn compile && yarn readme", "test": "yarn test:unit", "test:unit": "jest", "coverage": "jest --coverage", "lint": "eslint --ext .ts . && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -c", "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck .", - "readme": "ts-node ../../scripts/replace-common-readme.ts" + "readme": "ts-node ../../scripts/replace-common-readme.ts", + "prepack": "tsc -b" }, "dependencies": { "@sap-cloud-sdk/connectivity": "^4.3.1", diff --git a/packages/openapi/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 621dd28e3d..39c5bb1b42 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -151,11 +151,18 @@ describe('openapi-request-builder', () => { body: { limit: 100 }, - headerParameters: { 'content-type': 'multipart/form-data' } + headerParameters: { 'content-type': 'multipart/form-data' }, + _encoding: { + limit: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + } + } }); await requestBuilder.executeRaw(destination); const data = new FormData(); - data.append('limit', 100); + data.append('limit', '100'); expect(httpClient.executeHttpRequest).toHaveBeenCalledWith( sanitizeDestination(destination), { @@ -385,6 +392,252 @@ 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, + contentTypeParsed: [ + { 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [ + { 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [{ type: 'image/png', parameters: {} }] + } + } + }); + await requestBuilder.executeRaw(destination); + + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Content type mismatch') + ); + consoleSpy.mockRestore(); + }); + + 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + field2: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + field3: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + field4: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [ + { 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); + }); + 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 91df01c5d4..88213db47b 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -1,6 +1,8 @@ /* eslint-disable max-classes-per-file */ // eslint-disable-next-line import/named import { + createLogger, + ErrorWithCause, isNullish, pickValueIgnoreCase, removeSlashes, @@ -26,6 +28,11 @@ import type { import type { HttpDestinationOrFetchOptions } from '@sap-cloud-sdk/connectivity'; import type { AxiosResponse } from 'axios'; +const logger = createLogger({ + package: 'openapi', + messageContext: 'openapi-request-builder' +}); + /** * Request builder for OpenAPI requests. * @template ResponseT - Type of the response for the request. @@ -185,20 +192,137 @@ export class OpenApiRequestBuilder { private getBody(): any { const body = this.parameters?.body; - if ( - pickValueIgnoreCase(this.parameters?.headerParameters, 'content-type') === - 'multipart/form-data' - ) { - const formData = new FormData(); - for (const key in body) { - // TODO: is it enough to just append the body[key] or does it need to be stringified? - formData.append(key, body[key]); - } - return formData; + 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)) { + return this.buildFormData(body); } + return body; } + private buildFormData(body: Record): FormData { + const formData = new FormData(); + const encoding = this.parameters!._encoding; + + for (const [key, value] of Object.entries(body ?? {})) { + if (value === undefined || value === null) { + continue; + } + + if (!encoding || !encoding[key]) { + throw new Error( + `Missing encoding metadata for property '${key}'. ` + + 'This indicates a code generation issue. ' + + 'Please regenerate your API client.' + ); + } + + // Content type is provided by the generator in _encoding based on the schema + const { + contentType: targetContentType, + isImplicit: targetIsImplicit, + contentTypeParsed + } = encoding[key]; + // Use the first parsed content type (primary type) + const allowedTypes = new Set( + // TODO: compcase? + contentTypeParsed.map(ct => ct.type.toLowerCase()) + ); + if (value instanceof Blob) { + const isFlexibleContentType = + targetContentType && + (targetContentType.includes('*') || + contentTypeParsed.length > 1 || + allowedTypes.has('any')); + + // 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 && + !contentTypeParsed[0].parameters.charset + ) { + logger.debug( + `Adding missing content type '${targetContentType}' to Blob for key '${key}' as per encoding specification.` + ); + const withType = new Blob([value], { + type: targetContentType + }); + formData.append(key, withType); + continue; + } + + // 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.localeCompare( + contentTypeParsed[0].type, + undefined, + { sensitivity: 'base' } + ) !== 0 + ) { + logger.warn( + `Content type mismatch for key '${key}': value has type '${value.type}' but encoding specifies '${targetContentType}'.` + ); + } + formData.append(key, value); + continue; + } + + // 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 = new Set( + contentTypeParsed.map(ct => ct.parameters.charset) + ); + if ( + targetCharset.size === 1 && + targetCharset.values().next().value !== undefined + ) { + const targetCharsetValue = targetCharset.values().next().value; + + // 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 | undefined; + 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 + ); + } + + const blob = new Blob([buffer], { + type: targetContentType + }); + formData.append(key, blob); + continue; + } + + formData.append(key, stringValue); + } + + return formData; + } + private getPath(): string { const pathParameters = this.parameters?.pathParameters || {}; @@ -237,6 +361,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; + contentTypeParsed: { + type: string; + parameters: { [key: string]: string }; + }[]; + } + >; } function isAxiosResponse(val: any): val is AxiosResponse { diff --git a/yarn.lock b/yarn.lock index 2f9ca9e1a4..060d4f43d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1751,6 +1751,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" @@ -3198,7 +3203,7 @@ content-disposition@~0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== From 954bc960180134439db08a172b32380882e9f7f8 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Fri, 6 Feb 2026 17:03:05 +0100 Subject: [PATCH 04/15] wip --- packages/openapi-generator/package.json | 4 +- .../src/parser/document.spec.ts | 4 + .../src/parser/media-type.spec.ts | 550 ++++++------------ .../src/parser/media-type.ts | 90 +-- .../openapi-generator/src/parser/schema.ts | 2 +- .../src/parser/type-mapping.spec.ts | 8 + .../src/parser/type-mapping.ts | 11 +- .../tsconfig.for-prepack.json | 7 + packages/openapi/package.json | 2 +- .../src/openapi-request-builder.spec.ts | 117 ++++ packages/openapi/tsconfig.for-prepack.json | 7 + test-packages/e2e-tests/openapi.js | 24 + test-packages/e2e-tests/package.json | 1 + test-packages/e2e-tests/test/openapi.spec.ts | 18 +- .../test-service/test-case-api.js | 14 + .../test-service/test-case-api.js.map | 2 +- .../test-service/test-case-api.ts | 14 + yarn.lock | 61 +- 18 files changed, 518 insertions(+), 418 deletions(-) create mode 100644 packages/openapi-generator/tsconfig.for-prepack.json create mode 100644 packages/openapi/tsconfig.for-prepack.json diff --git a/packages/openapi-generator/package.json b/packages/openapi-generator/package.json index fb22437ee3..0983d2f215 100644 --- a/packages/openapi-generator/package.json +++ b/packages/openapi-generator/package.json @@ -29,7 +29,7 @@ "directory": "packages/openapi-generator" }, "scripts": { - "compile": "tsc -b",, + "compile": "tsc -b", "test": "yarn test:unit", "test:unit": "yarn node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", "coverage": "jest --coverage", @@ -37,7 +37,7 @@ "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck . --ignores='@sap-cloud-sdk/openapi'", "readme": "ts-node ../../scripts/replace-common-readme.ts", - "prepack": "tsc -b" + "prepack": "test -f dist/index.js || tsc -p tsconfig.for-prepack.json || true" }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", 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 4bb7b811c8..bf2fcd7cad 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 createMultipartContent(schema: any, encoding?: any) { + return { + content: { + 'multipart/form-data': { + schema, + ...(encoding && { encoding }) + } + } + }; +} + +function createImplicitEncoding(contentType: string): { + contentType: string; + isImplicit: true; + contentTypeParsed: any[]; +} { + return { + contentType, + isImplicit: true, + contentTypeParsed: [{ type: contentType, parameters: {} }] + }; +} + +function createExplicitEncoding( + contentType: string, + parameters: Record = {} +): { + contentType: string; + isImplicit: false; + contentTypeParsed: any[]; +} { + return { + contentType, + isImplicit: false, + contentTypeParsed: [{ type: contentType.split(';')[0].trim(), parameters }] + }; +} describe('parseTopLevelMediaType', () => { it('returns undefined if the media type is not supported', async () => { expect( @@ -87,7 +125,7 @@ describe('parseTopLevelMediaType', () => { defaultOptions ) ).toEqual({ - schema: { type: 'string' }, + schema: { type: 'Blob' }, mediaType: 'application/octet-stream', encoding: undefined }); @@ -95,13 +133,7 @@ describe('parseTopLevelMediaType', () => { it('returns undefined encoding for non-multipart media types', async () => { const result = parseTopLevelMediaType( - { - content: { - 'application/json': { - schema: { type: 'object' } - } - } - }, + { content: { 'application/json': { schema: { type: 'object' } } } }, await createTestRefs(), defaultOptions ); @@ -109,24 +141,13 @@ describe('parseTopLevelMediaType', () => { }); 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - profileImage: { type: 'string', format: 'binary' } - } - }, - encoding: { - profileImage: { - contentType: 'image/png, image/jpeg' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -145,13 +166,7 @@ describe('parseTopLevelMediaType', () => { it('returns undefined encoding when encoding object is empty', async () => { const result = parseTopLevelMediaType( - { - content: { - 'multipart/form-data': { - schema: { type: 'object' } - } - } - }, + createMultipartContent({ type: 'object' }), await createTestRefs(), defaultOptions ); @@ -159,20 +174,15 @@ describe('parseTopLevelMediaType', () => { }); 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - file: { type: 'string', format: 'binary' }, - metadata: { type: 'string' } - } - } - } - } - }, + createMultipartContent(schema), await createTestRefs(), defaultOptions ); @@ -200,188 +210,111 @@ describe('parseTopLevelMediaType', () => { }); expect(result?.mediaType).toBe('multipart/form-data'); expect(result?.encoding).toEqual({ - file: { - contentType: 'application/octet-stream', - isImplicit: true, - contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] - }, - metadata: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - } + file: createImplicitEncoding('application/octet-stream'), + metadata: createImplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - image: { type: 'string', format: 'binary' } - } - }, - encoding: { - image: { - contentType: 'image/png' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - image: { - contentType: 'image/png', - isImplicit: false, - contentTypeParsed: [{ type: 'image/png', parameters: {} }] - } + image: createExplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'integer' }, - score: { type: 'number' }, - active: { type: 'boolean' } - } - } - } - } - }, + createMultipartContent(schema), await createTestRefs(), defaultOptions ); + const textPlainEncoding = createImplicitEncoding('text/plain'); expect(result?.encoding).toEqual({ - name: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - }, - age: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - }, - score: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - }, - active: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - } + 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - tags: { type: 'array', items: { type: 'string' } }, - files: { - type: 'array', - items: { type: 'string', format: 'binary' } - } - } - } - } - } - }, + createMultipartContent(schema), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - tags: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - }, - files: { - contentType: 'application/octet-stream', - isImplicit: true, - contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] - } + tags: createImplicitEncoding('text/plain'), + files: createImplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - metadata: { - type: 'object', - properties: { key: { type: 'string' } } - } - } - } - } - } - }, + createMultipartContent(schema), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - metadata: { - contentType: 'application/json', - isImplicit: true, - contentTypeParsed: [{ type: 'application/json', parameters: {} }] - } + metadata: createImplicitEncoding('application/json') }); }); it('handles multipart/form-data with $ref schema', async () => { - const result = parseTopLevelMediaType( - { - content: { - 'multipart/form-data': { - schema: { - $ref: '#/components/schemas/Body_predict_parquet' - } - } - } - }, - 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 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( + createMultipartContent(schema), + refs, defaultOptions ); @@ -392,86 +325,46 @@ describe('parseTopLevelMediaType', () => { }); expect(result?.mediaType).toBe('multipart/form-data'); expect(result?.encoding).toEqual({ - file: { - contentType: 'application/octet-stream', - isImplicit: true, - contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] - }, - target_columns: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - }, - metadata: { - contentType: 'application/json', - isImplicit: true, - contentTypeParsed: [{ type: 'application/json', parameters: {} }] - } + file: createImplicitEncoding('application/octet-stream'), + target_columns: createImplicitEncoding('text/plain'), + metadata: createImplicitEncoding('application/json') }); }); it('handles multipart/form-data with $ref schema containing nested $refs', async () => { - const result = parseTopLevelMediaType( - { - content: { - 'multipart/form-data': { - schema: { - $ref: '#/components/schemas/FormData' - } - } - } - }, - await createTestRefs({ - schemas: { - FormData: { - type: 'object', - properties: { - image: { $ref: '#/components/schemas/ImageFile' }, - description: { type: 'string' } - } - }, - ImageFile: { - type: 'string', - format: 'binary' + 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( + createMultipartContent(schema), + refs, defaultOptions ); expect(result?.encoding).toEqual({ - image: { - contentType: 'application/octet-stream', - isImplicit: true, - contentTypeParsed: [{ type: 'application/octet-stream', parameters: {} }] - }, - description: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - } + image: createImplicitEncoding('application/octet-stream'), + description: createImplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - textData: { type: 'string' } - } - }, - encoding: { - textData: { - contentType: 'text/plain; charset=utf-8' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -481,10 +374,7 @@ describe('parseTopLevelMediaType', () => { contentType: 'text/plain; charset=utf-8', isImplicit: false, contentTypeParsed: [ - { - type: 'text/plain', - parameters: { charset: 'utf-8' } - } + { type: 'text/plain', parameters: { charset: 'utf-8' } } ] } }); @@ -527,24 +417,17 @@ describe('parseTopLevelMediaType', () => { }); 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - xmlData: { type: 'string' } - } - }, - encoding: { - xmlData: { - contentType: 'application/xml; charset=iso-8859-1; boundary=something' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -556,10 +439,7 @@ describe('parseTopLevelMediaType', () => { contentTypeParsed: [ { type: 'application/xml', - parameters: { - charset: 'iso-8859-1', - boundary: 'something' - } + parameters: { charset: 'iso-8859-1', boundary: 'something' } } ] } @@ -567,25 +447,18 @@ describe('parseTopLevelMediaType', () => { }); 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - customText: { type: 'string' }, - normalField: { type: 'string' } - } - }, - encoding: { - customText: { - contentType: 'text/plain; charset=utf-16' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -595,41 +468,24 @@ describe('parseTopLevelMediaType', () => { contentType: 'text/plain; charset=utf-16', isImplicit: false, contentTypeParsed: [ - { - type: 'text/plain', - parameters: { charset: 'utf-16' } - } + { type: 'text/plain', parameters: { charset: 'utf-16' } } ] }, - normalField: { - contentType: 'text/plain', - isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] - } + normalField: createImplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - file: { type: 'string', format: 'binary' } - } - }, - encoding: { - file: { - contentType: 'image/png;;invalid' - } - } - } - } - }, + createMultipartContent(schema, encoding), refs, defaultOptions ) @@ -637,67 +493,41 @@ describe('parseTopLevelMediaType', () => { }); 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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - data: { type: 'string', format: 'binary' } - } - }, - encoding: { - data: { - contentType: 'image/*' - } - } - } - } - }, + createMultipartContent(schema, encoding), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - data: { - contentType: 'image/*', - isImplicit: false, - contentTypeParsed: [ - { - type: 'image/*', - parameters: {} - } - ] - } + data: createExplicitEncoding('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( - { - content: { - 'multipart/form-data': { - schema: { - type: 'object', - properties: { - attachment: { type: 'string', format: 'binary' } - } - }, - encoding: { - attachment: { - contentType: 'not-a-valid-content-type-at-all' - } - } - } - } - }, + createMultipartContent(schema, encoding), refs, defaultOptions ) - ).toThrow(/invalid content-type.*not-a-valid-content-type-at-all.*attachment/i); + ).toThrow( + /invalid content-type.*not-a-valid-content-type-at-all.*attachment/i + ); }); }); diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index e9b50e9fc5..d7cda1eaea 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -1,6 +1,5 @@ import { createLogger, ErrorWithCause } from '@sap-cloud-sdk/util'; import { parse as parseContentType, type ParsedMediaType } from 'content-type'; -import { parse } from '@apidevtools/swagger-parser'; import { parseSchema } from './schema'; import type { OpenAPIV3 } from 'openapi-types'; import type { OpenApiMediaTypeObject, OpenApiSchema } from '../openapi-types'; @@ -17,6 +16,33 @@ const allowedMediaTypes = [ '*/*' ]; +/** + * 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 + ); + } + }); +} + /** * Parse encoding object from a media type, extracting contentType for each property. * Also automatically infers content types for properties with binary format. @@ -28,14 +54,16 @@ const allowedMediaTypes = [ function parseEncoding( mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, refs: OpenApiDocumentRefs -): Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } -> | undefined { +): + | Record< + string, + { + contentType: string; + isImplicit: boolean; + contentTypeParsed: ParsedMediaType[]; + } + > + | undefined { const explicitEncoding: Record< string, { @@ -44,38 +72,20 @@ function parseEncoding( contentTypeParsed: ParsedMediaType[]; } > = mediaTypeObject?.encoding - ? Object.entries(mediaTypeObject.encoding).reduce( - (acc, [propName, encodingObj]) => { - if (encodingObj.contentType) { - // OpenAPI allows comma-separated content types - const contentTypes = encodingObj.contentType.split(',').map(ct => ct.trim()); - const contentTypeParsed: ParsedMediaType[] = []; - - for (const ct of contentTypes) { - try { - contentTypeParsed.push(parseContentType(ct)); - } catch (error) { - 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 - ); - } + ? Object.fromEntries( + Object.entries(mediaTypeObject.encoding) + .filter(([, encodingObj]) => encodingObj.contentType) + .map(([propName, encodingObj]) => [ + propName, + { + contentType: encodingObj.contentType!, + isImplicit: false, + contentTypeParsed: parseContentTypes( + encodingObj.contentType!, + propName + ) } - - return { - ...acc, - [propName]: { - contentType: encodingObj.contentType, - isImplicit: false, - contentTypeParsed - } - }; - } - return acc; - }, - {} + ]) ) : {}; diff --git a/packages/openapi-generator/src/parser/schema.ts b/packages/openapi-generator/src/parser/schema.ts index e0756323ce..ab36985a31 100644 --- a/packages/openapi-generator/src/parser/schema.ts +++ b/packages/openapi-generator/src/parser/schema.ts @@ -78,7 +78,7 @@ export function parseSchema( } return { - type: getType(schema.type, schema.format, mediaType) + 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 ff16849fd2..dc56b417cf 100644 --- a/packages/openapi-generator/src/parser/type-mapping.ts +++ b/packages/openapi-generator/src/parser/type-mapping.ts @@ -27,6 +27,7 @@ const typeMapping = { date: 'string', DateTime: 'string', binary: 'Blob', + Blob: 'Blob', File: 'Blob', file: 'Blob', ByteArray: 'string', @@ -41,20 +42,14 @@ 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. - * @param requestMediaType - Optional media type of the request. * @returns The mapped TypeScript type. * @internal */ export function getType( originalType: string | undefined, - format?: string, - requestMediaType?: string + format?: string ): string { - if ( - originalType === 'string' && - format === 'binary' && - requestMediaType === 'multipart/form-data' - ) { + if (originalType === 'string' && format === 'binary') { return 'Blob'; } const type = originalType ? typeMapping[originalType] : 'any'; diff --git a/packages/openapi-generator/tsconfig.for-prepack.json b/packages/openapi-generator/tsconfig.for-prepack.json new file mode 100644 index 0000000000..7a5406aa4d --- /dev/null +++ b/packages/openapi-generator/tsconfig.for-prepack.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "noEmitOnError": false + } +} diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 8d050797df..75ebd778e1 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -37,7 +37,7 @@ "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck .", "readme": "ts-node ../../scripts/replace-common-readme.ts", - "prepack": "tsc -b" + "prepack": "test -f dist/index.js || tsc -p tsconfig.for-prepack.json || true" }, "dependencies": { "@sap-cloud-sdk/connectivity": "^4.4.0", diff --git a/packages/openapi/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 39c5bb1b42..20edb97b4a 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -638,6 +638,123 @@ describe('openapi-request-builder', () => { 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [ + { 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, + contentTypeParsed: [{ 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, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + numberField: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + fileField: { + contentType: 'application/pdf', + isImplicit: false, + contentTypeParsed: [{ 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/tsconfig.for-prepack.json b/packages/openapi/tsconfig.for-prepack.json new file mode 100644 index 0000000000..7a5406aa4d --- /dev/null +++ b/packages/openapi/tsconfig.for-prepack.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "skipLibCheck": true, + "noEmitOnError": false + } +} 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 06301ed258..bf25e94adb 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/test-service/test-case-api.js b/test-packages/test-services-openapi/test-service/test-case-api.js index 38c7d94e66..83389db803 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 @@ -153,6 +153,13 @@ exports.TestCaseApi = { */ testCasePostMultipartBody: (body) => new openapi_1.OpenApiRequestBuilder('post', '/test-cases/multipart-body', { body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + } + }, headerParameters: { 'content-type': 'multipart/form-data' } }, exports.TestCaseApi._defaultBasePath), /** @@ -163,6 +170,13 @@ exports.TestCaseApi = { */ testCasePatchMultipartBodyWithHeaders: (body, headerParameters) => new openapi_1.OpenApiRequestBuilder('patch', '/test-cases/multipart-body', { body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + } + }, headerParameters: { 'content-type': 'multipart/form-data', ...headerParameters 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 fd238172f3..6c304cfc0c 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":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAO/D;;;GAGG;AACU,QAAA,WAAW,GAAG;IACzB,gBAAgB,EAAE,SAAS;IAC3B;;;;;;OAMG;IACH,6BAA6B,EAAE,CAC7B,yBAAiC,EACjC,IAAkC,EAClC,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QACxD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,8BAA8B,EAAE,CAC9B,yBAAiC,EACjC,IAAsB,EACtB,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QACxD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,mCAAmC,EAAE,CACnC,eAA+C,EAC/C,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wBAAwB,EACxB;QACE,gBAAgB;QAChB,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAgD,EAChD,gBAAiD,EACjD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wBAAwB,EACxB;QACE,IAAI;QACJ,gBAAgB,EAAE;YAChB,cAAc,EAAE,kBAAkB;YAClC,GAAG,gBAAgB;SACpB;QACD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAiD,EACjD,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,OAAO,EACP,wBAAwB,EACxB;QACE,IAAI;QACJ,gBAAgB,EAAE;YAChB,cAAc,EAAE,kBAAkB;YAClC,GAAG,gBAAgB;SACpB;QACD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,8BAA8B,EAAE,CAC9B,cAAsB,EACtB,eAA2C,EAC3C,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,yCAAyC,EACzC;QACE,cAAc,EAAE,EAAE,cAAc,EAAE;QAClC,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,oBAAoB,EAAE,GAAG,EAAE,CACzB,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,uBAAuB,EAAE,GAAG,EAAE,CAC5B,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,sBAAsB,EAAE,GAAG,EAAE,CAC3B,IAAI,+BAAqB,CACvB,MAAM,EACN,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,qBAAqB,EAAE,GAAG,EAAE,CAC1B,IAAI,+BAAqB,CACvB,OAAO,EACP,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,MAAM,EAAE,CAAC,MAAc,EAAE,eAAkC,EAAE,EAAE,CAC7D,IAAI,+BAAqB,CACvB,KAAK,EACL,wCAAwC,EACxC;QACE,cAAc,EAAE,EAAE,MAAM,EAAE;QAC1B,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,cAAc,EAAE,CAAC,IAAmC,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,IAA6C,EAAE,EAAE,CACpE,IAAI,+BAAqB,CACvB,MAAM,EACN,6BAA6B,EAC7B;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,IAA8B,EAAE,EAAE,CACpD,IAAI,+BAAqB,CACvB,KAAK,EACL,iCAAiC,EACjC;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,IAAsB,EAAE,EAAE,CACpD,IAAI,+BAAqB,CACvB,MAAM,EACN,4BAA4B,EAC5B;QACE,IAAI;QACJ,SAAS,EAAE;YACT,cAAc,EAAE;gBACd,WAAW,EAAE,YAAY;gBACzB,UAAU,EAAE,IAAI;gBAChB,iBAAiB,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;aAC5D;SACF;QACD,gBAAgB,EAAE,EAAE,cAAc,EAAE,qBAAqB,EAAE;KAC5D,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,qCAAqC,EAAE,CACrC,IAAsB,EACtB,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,OAAO,EACP,4BAA4B,EAC5B;QACE,IAAI;QACJ,SAAS,EAAE;YACT,cAAc,EAAE;gBACd,WAAW,EAAE,YAAY;gBACzB,UAAU,EAAE,IAAI;gBAChB,iBAAiB,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;aAC5D;SACF;QACD,gBAAgB,EAAE;YAChB,cAAc,EAAE,qBAAqB;YACrC,GAAG,gBAAgB;SACpB;KACF,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,yBAAyB,EAAE,GAAG,EAAE,CAC9B,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;CACJ,CAAC"} \ 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 40c2cdc62a..0fdebac206 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 @@ -276,6 +276,13 @@ export const TestCaseApi = { '/test-cases/multipart-body', { body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + } + }, headerParameters: { 'content-type': 'multipart/form-data' } }, TestCaseApi._defaultBasePath @@ -295,6 +302,13 @@ export const TestCaseApi = { '/test-cases/multipart-body', { body, + _encoding: { + stringProperty: { + contentType: 'text/plain', + isImplicit: true, + contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + } + }, headerParameters: { 'content-type': 'multipart/form-data', ...headerParameters diff --git a/yarn.lock b/yarn.lock index f43ac182ab..aa895783c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2380,6 +2380,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" @@ -2843,6 +2848,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: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" @@ -3191,6 +3203,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" @@ -6664,7 +6686,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== @@ -6726,6 +6748,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" @@ -6998,6 +7033,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" @@ -7884,7 +7924,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== @@ -8599,6 +8639,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" @@ -9171,7 +9216,7 @@ 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, 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== @@ -9229,6 +9274,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.16: version "0.28.16" resolved "https://registry.npmjs.org/typedoc/-/typedoc-0.28.16.tgz#3901672c48746587fa24390077d07317a1fd180f" @@ -9653,6 +9703,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" From 976a6181999a3bd96a7a1eedad913d90ee4591a1 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Mon, 9 Feb 2026 10:31:56 +0100 Subject: [PATCH 05/15] preserve null and undefined values in multipart body --- .../src/openapi-request-builder.spec.ts | 31 +++++++++++++++++++ .../openapi/src/openapi-request-builder.ts | 4 --- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/openapi/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 20edb97b4a..75134de33b 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -509,6 +509,37 @@ describe('openapi-request-builder', () => { 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, + contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + }, + field2: { + contentType: 'application/json', + isImplicit: true, + contentTypeParsed: [{ 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' }); diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 88213db47b..0aa50b4cf3 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -210,10 +210,6 @@ export class OpenApiRequestBuilder { const encoding = this.parameters!._encoding; for (const [key, value] of Object.entries(body ?? {})) { - if (value === undefined || value === null) { - continue; - } - if (!encoding || !encoding[key]) { throw new Error( `Missing encoding metadata for property '${key}'. ` + From b7c2f985e621b546db04bd4f325ee8876834b808 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Wed, 11 Feb 2026 10:57:11 +0100 Subject: [PATCH 06/15] Apply suggestions from code review Co-authored-by: Marika Marszalkowski --- packages/openapi-generator/src/file-serializer/operation.ts | 2 +- packages/openapi-generator/src/parser/media-type.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openapi-generator/src/file-serializer/operation.ts b/packages/openapi-generator/src/file-serializer/operation.ts index f924cabdc8..71cbc60bc8 100644 --- a/packages/openapi-generator/src/file-serializer/operation.ts +++ b/packages/openapi-generator/src/file-serializer/operation.ts @@ -123,7 +123,7 @@ function serializeParamsForRequestBuilder( params.push('body'); if ( operation.requestBody.encoding && - Object.keys(operation.requestBody.encoding).length > 0 + Object.keys(operation.requestBody.encoding).length ) { params.push( `_encoding: ${JSON.stringify(operation.requestBody.encoding)}` diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index d7cda1eaea..95cf08eed2 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -102,7 +102,7 @@ function parseEncoding( if (!schema) { const joined = { ...autoEncoding, ...explicitEncoding }; - return Object.keys(joined).length > 0 ? joined : undefined; + return Object.keys(joined).length ? joined : undefined; } // Resolve $ref if present @@ -168,7 +168,7 @@ function parseEncoding( } const combined = { ...autoEncoding, ...explicitEncoding }; - return Object.keys(combined).length > 0 ? combined : undefined; + return Object.keys(combined).length ? combined : undefined; } /** * Parse the type of a resolved request body or response object. From 393104587a1a8313cd9e619a53bea456eab73348 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Wed, 11 Feb 2026 14:08:47 +0100 Subject: [PATCH 07/15] address review comments --- .../src/file-serializer/operation.ts | 31 +-- .../openapi-generator/src/openapi-types.ts | 2 +- .../src/parser/media-type.spec.ts | 18 +- .../src/parser/media-type.ts | 213 +++++++++--------- .../src/openapi-request-builder.spec.ts | 44 ++-- .../openapi/src/openapi-request-builder.ts | 14 +- .../test-service/test-case-api.js | 4 +- .../test-service/test-case-api.js.map | 2 +- .../test-service/test-case-api.ts | 4 +- 9 files changed, 169 insertions(+), 163 deletions(-) diff --git a/packages/openapi-generator/src/file-serializer/operation.ts b/packages/openapi-generator/src/file-serializer/operation.ts index 71cbc60bc8..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 { @@ -129,20 +141,11 @@ function serializeParamsForRequestBuilder( `_encoding: ${JSON.stringify(operation.requestBody.encoding)}` ); } - if (operation.requestBody.mediaType) { - const contentTypeStr = `'content-type': '${operation.requestBody.mediaType}'`; - if (operation.headerParameters.length) { - params.push( - `headerParameters: {${contentTypeStr}, ...headerParameters}` - ); - } else { - params.push(`headerParameters: {${contentTypeStr}}`); - } - } else if (operation.headerParameters.length) { - params.push('headerParameters'); - } - } else if (operation.headerParameters.length) { - params.push('headerParameters'); + } + + const headerParam = getHeaderParameters(operation); + if (headerParam) { + params.push(headerParam); } if (operation.queryParameters.length) { params.push('queryParameters'); diff --git a/packages/openapi-generator/src/openapi-types.ts b/packages/openapi-generator/src/openapi-types.ts index b4c7da3746..15f9266498 100644 --- a/packages/openapi-generator/src/openapi-types.ts +++ b/packages/openapi-generator/src/openapi-types.ts @@ -172,7 +172,7 @@ export interface OpenApiRequestBody { { contentType: string; isImplicit: boolean; - contentTypeParsed: { + parsedContentTypes: { type: string; parameters: { [key: string]: string }; }[]; diff --git a/packages/openapi-generator/src/parser/media-type.spec.ts b/packages/openapi-generator/src/parser/media-type.spec.ts index bf2fcd7cad..2c5b169fc0 100644 --- a/packages/openapi-generator/src/parser/media-type.spec.ts +++ b/packages/openapi-generator/src/parser/media-type.spec.ts @@ -21,12 +21,12 @@ function createMultipartContent(schema: any, encoding?: any) { function createImplicitEncoding(contentType: string): { contentType: string; isImplicit: true; - contentTypeParsed: any[]; + parsedContentTypes: any[]; } { return { contentType, isImplicit: true, - contentTypeParsed: [{ type: contentType, parameters: {} }] + parsedContentTypes: [{ type: contentType, parameters: {} }] }; } @@ -36,12 +36,12 @@ function createExplicitEncoding( ): { contentType: string; isImplicit: false; - contentTypeParsed: any[]; + parsedContentTypes: any[]; } { return { contentType, isImplicit: false, - contentTypeParsed: [{ type: contentType.split(';')[0].trim(), parameters }] + parsedContentTypes: [{ type: contentType.split(';')[0].trim(), parameters }] }; } describe('parseTopLevelMediaType', () => { @@ -156,7 +156,7 @@ describe('parseTopLevelMediaType', () => { profileImage: { contentType: 'image/png, image/jpeg', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'image/png', parameters: {} }, { type: 'image/jpeg', parameters: {} } ] @@ -373,7 +373,7 @@ describe('parseTopLevelMediaType', () => { textData: { contentType: 'text/plain; charset=utf-8', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'text/plain', parameters: { charset: 'utf-8' } } ] } @@ -407,7 +407,7 @@ describe('parseTopLevelMediaType', () => { document: { contentType: 'application/pdf, application/msword, text/plain', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'application/pdf', parameters: {} }, { type: 'application/msword', parameters: {} }, { type: 'text/plain', parameters: {} } @@ -436,7 +436,7 @@ describe('parseTopLevelMediaType', () => { xmlData: { contentType: 'application/xml; charset=iso-8859-1; boundary=something', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'application/xml', parameters: { charset: 'iso-8859-1', boundary: 'something' } @@ -467,7 +467,7 @@ describe('parseTopLevelMediaType', () => { customText: { contentType: 'text/plain; charset=utf-16', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'text/plain', parameters: { charset: 'utf-16' } } ] }, diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index 95cf08eed2..f1a0455b5e 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -16,6 +16,14 @@ const allowedMediaTypes = [ '*/*' ]; +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. @@ -43,6 +51,90 @@ function parseContentTypes( }); } +/** + * 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'; +} + +/** + * Add auto-inferred content types for schema properties. + * @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 buildInferredEncodings( + resolvedEncodings: string[], + resolvedSchema: OpenAPIV3.SchemaObject, + refs: OpenApiDocumentRefs +): EncodingMap | undefined { + if (!('properties' in resolvedSchema) || !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 (Object.prototype.hasOwnProperty.call(resolvedPropSchema, '$ref')) { + 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 object from a media type, extracting contentType for each property. * Also automatically infers content types for properties with binary format. @@ -54,24 +146,8 @@ function parseContentTypes( function parseEncoding( mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, refs: OpenApiDocumentRefs -): - | Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } - > - | undefined { - const explicitEncoding: Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } - > = mediaTypeObject?.encoding +): EncodingMap | undefined { + const explicitEncodings: EncodingMap = mediaTypeObject?.encoding ? Object.fromEntries( Object.entries(mediaTypeObject.encoding) .filter(([, encodingObj]) => encodingObj.contentType) @@ -80,7 +156,7 @@ function parseEncoding( { contentType: encodingObj.contentType!, isImplicit: false, - contentTypeParsed: parseContentTypes( + parsedContentTypes: parseContentTypes( encodingObj.contentType!, propName ) @@ -91,84 +167,25 @@ function parseEncoding( // Auto-infer content types based on schema types const schema = mediaTypeObject?.schema; - const autoEncoding: Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } - > = {}; if (!schema) { - const joined = { ...autoEncoding, ...explicitEncoding }; - return Object.keys(joined).length ? joined : undefined; + return Object.keys(explicitEncodings).length + ? explicitEncodings + : undefined; } // Resolve $ref if present const resolvedSchema = refs.resolveObject(schema); - if ('properties' in resolvedSchema && resolvedSchema.properties) { - Object.entries(resolvedSchema.properties).forEach( - ([propName, propSchema]) => { - // Skip if already has explicit encoding - if (explicitEncoding[propName]) { - return; - } - - if (!propSchema || typeof propSchema !== 'object') { - return; - } - - // Resolve $ref for property schema - const resolvedPropSchema = refs.resolveObject(propSchema); - - const 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'; - }; - - if (!('$ref' in resolvedPropSchema)) { - const contentType = inferContentTypeFromSchema(resolvedPropSchema); - if (contentType) { - const contentTypeParsed = parseContentType(contentType); - autoEncoding[propName] = { - contentType, - isImplicit: true, - contentTypeParsed: [contentTypeParsed] - }; - } - } - } - ); - } + const implicitEncodings = + buildInferredEncodings( + Object.keys(explicitEncodings), + resolvedSchema, + refs + ) || {}; - const combined = { ...autoEncoding, ...explicitEncoding }; - return Object.keys(combined).length ? combined : undefined; + const allEncodings = { ...implicitEncodings, ...explicitEncodings }; + return Object.keys(allEncodings).length ? allEncodings : undefined; } /** * Parse the type of a resolved request body or response object. @@ -189,14 +206,7 @@ export function parseTopLevelMediaType( | { schema: OpenApiSchema; mediaType: string; - encoding?: Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } - >; + encoding?: EncodingMap; } | undefined { if (bodyOrResponseObject) { @@ -238,14 +248,7 @@ export function parseMediaType( | { schema: OpenApiSchema; mediaType: string; - encoding?: Record< - string, - { - contentType: string; - isImplicit: boolean; - contentTypeParsed: ParsedMediaType[]; - } - >; + encoding?: EncodingMap; } | undefined { const allMediaTypes = getMediaTypes(bodyOrResponseObject); diff --git a/packages/openapi/src/openapi-request-builder.spec.ts b/packages/openapi/src/openapi-request-builder.spec.ts index 75134de33b..e202897689 100644 --- a/packages/openapi/src/openapi-request-builder.spec.ts +++ b/packages/openapi/src/openapi-request-builder.spec.ts @@ -156,7 +156,7 @@ describe('openapi-request-builder', () => { limit: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] } } }); @@ -402,7 +402,7 @@ describe('openapi-request-builder', () => { textData: { contentType: 'text/plain; charset=utf-8', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'text/plain', parameters: { charset: 'utf-8' } } ] } @@ -427,7 +427,7 @@ describe('openapi-request-builder', () => { file: { contentType: 'application/pdf', isImplicit: false, - contentTypeParsed: [{ type: 'application/pdf', parameters: {} }] + parsedContentTypes: [{ type: 'application/pdf', parameters: {} }] } } }); @@ -448,7 +448,7 @@ describe('openapi-request-builder', () => { document: { contentType: 'application/pdf, application/msword', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'application/pdf', parameters: {} }, { type: 'application/msword', parameters: {} } ] @@ -474,7 +474,7 @@ describe('openapi-request-builder', () => { image: { contentType: 'image/png', isImplicit: false, - contentTypeParsed: [{ type: 'image/png', parameters: {} }] + parsedContentTypes: [{ type: 'image/png', parameters: {} }] } } }); @@ -497,7 +497,7 @@ describe('openapi-request-builder', () => { image: { contentType: 'image/png', isImplicit: true, - contentTypeParsed: [{ type: 'image/png', parameters: {} }] + parsedContentTypes: [{ type: 'image/png', parameters: {} }] } } }); @@ -520,12 +520,12 @@ describe('openapi-request-builder', () => { field1: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, field2: { contentType: 'application/json', isImplicit: true, - contentTypeParsed: [{ type: 'application/json', parameters: {} }] + parsedContentTypes: [{ type: 'application/json', parameters: {} }] } } }); @@ -552,7 +552,7 @@ describe('openapi-request-builder', () => { file: { contentType: 'image/*', isImplicit: false, - contentTypeParsed: [{ type: 'image/*', parameters: {} }] + parsedContentTypes: [{ type: 'image/*', parameters: {} }] } } }); @@ -577,22 +577,22 @@ describe('openapi-request-builder', () => { field1: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, field2: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, field3: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, field4: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] } } }); @@ -613,7 +613,7 @@ describe('openapi-request-builder', () => { jsonData: { contentType: 'application/json', isImplicit: true, - contentTypeParsed: [{ type: 'application/json', parameters: {} }] + parsedContentTypes: [{ type: 'application/json', parameters: {} }] } } }); @@ -634,7 +634,7 @@ describe('openapi-request-builder', () => { textData: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] } } }); @@ -656,7 +656,7 @@ describe('openapi-request-builder', () => { data: { contentType: 'application/json; charset=utf-8', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'application/json', parameters: { charset: 'utf-8' } } ] } @@ -680,7 +680,7 @@ describe('openapi-request-builder', () => { fileField: { contentType: 'image/png', isImplicit: false, - contentTypeParsed: [{ type: 'image/png', parameters: {} }] + parsedContentTypes: [{ type: 'image/png', parameters: {} }] } } }); @@ -705,7 +705,7 @@ describe('openapi-request-builder', () => { textData: { contentType: 'text/plain; charset=utf-8', isImplicit: false, - contentTypeParsed: [ + parsedContentTypes: [ { type: 'text/plain', parameters: { charset: 'utf-8' } } ] } @@ -732,7 +732,7 @@ describe('openapi-request-builder', () => { jsonData: { contentType: 'application/json', isImplicit: true, - contentTypeParsed: [{ type: 'application/json', parameters: {} }] + parsedContentTypes: [{ type: 'application/json', parameters: {} }] } } }); @@ -759,17 +759,17 @@ describe('openapi-request-builder', () => { textField: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, numberField: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ type: 'text/plain', parameters: {} }] + parsedContentTypes: [{ type: 'text/plain', parameters: {} }] }, fileField: { contentType: 'application/pdf', isImplicit: false, - contentTypeParsed: [{ type: 'application/pdf', parameters: {} }] + parsedContentTypes: [{ type: 'application/pdf', parameters: {} }] } } }); diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 0aa50b4cf3..2fa5fc2bc9 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -222,25 +222,25 @@ export class OpenApiRequestBuilder { const { contentType: targetContentType, isImplicit: targetIsImplicit, - contentTypeParsed + parsedContentTypes } = encoding[key]; // Use the first parsed content type (primary type) const allowedTypes = new Set( // TODO: compcase? - contentTypeParsed.map(ct => ct.type.toLowerCase()) + parsedContentTypes.map(ct => ct.type.toLowerCase()) ); if (value instanceof Blob) { const isFlexibleContentType = targetContentType && (targetContentType.includes('*') || - contentTypeParsed.length > 1 || + parsedContentTypes.length > 1 || allowedTypes.has('any')); // 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 && - !contentTypeParsed[0].parameters.charset + !parsedContentTypes[0].parameters.charset ) { logger.debug( `Adding missing content type '${targetContentType}' to Blob for key '${key}' as per encoding specification.` @@ -263,7 +263,7 @@ export class OpenApiRequestBuilder { !isFlexibleContentType && // Do the actual comparison valueContentTypeBase.localeCompare( - contentTypeParsed[0].type, + parsedContentTypes[0].type, undefined, { sensitivity: 'base' } ) !== 0 @@ -284,7 +284,7 @@ export class OpenApiRequestBuilder { : String(value); // If a charset is specified in the encoding, we encode the string accordingly (if unambiguous) const targetCharset = new Set( - contentTypeParsed.map(ct => ct.parameters.charset) + parsedContentTypes.map(ct => ct.parameters.charset) ); if ( targetCharset.size === 1 && @@ -366,7 +366,7 @@ export interface OpenApiRequestParameters { { contentType: string; isImplicit: boolean; - contentTypeParsed: { + parsedContentTypes: { type: string; parameters: { [key: string]: string }; }[]; 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 83389db803..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 @@ -157,7 +157,7 @@ exports.TestCaseApi = { stringProperty: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] } }, headerParameters: { 'content-type': 'multipart/form-data' } @@ -174,7 +174,7 @@ exports.TestCaseApi = { stringProperty: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] } }, headerParameters: { 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 6c304cfc0c..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":";;;AAAA;;;;GAIG;AACH,oDAA+D;AAO/D;;;GAGG;AACU,QAAA,WAAW,GAAG;IACzB,gBAAgB,EAAE,SAAS;IAC3B;;;;;;OAMG;IACH,6BAA6B,EAAE,CAC7B,yBAAiC,EACjC,IAAkC,EAClC,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QACxD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,8BAA8B,EAAE,CAC9B,yBAAiC,EACjC,IAAsB,EACtB,eAKC,EACD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wEAAwE,EACxE;QACE,cAAc,EAAE,EAAE,yBAAyB,EAAE;QAC7C,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QACxD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,mCAAmC,EAAE,CACnC,eAA+C,EAC/C,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,wBAAwB,EACxB;QACE,gBAAgB;QAChB,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAgD,EAChD,gBAAiD,EACjD,EAAE,CACF,IAAI,+BAAqB,CACvB,MAAM,EACN,wBAAwB,EACxB;QACE,IAAI;QACJ,gBAAgB,EAAE;YAChB,cAAc,EAAE,kBAAkB;YAClC,GAAG,gBAAgB;SACpB;QACD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;;OAMG;IACH,mCAAmC,EAAE,CACnC,IAAsB,EACtB,eAAiD,EACjD,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,OAAO,EACP,wBAAwB,EACxB;QACE,IAAI;QACJ,gBAAgB,EAAE;YAChB,cAAc,EAAE,kBAAkB;YAClC,GAAG,gBAAgB;SACpB;QACD,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,8BAA8B,EAAE,CAC9B,cAAsB,EACtB,eAA2C,EAC3C,EAAE,CACF,IAAI,+BAAqB,CACvB,KAAK,EACL,yCAAyC,EACzC;QACE,cAAc,EAAE,EAAE,cAAc,EAAE;QAClC,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,oBAAoB,EAAE,GAAG,EAAE,CACzB,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,uBAAuB,EAAE,GAAG,EAAE,CAC5B,IAAI,+BAAqB,CACvB,KAAK,EACL,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,sBAAsB,EAAE,GAAG,EAAE,CAC3B,IAAI,+BAAqB,CACvB,MAAM,EACN,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,qBAAqB,EAAE,GAAG,EAAE,CAC1B,IAAI,+BAAqB,CACvB,OAAO,EACP,qCAAqC,EACrC,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,MAAM,EAAE,CAAC,MAAc,EAAE,eAAkC,EAAE,EAAE,CAC7D,IAAI,+BAAqB,CACvB,KAAK,EACL,wCAAwC,EACxC;QACE,cAAc,EAAE,EAAE,MAAM,EAAE;QAC1B,eAAe;KAChB,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,cAAc,EAAE,CAAC,IAAmC,EAAE,EAAE,CACtD,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,kBAAkB,EAAE,CAAC,IAA6C,EAAE,EAAE,CACpE,IAAI,+BAAqB,CACvB,MAAM,EACN,6BAA6B,EAC7B;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,IAA8B,EAAE,EAAE,CACpD,IAAI,+BAAqB,CACvB,KAAK,EACL,iCAAiC,EACjC;QACE,IAAI;QACJ,gBAAgB,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;KACzD,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;OAIG;IACH,yBAAyB,EAAE,CAAC,IAAsB,EAAE,EAAE,CACpD,IAAI,+BAAqB,CACvB,MAAM,EACN,4BAA4B,EAC5B;QACE,IAAI;QACJ,SAAS,EAAE;YACT,cAAc,EAAE;gBACd,WAAW,EAAE,YAAY;gBACzB,UAAU,EAAE,IAAI;gBAChB,iBAAiB,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;aAC5D;SACF;QACD,gBAAgB,EAAE,EAAE,cAAc,EAAE,qBAAqB,EAAE;KAC5D,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;;;OAKG;IACH,qCAAqC,EAAE,CACrC,IAAsB,EACtB,gBAAmD,EACnD,EAAE,CACF,IAAI,+BAAqB,CACvB,OAAO,EACP,4BAA4B,EAC5B;QACE,IAAI;QACJ,SAAS,EAAE;YACT,cAAc,EAAE;gBACd,WAAW,EAAE,YAAY;gBACzB,UAAU,EAAE,IAAI;gBAChB,iBAAiB,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;aAC5D;SACF;QACD,gBAAgB,EAAE;YAChB,cAAc,EAAE,qBAAqB;YACrC,GAAG,gBAAgB;SACpB;KACF,EACD,mBAAW,CAAC,gBAAgB,CAC7B;IACH;;;OAGG;IACH,yBAAyB,EAAE,GAAG,EAAE,CAC9B,IAAI,+BAAqB,CACvB,KAAK,EACL,6BAA6B,EAC7B,EAAE,EACF,mBAAW,CAAC,gBAAgB,CAC7B;CACJ,CAAC"} \ 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 0fdebac206..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 @@ -280,7 +280,7 @@ export const TestCaseApi = { stringProperty: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] } }, headerParameters: { 'content-type': 'multipart/form-data' } @@ -306,7 +306,7 @@ export const TestCaseApi = { stringProperty: { contentType: 'text/plain', isImplicit: true, - contentTypeParsed: [{ parameters: {}, type: 'text/plain' }] + parsedContentTypes: [{ parameters: {}, type: 'text/plain' }] } }, headerParameters: { From 6f031a0883645eaa2738bc430bc1ae1e730f994c Mon Sep 17 00:00:00 2001 From: David Knaack Date: Wed, 11 Feb 2026 17:25:33 +0100 Subject: [PATCH 08/15] address some of the review comments --- .../src/parser/media-type.spec.ts | 4 +- .../src/parser/media-type.ts | 19 +++--- .../openapi-generator/src/parser/schema.ts | 62 ++++++------------- .../openapi/src/openapi-request-builder.ts | 6 +- 4 files changed, 30 insertions(+), 61 deletions(-) diff --git a/packages/openapi-generator/src/parser/media-type.spec.ts b/packages/openapi-generator/src/parser/media-type.spec.ts index 2c5b169fc0..5fd34c2031 100644 --- a/packages/openapi-generator/src/parser/media-type.spec.ts +++ b/packages/openapi-generator/src/parser/media-type.spec.ts @@ -489,7 +489,7 @@ describe('parseTopLevelMediaType', () => { refs, defaultOptions ) - ).toThrow(/invalid content-type.*image\/png;;invalid.*file/i); + ).toThrow(/invalid content type.*image\/png;;invalid.*file/i); }); it('handles wildcard content types correctly', async () => { @@ -526,7 +526,7 @@ describe('parseTopLevelMediaType', () => { defaultOptions ) ).toThrow( - /invalid content-type.*not-a-valid-content-type-at-all.*attachment/i + /invalid content type.*not-a-valid-content-type-at-all.*attachment/i ); }); }); diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index f1a0455b5e..d72c244d00 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -42,7 +42,7 @@ function parseContentTypes( return parseContentType(ct); } catch (error: any) { throw new ErrorWithCause( - `Invalid content-type '${ct}' for property '${propName}' in OpenAPI specification. ` + + `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 @@ -93,12 +93,12 @@ function inferContentTypeFromSchema( * @returns Encoding map with inferred content types added, or undefined if no encodings. * @internal */ -function buildInferredEncodings( +function buildInferredEncodingsProperties( resolvedEncodings: string[], resolvedSchema: OpenAPIV3.SchemaObject, refs: OpenApiDocumentRefs ): EncodingMap | undefined { - if (!('properties' in resolvedSchema) || !resolvedSchema.properties) { + if (!resolvedSchema.properties) { return; } @@ -113,7 +113,7 @@ function buildInferredEncodings( // Resolve $ref for property schema const resolvedPropSchema = refs.resolveObject(propSchema); - if (Object.prototype.hasOwnProperty.call(resolvedPropSchema, '$ref')) { + if ('$ref' in resolvedPropSchema) { return; } @@ -165,7 +165,6 @@ function parseEncoding( ) : {}; - // Auto-infer content types based on schema types const schema = mediaTypeObject?.schema; if (!schema) { @@ -177,8 +176,9 @@ function parseEncoding( // Resolve $ref if present const resolvedSchema = refs.resolveObject(schema); + // Auto-infer missing content types based on schema types const implicitEncodings = - buildInferredEncodings( + buildInferredEncodingsProperties( Object.keys(explicitEncodings), resolvedSchema, refs @@ -221,12 +221,7 @@ export function parseTopLevelMediaType( : undefined; return { - schema: parseSchema( - mediaTypeObject.schema, - refs, - options, - mediaTypeObject.mediaType - ), + schema: parseSchema(mediaTypeObject.schema, refs, options), mediaType: mediaTypeObject.mediaType, encoding }; diff --git a/packages/openapi-generator/src/parser/schema.ts b/packages/openapi-generator/src/parser/schema.ts index ab36985a31..c79fe09b33 100644 --- a/packages/openapi-generator/src/parser/schema.ts +++ b/packages/openapi-generator/src/parser/schema.ts @@ -22,15 +22,13 @@ const logger = createLogger('openapi-generator'); * @param schema - Originally provided schema or reference object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. - * @param mediaType - Optional media type context for proper type mapping (e.g., 'multipart/form-data'). * @returns The parsed schema. * @internal */ export function parseSchema( schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined, refs: OpenApiDocumentRefs, - options: ParserOptions, - mediaType?: string + options: ParserOptions ): OpenApiSchema { if (!schema) { logger.verbose("No schema provided, continuing with 'any'."); @@ -42,7 +40,7 @@ export function parseSchema( } if (schema.type === 'array') { - return parseArraySchema(schema, refs, options, mediaType); + return parseArraySchema(schema, refs, options); } if (schema.enum?.length) { @@ -50,15 +48,15 @@ export function parseSchema( } if (schema.oneOf?.length || schema.discriminator) { - return parseXOfSchema(schema, refs, 'oneOf', options, mediaType); + return parseXOfSchema(schema, refs, 'oneOf', options); } if (schema.allOf?.length) { - return parseXOfSchema(schema, refs, 'allOf', options, mediaType); + return parseXOfSchema(schema, refs, 'allOf', options); } if (schema.anyOf?.length) { - return parseXOfSchema(schema, refs, 'anyOf', options, mediaType); + return parseXOfSchema(schema, refs, 'anyOf', options); } // An object schema should be parsed after allOf, anyOf, oneOf. @@ -68,12 +66,12 @@ export function parseSchema( schema.properties || schema.additionalProperties ) { - return parseObjectSchema(schema, refs, options, mediaType); + return parseObjectSchema(schema, refs, options); } if (schema.not) { return { - not: parseSchema(schema.not, refs, options, mediaType) + not: parseSchema(schema.not, refs, options) }; } @@ -97,18 +95,16 @@ function parseReferenceSchema( * @param schema - Original schema representing an array. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. - * @param mediaType - Optional media type context for proper type mapping. * @returns The recursively parsed array schema. */ function parseArraySchema( schema: OpenAPIV3.ArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions, - mediaType?: string + options: ParserOptions ): OpenApiArraySchema { return { uniqueItems: schema.uniqueItems, - items: parseSchema(schema.items, refs, options, mediaType) + items: parseSchema(schema.items, refs, options) }; } @@ -118,24 +114,17 @@ function parseArraySchema( * @param schema - Original schema representing an object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. - * @param mediaType - Optional media type context for proper type mapping. * @returns The recursively parsed object schema. */ export function parseObjectSchema( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions, - mediaType?: string + options: ParserOptions ): OpenApiObjectSchema { if (schema.discriminator) { - return parseXOfSchema(schema, refs, 'oneOf', options, mediaType); + return parseXOfSchema(schema, refs, 'oneOf', options); } - const properties = parseObjectSchemaProperties( - schema, - refs, - options, - mediaType - ); + const properties = parseObjectSchemaProperties(schema, refs, options); if (schema.additionalProperties === false) { if (!properties.length) { @@ -150,7 +139,7 @@ export function parseObjectSchema( const additionalProperties = typeof schema.additionalProperties === 'object' && Object.keys(schema.additionalProperties).length - ? parseSchema(schema.additionalProperties, refs, options, mediaType) + ? parseSchema(schema.additionalProperties, refs, options) : { type: 'any' }; return { @@ -164,20 +153,18 @@ export function parseObjectSchema( * @param schema - Original schema representing an object. * @param refs - Object representing cross references throughout the document. * @param options - Options that were set for service generation. - * @param mediaType - Optional media type context for proper type mapping. * @returns The list of parsed property schemas. */ function parseObjectSchemaProperties( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, - options: ParserOptions, - mediaType?: string + options: ParserOptions ): OpenApiObjectSchemaProperty[] { return Object.entries(schema.properties || {}).reduce( (props, [propName, propSchema]) => [ ...props, { - schema: parseSchema(propSchema, refs, options, mediaType), + schema: parseSchema(propSchema, refs, options), description: isReferenceObject(propSchema) ? undefined : propSchema.description, @@ -235,15 +222,13 @@ function getEnumStringValue(input: string): string { * @param refs - Object representing cross references throughout the document. * @param xOf - Key to identify which schema to parse. * @param options - Options that were set for service generation. - * @param mediaType - Optional media type context for proper type mapping. * @returns The parsed schema based on the given key. */ function parseXOfSchema( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, xOf: 'oneOf' | 'allOf' | 'anyOf', - options: ParserOptions, - mediaType?: string + options: ParserOptions ): any { const normalizedSchema = normalizeSchema(schema, xOf); @@ -260,8 +245,7 @@ function parseXOfSchema( ] }, refs, - options, - mediaType + options ) ) }; @@ -269,7 +253,7 @@ function parseXOfSchema( if (schema.discriminator && xOf !== 'allOf') { return { ...xOfSchema, - discriminator: parseDiscriminator(schema, refs, xOf, options, mediaType) + discriminator: parseDiscriminator(schema, refs, xOf, options) }; } @@ -280,8 +264,7 @@ function parseDiscriminator( schema: OpenAPIV3.NonArraySchemaObject, refs: OpenApiDocumentRefs, xOf: 'oneOf' | 'anyOf', - options: ParserOptions, - mediaType?: string + options: ParserOptions ): OpenApiDiscriminator { const { discriminator } = schema; @@ -298,12 +281,7 @@ function parseDiscriminator( mapping: Object.entries(discriminatorMapping).reduce( (mapping, [propertyValue, schemaMapping]) => ({ ...mapping, - [propertyValue]: parseSchema( - { $ref: schemaMapping }, - refs, - options, - mediaType - ) + [propertyValue]: parseSchema({ $ref: schemaMapping }, refs, options) }), {} ) diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 2fa5fc2bc9..6c9f3f88f5 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -262,11 +262,7 @@ export class OpenApiRequestBuilder { // We do not handle more complex content types !isFlexibleContentType && // Do the actual comparison - valueContentTypeBase.localeCompare( - parsedContentTypes[0].type, - undefined, - { sensitivity: 'base' } - ) !== 0 + valueContentTypeBase.toLowerCase() !== targetContentType.toLowerCase() ) { logger.warn( `Content type mismatch for key '${key}': value has type '${value.type}' but encoding specifies '${targetContentType}'.` From ce2e75fda0e5ade9d46603ace2c2c3f0b197079d Mon Sep 17 00:00:00 2001 From: David Knaack Date: Thu, 12 Feb 2026 09:35:23 +0100 Subject: [PATCH 09/15] refactor buildFormData into Builder-class --- .../openapi/src/openapi-request-builder.ts | 284 +++++++++++------- 1 file changed, 173 insertions(+), 111 deletions(-) diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 6c9f3f88f5..57daa99dfd 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -33,6 +33,176 @@ const logger = createLogger({ 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 || !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()) + ); + + if (value instanceof Blob) { + this.appendBlob(formData, key, value, metadata); + } else { + this.appendString(formData, key, value, metadata, allowedTypes); + } + } + + return formData; + } + + /** + * Append a Blob value to the FormData with appropriate content type handling. + * @param formData - The FormData object to append to. + * @param key - The field name. + * @param value - The Blob value to append. + * @param metadata - Encoding metadata for this field. + */ + private appendBlob( + formData: FormData, + key: string, + value: Blob, + metadata: EncodingMetadata + ): void { + const { contentType: targetContentType, isImplicit: targetIsImplicit, parsedContentTypes } = metadata; + const allowedTypes = new Set( + parsedContentTypes.map(ct => ct.type.toLowerCase()) + ); + + const isFlexibleContentType = + targetContentType && + (targetContentType.includes('*') || + parsedContentTypes.length > 1 || + allowedTypes.has('any')); + + // 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 && + !parsedContentTypes[0].parameters.charset + ) { + logger.debug( + `Adding missing content type '${targetContentType}' to Blob for key '${key}' as per encoding specification.` + ); + const withType = new Blob([value], { + type: targetContentType + }); + formData.append(key, withType); + return; + } + + // 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}'.` + ); + } + formData.append(key, value); + } + + /** + * Append a string value to the FormData with appropriate charset encoding. + * @param formData - The FormData object to append to. + * @param key - The field name. + * @param value - The value to append (will be stringified). + * @param metadata - Encoding metadata for this field. + * @param allowedTypes - Set of allowed content types for this field. + */ + private appendString( + formData: FormData, + key: string, + value: any, + metadata: EncodingMetadata, + allowedTypes: Set + ): void { + // 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 = new Set( + metadata.parsedContentTypes.map(ct => ct.parameters.charset) + ); + + if ( + targetCharset.size === 1 && + targetCharset.values().next().value !== undefined + ) { + const targetCharsetValue = targetCharset.values().next().value; + + // 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 + ); + } + + const blob = new Blob([buffer], { + type: metadata.contentType + }); + formData.append(key, blob); + return; + } + + formData.append(key, stringValue); + } +} + /** * Request builder for OpenAPI requests. * @template ResponseT - Type of the response for the request. @@ -199,122 +369,14 @@ export class OpenApiRequestBuilder { // Handle multipart/form-data body unless the body is already a FormData instance if (contentType === 'multipart/form-data' && !(body instanceof FormData)) { - return this.buildFormData(body); + const encoding = this.parameters!._encoding!; + const builder = new FormDataBuilder(body, encoding); + return builder.build(); } return body; } - private buildFormData(body: Record): FormData { - const formData = new FormData(); - const encoding = this.parameters!._encoding; - - for (const [key, value] of Object.entries(body ?? {})) { - if (!encoding || !encoding[key]) { - throw new Error( - `Missing encoding metadata for property '${key}'. ` + - 'This indicates a code generation issue. ' + - 'Please regenerate your API client.' - ); - } - - // Content type is provided by the generator in _encoding based on the schema - const { - contentType: targetContentType, - isImplicit: targetIsImplicit, - parsedContentTypes - } = encoding[key]; - // Use the first parsed content type (primary type) - const allowedTypes = new Set( - // TODO: compcase? - parsedContentTypes.map(ct => ct.type.toLowerCase()) - ); - if (value instanceof Blob) { - const isFlexibleContentType = - targetContentType && - (targetContentType.includes('*') || - parsedContentTypes.length > 1 || - allowedTypes.has('any')); - - // 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 && - !parsedContentTypes[0].parameters.charset - ) { - logger.debug( - `Adding missing content type '${targetContentType}' to Blob for key '${key}' as per encoding specification.` - ); - const withType = new Blob([value], { - type: targetContentType - }); - formData.append(key, withType); - continue; - } - - // 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}'.` - ); - } - formData.append(key, value); - continue; - } - - // 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 = new Set( - parsedContentTypes.map(ct => ct.parameters.charset) - ); - if ( - targetCharset.size === 1 && - targetCharset.values().next().value !== undefined - ) { - const targetCharsetValue = targetCharset.values().next().value; - - // 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 | undefined; - 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 - ); - } - - const blob = new Blob([buffer], { - type: targetContentType - }); - formData.append(key, blob); - continue; - } - - formData.append(key, stringValue); - } - - return formData; - } - private getPath(): string { const pathParameters = this.parameters?.pathParameters || {}; From 8e94d6f734b53060d5eb04ac2bba06861b137268 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Thu, 12 Feb 2026 09:46:02 +0100 Subject: [PATCH 10/15] improve charset handling --- packages/openapi/src/openapi-request-builder.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 57daa99dfd..60cbe438d0 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -192,9 +192,9 @@ class FormDataBuilder { ); } - const blob = new Blob([buffer], { - type: metadata.contentType - }); + // Append as Blob with appropriate content type if unambiguous + const maybeContentType = metadata.parsedContentTypes.length === 1 ? { type: metadata.contentType } : undefined; + const blob = new Blob([buffer], maybeContentType); formData.append(key, blob); return; } From 2c36f514f228843a6d8843d77fff0653c5c64f59 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Thu, 12 Feb 2026 10:49:02 +0100 Subject: [PATCH 11/15] improve naming, docs and enhance charset encoding handling --- .../src/parser/media-type.ts | 18 ++++++++------- .../openapi/src/openapi-request-builder.ts | 22 +++++++++++++------ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index d72c244d00..02b86b956f 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -86,14 +86,15 @@ function inferContentTypeFromSchema( } /** - * Add auto-inferred content types for schema properties. + * 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 buildInferredEncodingsProperties( +function inferMultipartEncodings( resolvedEncodings: string[], resolvedSchema: OpenAPIV3.SchemaObject, refs: OpenApiDocumentRefs @@ -136,14 +137,15 @@ function buildInferredEncodingsProperties( } /** - * Parse encoding object from a media type, extracting contentType for each property. - * Also automatically infers content types for properties with binary format. + * 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 contentType, or undefined. + * @returns Encoding configuration mapping property names to their content types and metadata, or undefined if no encodings. * @internal */ -function parseEncoding( +function parseMultipartEncodings( mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, refs: OpenApiDocumentRefs ): EncodingMap | undefined { @@ -178,7 +180,7 @@ function parseEncoding( // Auto-infer missing content types based on schema types const implicitEncodings = - buildInferredEncodingsProperties( + inferMultipartEncodings( Object.keys(explicitEncodings), resolvedSchema, refs @@ -217,7 +219,7 @@ export function parseTopLevelMediaType( if (mediaTypeObject) { const encoding = mediaTypeObject.mediaType.startsWith('multipart/') - ? parseEncoding(mediaTypeObject, refs) + ? parseMultipartEncodings(mediaTypeObject, refs) : undefined; return { diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 60cbe438d0..330cc6d9cb 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -86,6 +86,14 @@ class FormDataBuilder { 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'))); + } + /** * Append a Blob value to the FormData with appropriate content type handling. * @param formData - The FormData object to append to. @@ -104,17 +112,16 @@ class FormDataBuilder { parsedContentTypes.map(ct => ct.type.toLowerCase()) ); - const isFlexibleContentType = - targetContentType && - (targetContentType.includes('*') || - parsedContentTypes.length > 1 || - allowedTypes.has('any')); + 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 && - !parsedContentTypes[0].parameters.charset + // 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.` @@ -193,7 +200,8 @@ class FormDataBuilder { } // Append as Blob with appropriate content type if unambiguous - const maybeContentType = metadata.parsedContentTypes.length === 1 ? { type: metadata.contentType } : undefined; + const isFlexibleContentType = this.checkIsFlexibleContentType(metadata, allowedTypes); + const maybeContentType = !isFlexibleContentType ? { type: metadata.contentType } : undefined; const blob = new Blob([buffer], maybeContentType); formData.append(key, blob); return; From 4c4b59dfd0575a734f2d06e60f0d3a5dceeb47ec Mon Sep 17 00:00:00 2001 From: David Knaack Date: Thu, 12 Feb 2026 14:55:32 +0100 Subject: [PATCH 12/15] rename again --- .../src/parser/media-type.spec.ts | 64 +++++++++---------- .../src/parser/media-type.ts | 8 +-- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/packages/openapi-generator/src/parser/media-type.spec.ts b/packages/openapi-generator/src/parser/media-type.spec.ts index 5fd34c2031..66719090e9 100644 --- a/packages/openapi-generator/src/parser/media-type.spec.ts +++ b/packages/openapi-generator/src/parser/media-type.spec.ts @@ -7,7 +7,7 @@ const defaultOptions = { resolveExternal: true }; -function createMultipartContent(schema: any, encoding?: any) { +function createMultipartFormContent(schema: any, encoding?: any) { return { content: { 'multipart/form-data': { @@ -18,7 +18,7 @@ function createMultipartContent(schema: any, encoding?: any) { }; } -function createImplicitEncoding(contentType: string): { +function createImplicitMultipartFormEncoding(contentType: string): { contentType: string; isImplicit: true; parsedContentTypes: any[]; @@ -30,7 +30,7 @@ function createImplicitEncoding(contentType: string): { }; } -function createExplicitEncoding( +function createExplicitMultipartFormEncoding( contentType: string, parameters: Record = {} ): { @@ -147,7 +147,7 @@ describe('parseTopLevelMediaType', () => { }; const encoding = { profileImage: { contentType: 'image/png, image/jpeg' } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -166,7 +166,7 @@ describe('parseTopLevelMediaType', () => { it('returns undefined encoding when encoding object is empty', async () => { const result = parseTopLevelMediaType( - createMultipartContent({ type: 'object' }), + createMultipartFormContent({ type: 'object' }), await createTestRefs(), defaultOptions ); @@ -182,7 +182,7 @@ describe('parseTopLevelMediaType', () => { } }; const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), await createTestRefs(), defaultOptions ); @@ -210,8 +210,8 @@ describe('parseTopLevelMediaType', () => { }); expect(result?.mediaType).toBe('multipart/form-data'); expect(result?.encoding).toEqual({ - file: createImplicitEncoding('application/octet-stream'), - metadata: createImplicitEncoding('text/plain') + file: createImplicitMultipartFormEncoding('application/octet-stream'), + metadata: createImplicitMultipartFormEncoding('text/plain') }); }); @@ -222,13 +222,13 @@ describe('parseTopLevelMediaType', () => { }; const encoding = { image: { contentType: 'image/png' } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - image: createExplicitEncoding('image/png') + image: createExplicitMultipartFormEncoding('image/png') }); }); @@ -243,12 +243,12 @@ describe('parseTopLevelMediaType', () => { } }; const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), await createTestRefs(), defaultOptions ); - const textPlainEncoding = createImplicitEncoding('text/plain'); + const textPlainEncoding = createImplicitMultipartFormEncoding('text/plain'); expect(result?.encoding).toEqual({ name: textPlainEncoding, age: textPlainEncoding, @@ -266,14 +266,14 @@ describe('parseTopLevelMediaType', () => { } }; const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - tags: createImplicitEncoding('text/plain'), - files: createImplicitEncoding('application/octet-stream') + tags: createImplicitMultipartFormEncoding('text/plain'), + files: createImplicitMultipartFormEncoding('application/octet-stream') }); }); @@ -285,13 +285,13 @@ describe('parseTopLevelMediaType', () => { } }; const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - metadata: createImplicitEncoding('application/json') + metadata: createImplicitMultipartFormEncoding('application/json') }); }); @@ -313,7 +313,7 @@ describe('parseTopLevelMediaType', () => { } }); const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), refs, defaultOptions ); @@ -325,9 +325,9 @@ describe('parseTopLevelMediaType', () => { }); expect(result?.mediaType).toBe('multipart/form-data'); expect(result?.encoding).toEqual({ - file: createImplicitEncoding('application/octet-stream'), - target_columns: createImplicitEncoding('text/plain'), - metadata: createImplicitEncoding('application/json') + file: createImplicitMultipartFormEncoding('application/octet-stream'), + target_columns: createImplicitMultipartFormEncoding('text/plain'), + metadata: createImplicitMultipartFormEncoding('application/json') }); }); @@ -346,14 +346,14 @@ describe('parseTopLevelMediaType', () => { } }); const result = parseTopLevelMediaType( - createMultipartContent(schema), + createMultipartFormContent(schema), refs, defaultOptions ); expect(result?.encoding).toEqual({ - image: createImplicitEncoding('application/octet-stream'), - description: createImplicitEncoding('text/plain') + image: createImplicitMultipartFormEncoding('application/octet-stream'), + description: createImplicitMultipartFormEncoding('text/plain') }); }); @@ -364,7 +364,7 @@ describe('parseTopLevelMediaType', () => { }; const encoding = { textData: { contentType: 'text/plain; charset=utf-8' } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -427,7 +427,7 @@ describe('parseTopLevelMediaType', () => { } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -458,7 +458,7 @@ describe('parseTopLevelMediaType', () => { customText: { contentType: 'text/plain; charset=utf-16' } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); @@ -471,7 +471,7 @@ describe('parseTopLevelMediaType', () => { { type: 'text/plain', parameters: { charset: 'utf-16' } } ] }, - normalField: createImplicitEncoding('text/plain') + normalField: createImplicitMultipartFormEncoding('text/plain') }); }); @@ -485,7 +485,7 @@ describe('parseTopLevelMediaType', () => { expect(() => parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), refs, defaultOptions ) @@ -499,13 +499,13 @@ describe('parseTopLevelMediaType', () => { }; const encoding = { data: { contentType: 'image/*' } }; const result = parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), await createTestRefs(), defaultOptions ); expect(result?.encoding).toEqual({ - data: createExplicitEncoding('image/*') + data: createExplicitMultipartFormEncoding('image/*') }); }); @@ -521,7 +521,7 @@ describe('parseTopLevelMediaType', () => { expect(() => parseTopLevelMediaType( - createMultipartContent(schema, encoding), + createMultipartFormContent(schema, encoding), refs, defaultOptions ) diff --git a/packages/openapi-generator/src/parser/media-type.ts b/packages/openapi-generator/src/parser/media-type.ts index 02b86b956f..6455798389 100644 --- a/packages/openapi-generator/src/parser/media-type.ts +++ b/packages/openapi-generator/src/parser/media-type.ts @@ -94,7 +94,7 @@ function inferContentTypeFromSchema( * @returns Encoding map with inferred content types added, or undefined if no encodings. * @internal */ -function inferMultipartEncodings( +function inferMultipartFormEncodings( resolvedEncodings: string[], resolvedSchema: OpenAPIV3.SchemaObject, refs: OpenApiDocumentRefs @@ -145,7 +145,7 @@ function inferMultipartEncodings( * @returns Encoding configuration mapping property names to their content types and metadata, or undefined if no encodings. * @internal */ -function parseMultipartEncodings( +function parseMultipartFormEncodings( mediaTypeObject: OpenAPIV3.MediaTypeObject | undefined, refs: OpenApiDocumentRefs ): EncodingMap | undefined { @@ -180,7 +180,7 @@ function parseMultipartEncodings( // Auto-infer missing content types based on schema types const implicitEncodings = - inferMultipartEncodings( + inferMultipartFormEncodings( Object.keys(explicitEncodings), resolvedSchema, refs @@ -219,7 +219,7 @@ export function parseTopLevelMediaType( if (mediaTypeObject) { const encoding = mediaTypeObject.mediaType.startsWith('multipart/') - ? parseMultipartEncodings(mediaTypeObject, refs) + ? parseMultipartFormEncodings(mediaTypeObject, refs) : undefined; return { From 310b533afdd74fc1bb73fa49a3ff41980c7e38c8 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Mon, 16 Feb 2026 14:09:02 +0100 Subject: [PATCH 13/15] address review comments --- packages/openapi-generator/package.json | 6 +- .../tsconfig.for-prepack.json | 7 - packages/openapi/package.json | 4 +- .../openapi/src/openapi-request-builder.ts | 155 ++++++++++-------- packages/openapi/tsconfig.for-prepack.json | 7 - test-packages/e2e-tests/package.json | 1 - 6 files changed, 91 insertions(+), 89 deletions(-) delete mode 100644 packages/openapi-generator/tsconfig.for-prepack.json delete mode 100644 packages/openapi/tsconfig.for-prepack.json diff --git a/packages/openapi-generator/package.json b/packages/openapi-generator/package.json index 0983d2f215..f0a1e08953 100644 --- a/packages/openapi-generator/package.json +++ b/packages/openapi-generator/package.json @@ -30,28 +30,26 @@ }, "scripts": { "compile": "tsc -b", + "prepublishOnly": "yarn compile && yarn readme", "test": "yarn test:unit", "test:unit": "yarn node --experimental-vm-modules ../../node_modules/jest/bin/jest.js", "coverage": "jest --coverage", "lint": "eslint --ext .ts . && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -c", "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck . --ignores='@sap-cloud-sdk/openapi'", - "readme": "ts-node ../../scripts/replace-common-readme.ts", - "prepack": "test -f dist/index.js || tsc -p tsconfig.for-prepack.json || true" + "readme": "ts-node ../../scripts/replace-common-readme.ts" }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", "@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/tsconfig.for-prepack.json b/packages/openapi-generator/tsconfig.for-prepack.json deleted file mode 100644 index 7a5406aa4d..0000000000 --- a/packages/openapi-generator/tsconfig.for-prepack.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "skipLibCheck": true, - "noEmitOnError": false - } -} diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 75ebd778e1..87446dd375 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -30,14 +30,14 @@ }, "scripts": { "compile": "tsc -b", + "prepublishOnly": "yarn compile && yarn readme", "test": "yarn test:unit", "test:unit": "jest", "coverage": "jest --coverage", "lint": "eslint --ext .ts . && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -c", "lint:fix": "set TIMING=1 && eslint --ext .ts . --fix --quiet && prettier . --config ../../.prettierrc --ignore-path ../../.prettierignore -w --log-level error", "check:dependencies": "depcheck .", - "readme": "ts-node ../../scripts/replace-common-readme.ts", - "prepack": "test -f dist/index.js || tsc -p tsconfig.for-prepack.json || true" + "readme": "ts-node ../../scripts/replace-common-readme.ts" }, "dependencies": { "@sap-cloud-sdk/connectivity": "^4.4.0", diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 330cc6d9cb..6007e0c12a 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -6,7 +6,8 @@ import { isNullish, pickValueIgnoreCase, removeSlashes, - transformVariadicArgumentToArray + transformVariadicArgumentToArray, + unique } from '@sap-cloud-sdk/util'; import { useOrFetchDestination } from '@sap-cloud-sdk/connectivity'; import { @@ -63,7 +64,7 @@ class FormDataBuilder { const formData = new FormData(); for (const [key, value] of Object.entries(this.body ?? {})) { - if (!this.encoding || !this.encoding[key]) { + if (!this?.encoding[key]) { throw new Error( `Missing encoding metadata for property '${key}'. ` + 'This indicates a code generation issue. ' + @@ -76,43 +77,60 @@ class FormDataBuilder { metadata.parsedContentTypes.map(ct => ct.type.toLowerCase()) ); - if (value instanceof Blob) { - this.appendBlob(formData, key, value, metadata); - } else { - this.appendString(formData, key, value, metadata, allowedTypes); - } + 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 { + private checkIsFlexibleContentType( + metadata: EncodingMetadata, + allowedTypes: Set + ): boolean { const { contentType: targetContentType, parsedContentTypes } = metadata; - return (Boolean(targetContentType) && + return ( + Boolean(targetContentType) && (targetContentType.includes('*') || parsedContentTypes.length > 1 || - allowedTypes.has('any'))); - } + allowedTypes.has('any')) + ); + } /** - * Append a Blob value to the FormData with appropriate content type handling. - * @param formData - The FormData object to append to. - * @param key - The field name. - * @param value - The Blob value to append. - * @param metadata - Encoding metadata for this field. + * Encode a Blob value for FormData with appropriate content type handling. + * @param params - The parameters object. + * @param params.key - The field name. + * @param params.value - The Blob value to encode. + * @param params.metadata - Encoding metadata for this field. + * @returns Blob - The encoded Blob value. */ - private appendBlob( - formData: FormData, - key: string, - value: Blob, - metadata: EncodingMetadata - ): void { - const { contentType: targetContentType, isImplicit: targetIsImplicit, parsedContentTypes } = metadata; + private encodeBlob({ + key, + value, + metadata + }: { + 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); + 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 ( @@ -129,8 +147,7 @@ class FormDataBuilder { const withType = new Blob([value], { type: targetContentType }); - formData.append(key, withType); - return; + return withType; } // If `Blob` has a type, we do a surface-level check to warn users about potential mismatches with the specification @@ -149,24 +166,29 @@ class FormDataBuilder { `Content type mismatch for key '${key}': value has type '${value.type}' but encoding specifies '${targetContentType}'.` ); } - formData.append(key, value); + return value; } /** - * Append a string value to the FormData with appropriate charset encoding. - * @param formData - The FormData object to append to. - * @param key - The field name. - * @param value - The value to append (will be stringified). - * @param metadata - Encoding metadata for this field. - * @param allowedTypes - Set of allowed content types for this field. + * Encode a string value for FormData with appropriate content type and charset handling. + * @param params - The parameters object. + * @param params.key - The field name. + * @param params.value - The value to encode. + * @param params.metadata - Encoding metadata for this field. + * @param params.allowedTypes - Set of allowed content types for this field. + * @returns Blob or string - The encoded value. */ - private appendString( - formData: FormData, - key: string, - value: any, - metadata: EncodingMetadata, - allowedTypes: Set - ): void { + private encodeString({ + key, + value, + metadata, + allowedTypes + }: { + key: string; + value: any; + metadata: EncodingMetadata; + allowedTypes: Set; + }): Blob | string { // 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` @@ -175,39 +197,36 @@ class FormDataBuilder { : String(value); // If a charset is specified in the encoding, we encode the string accordingly (if unambiguous) - const targetCharset = new Set( + const targetCharset = unique( metadata.parsedContentTypes.map(ct => ct.parameters.charset) ); - if ( - targetCharset.size === 1 && - targetCharset.values().next().value !== undefined - ) { - const targetCharsetValue = targetCharset.values().next().value; - - // 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 - ); - } - - // Append 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); - formData.append(key, blob); - return; + 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 + ); } - formData.append(key, stringValue); + // 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; } } diff --git a/packages/openapi/tsconfig.for-prepack.json b/packages/openapi/tsconfig.for-prepack.json deleted file mode 100644 index 7a5406aa4d..0000000000 --- a/packages/openapi/tsconfig.for-prepack.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "skipLibCheck": true, - "noEmitOnError": false - } -} diff --git a/test-packages/e2e-tests/package.json b/test-packages/e2e-tests/package.json index bf25e94adb..06301ed258 100644 --- a/test-packages/e2e-tests/package.json +++ b/test-packages/e2e-tests/package.json @@ -41,7 +41,6 @@ "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" From 9bd73183d6df746aec894ac6a0dd277d99ee5c36 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Mon, 16 Feb 2026 14:26:30 +0100 Subject: [PATCH 14/15] package.json fix --- packages/openapi-generator/package.json | 2 + test-packages/e2e-tests/package.json | 1 + yarn.lock | 80 +++++++++++++++++++++---- 3 files changed, 73 insertions(+), 10 deletions(-) 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/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/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" From 147e4d155ff0b27f7d1ba069cddfde12560c01fa Mon Sep 17 00:00:00 2001 From: David Knaack Date: Mon, 16 Feb 2026 14:31:36 +0100 Subject: [PATCH 15/15] only wrap k/v in object --- .../openapi/src/openapi-request-builder.ts | 55 ++++++++++--------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/openapi/src/openapi-request-builder.ts b/packages/openapi/src/openapi-request-builder.ts index 6007e0c12a..78be20e337 100644 --- a/packages/openapi/src/openapi-request-builder.ts +++ b/packages/openapi/src/openapi-request-builder.ts @@ -79,8 +79,8 @@ class FormDataBuilder { const encoded: string | Blob = value instanceof Blob - ? this.encodeBlob({ key, value, metadata }) - : this.encodeString({ key, value, metadata, allowedTypes }); + ? this.encodeBlob({ key, value }, metadata) + : this.encodeString({ key, value }, metadata, allowedTypes); formData.append(key, encoded); } @@ -103,21 +103,22 @@ class FormDataBuilder { /** * Encode a Blob value for FormData with appropriate content type handling. - * @param params - The parameters object. + * @param params - The key and value to encode. * @param params.key - The field name. * @param params.value - The Blob value to encode. - * @param params.metadata - Encoding metadata for this field. + * @param metadata - Encoding metadata for this field. * @returns Blob - The encoded Blob value. */ - private encodeBlob({ - key, - value, - metadata - }: { - key: string; - value: Blob; - metadata: EncodingMetadata; - }): Blob { + private encodeBlob( + { + key, + value + }: { + key: string; + value: Blob; + }, + metadata: EncodingMetadata + ): Blob { const { contentType: targetContentType, isImplicit: targetIsImplicit, @@ -171,24 +172,24 @@ class FormDataBuilder { /** * Encode a string value for FormData with appropriate content type and charset handling. - * @param params - The parameters object. + * @param params - The key and value to encode. * @param params.key - The field name. * @param params.value - The value to encode. - * @param params.metadata - Encoding metadata for this field. - * @param params.allowedTypes - Set of allowed content types for this field. + * @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, - metadata, - allowedTypes - }: { - key: string; - value: any; - metadata: EncodingMetadata; - allowedTypes: Set; - }): Blob | string { + 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`