From c9fb47d0400638671b20d039cc87cfedfb2cc1b4 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 29 Sep 2026 15:27:35 +0700 Subject: [PATCH 1/2] fix(downgrader): keep untyped multipart parts sent as octet-stream in 3.1 to 3.0 In 3.1, a multipart or URL-encoded request body part with no `type`, or a string with `contentEncoding`, defaults to `application/octet-stream`. 3.0 has no default for untyped parts and sends such strings as `text/plain`, so a file part downgraded to 3.0 lost its content type. The converter now sets `contentType: application/octet-stream` in the part's Encoding Object, unless 3.0's defaults already give it or the Encoding Object sets a content type or RFC6570-style fields. --- packages/downgrader/README.md | 15 ++- packages/downgrader/src/shared.test.ts | 4 + packages/downgrader/src/shared.ts | 4 +- packages/downgrader/src/v3.1-to-v3.0.test.ts | 133 +++++++++++++++++++ packages/downgrader/src/v3.1-to-v3.0.ts | 92 ++++++++++++- packages/downgrader/tests/e2e.test.ts | 44 ++++++ 6 files changed, 282 insertions(+), 10 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 3d499d1..d936c7f 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -90,13 +90,14 @@ Removed, with no 3.1 equivalent: Converted: -| 3.1 construct | 3.0 result | -| ---------------------------------------------------------- | -------------------------------------- | -| `openapi: 3.1.x` | `openapi: 3.0.4` | -| missing `paths` | `{}` | -| missing operation `responses` | `{ "default": { "description": "" } }` | -| path parameter without `required: true` | `required: true` | -| security requirement scopes on `apiKey` and `http` schemes | `[]` | +| 3.1 construct | 3.0 result | +| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `openapi: 3.1.x` | `openapi: 3.0.4` | +| missing `paths` | `{}` | +| missing operation `responses` | `{ "default": { "description": "" } }` | +| path parameter without `required: true` | `required: true` | +| security requirement scopes on `apiKey` and `http` schemes | `[]` | +| `multipart` or URL-encoded body part with no `type`, or a `string` with `contentEncoding` | `contentType: application/octet-stream`, the 3.1 default, unless the part converts to a `binary` or `byte` string or its Encoding Object sets a content type or RFC6570-style fields | Removed, with no 3.0 equivalent: diff --git a/packages/downgrader/src/shared.test.ts b/packages/downgrader/src/shared.test.ts index 20de578..598e343 100644 --- a/packages/downgrader/src/shared.test.ts +++ b/packages/downgrader/src/shared.test.ts @@ -415,6 +415,10 @@ describe('map', () => { expect(keys).toEqual(['a', 'b', 'raw']) }) + it('passes each entry key to the converter', () => { + expect(map((item, _ctx, key) => `${key}=${item}`)({ a: 1, b: 2 }, createContext())).toEqual({ a: 'a=1', b: 'b=2' }) + }) + it('leaves out entries whose converter returns DROP', () => { expect(map(item => (item === 2 ? DROP : item))({ a: 1, b: 2, c: 3 }, createContext())).toEqual({ a: 1, c: 3 }) }) diff --git a/packages/downgrader/src/shared.ts b/packages/downgrader/src/shared.ts index ba72cf2..bbd6dde 100644 --- a/packages/downgrader/src/shared.ts +++ b/packages/downgrader/src/shared.ts @@ -109,14 +109,14 @@ export function convertObject(value: unknown, ctx: Context, fields: Fields, fini return result } -export function map(convert: Convert, isEntry: (key: string) => boolean = () => true): Convert { +export function map(convert: (value: unknown, ctx: Context, key: string) => unknown, isEntry: (key: string) => boolean = () => true): Convert { return (value, ctx) => { if (!isRecord(value)) { return clone(value, ctx) } const out: Record = {} for (const [key, item] of Object.entries(value)) { - const converted = isEntry(key) ? convert(item, ctx) : clone(item, ctx) + const converted = isEntry(key) ? convert(item, ctx, key) : clone(item, ctx) if (converted !== DROP) { setOwn(out, key, converted) } diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 37ea2ca..699e420 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1422,6 +1422,139 @@ describe('downgradeSpecV31ToV30', () => { }) }) + describe('form request body parts', () => { + const octetStream = { contentType: 'application/octet-stream' } + + function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { + const result = convertSpec({ + components: { + requestBodies: { X: { content: { [type]: mediaType } } }, + schemas: { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} }, + }, + }) + return dig(result, 'components', 'requestBodies', 'X', 'content', type) + } + + it.each([ + ['a schema without type', {}], + ['a true schema', true], + ['a string with a contentEncoding that no 3.0 format expresses', { contentEncoding: 'base64url', type: 'string' }], + ['an array of untyped items', { items: {}, type: 'array' }], + ['an array without items', { type: 'array' }], + ['untyped anyOf branches', { anyOf: [{ contentMediaType: 'image/png' }, { contentMediaType: 'image/jpeg' }] }], + ['a reference to an untyped schema', { $ref: '#/components/schemas/Raw' }], + ])('sets contentType: application/octet-stream, the 3.1 default, on %s', (_name, part) => { + expect(convertForm({ schema: { properties: { part } } })).toEqual({ + encoding: { part: octetStream }, + schema: { properties: { part: expect.anything() } }, + }) + }) + + it.each([ + ['a string', { format: 'uuid', type: 'string' }], + ['an object', { type: 'object' }], + ['raw binary, which becomes format binary', { contentMediaType: 'image/png' }], + ['base64, which becomes format byte', { contentEncoding: 'base64', type: 'string' }], + ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], + ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], + ['a null type', { type: 'null' }], + ['several types', { type: ['string', 'integer'] }], + ['typed prefixItems', { prefixItems: [{ type: 'string' }], type: 'array' }], + ['nested arrays, which have no multipart form', { items: { items: {}, type: 'array' }, type: 'array' }], + ['an external reference', { $ref: 'other.yaml#/File' }], + ['a missing reference', { $ref: '#/components/schemas/Missing' }], + ['a false schema', false], + ])('adds no Encoding Object for %s', (_name, part) => { + expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') + }) + + it('adds no Encoding Object for an array whose items loop back to it', () => { + const part: Record = { type: 'array' } + part.items = part + expect(convertForm({ schema: { properties: { part } } })).not.toHaveProperty('encoding') + }) + + it('keeps Encoding Objects that set contentType or RFC6570-style fields, and adds contentType beside headers', () => { + const headers = { 'X-Id': { schema: { type: 'string' } } } + expect( + convertForm({ + encoding: { + exploded: { explode: true }, + explicit: { contentType: 'image/png' }, + headed: { headers }, + junk: 'junk', + reserved: { allowReserved: true }, + styled: { style: 'form' }, + }, + schema: { properties: { exploded: {}, explicit: {}, headed: {}, junk: {}, reserved: {}, styled: {} } }, + }), + ).toEqual({ + encoding: { + exploded: { explode: true }, + explicit: { contentType: 'image/png' }, + headed: { ...octetStream, headers }, + junk: 'junk', + reserved: { allowReserved: true }, + styled: { style: 'form' }, + }, + schema: { properties: { exploded: {}, explicit: {}, headed: {}, junk: {}, reserved: {}, styled: {} } }, + }) + }) + + it('finds parts through references and allOf in the body schema, including keys named like Object.prototype members', () => { + expect(convertForm({ schema: { $ref: '#/components/schemas/Form' } })).toEqual({ + encoding: { a: octetStream, b: octetStream }, + schema: { $ref: '#/components/schemas/Form' }, + }) + const encoding = dig(convertForm({ schema: { properties: JSON.parse('{"__proto__":{}}') } }), 'encoding') as object + expect(Object.getPrototypeOf(encoding)).toBe(Object.prototype) + expect(Object.hasOwn(encoding, '__proto__')).toBe(true) + }) + + it('applies to multipart and URL-encoded request bodies only', () => { + const mediaType = { schema: { properties: { file: {} } } } + for (const type of ['multipart/mixed', 'Application/X-WWW-Form-Urlencoded; charset=utf-8']) { + expect(convertForm(mediaType, type)).toEqual({ ...mediaType, encoding: { file: octetStream } }) + } + for (const type of ['application/json', 'application/x-www-form-urlencoded-v2']) { + expect(convertForm(mediaType, type)).toEqual(mediaType) + } + const content = { 'multipart/form-data': mediaType } + expect(convertComponent('responses', { content, description: 'd' })).toEqual({ content, description: 'd' }) + expect(convertComponent('parameters', { content, in: 'query', name: 'q' })).toEqual({ content, in: 'query', name: 'q' }) + }) + + it('converts a media type shared between a form body and a response as each', () => { + const mediaType = { schema: { properties: { file: {} } } } + const result = convertSpec({ + components: { + requestBodies: { B: { content: { 'multipart/form-data': mediaType } } }, + responses: { R: { content: { 'multipart/form-data': mediaType }, description: 'd' } }, + }, + }) + expect(dig(result, 'components', 'requestBodies', 'B', 'content', 'multipart/form-data')).toEqual({ ...mediaType, encoding: { file: octetStream } }) + expect(dig(result, 'components', 'responses', 'R', 'content', 'multipart/form-data')).toEqual(mediaType) + }) + + it('leaves an Encoding Object shared with another part unchanged', () => { + const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } + const content = { + 'multipart/form-data': { encoding: { part: entry }, schema: { properties: { part: {} } } }, + 'multipart/mixed': { encoding: { part: entry }, schema: { properties: { part: { type: 'string' } } } }, + } + const result = dig(convertComponent('requestBodies', { content }), 'content') + expect(dig(result, 'multipart/form-data', 'encoding', 'part')).toEqual({ ...entry, ...octetStream }) + expect(dig(result, 'multipart/mixed', 'encoding', 'part')).toEqual(entry) + }) + + it('leaves a malformed encoding value alone', () => { + expect(convertForm({ encoding: 'junk', schema: { properties: { file: {} } } })).toEqual({ + encoding: 'junk', + schema: { properties: { file: {} } }, + }) + }) + }) + describe('components', () => { it('removes pathItems and keeps the other component maps', () => { const result = convertSpec({ diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 013ca53..2a850a1 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -60,10 +60,15 @@ const ANNOTATION_KEYWORDS = [ 'examples', ] +const FORM_MEDIA_TYPE = /^(?:multipart\/|application\/x-www-form-urlencoded\s*(?:;|$))/i + +const CONTENT_TYPE_OVERRIDES = ['allowReserved', 'contentType', 'explode', 'style'] + const LOOSE = new WeakSet() const convertCallback = map(convertPathItem, isNotExtension) const convertContent = map(convertMediaType) +const convertRequestContent = map(convertRequestMediaType) const convertRequirements = list(convertRequirement) const finishPathItem = mergeRef(convertPathItem) const convertCallbackRef = refOr(convertCallback, reference) @@ -120,12 +125,14 @@ const MEDIA_TYPE_FIELDS = defineFields({ schema: convertSchema, }) +const FORM_MEDIA_TYPE_FIELDS = new Map(MEDIA_TYPE_FIELDS) + const ENCODING_FIELDS = defineFields({ headers: map(convertParameterRef), }) const REQUEST_BODY_FIELDS = defineFields({ - content: convertContent, + content: convertRequestContent, }) const RESPONSE_FIELDS = defineFields({ @@ -343,6 +350,89 @@ function convertMediaType(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, MEDIA_TYPE_FIELDS) } +function subschemas(schemas: readonly unknown[], ctx: Context): Set { + const nodes = new Set(schemas) + for (const node of nodes) { + if (isRecord(node)) { + const ref = getRef(node) + if (ref !== undefined) { + nodes.add(ctx.resolve(ref)) + } + for (const key of ['allOf', 'anyOf', 'oneOf']) { + for (const item of Array.isArray(node[key]) ? node[key] : []) { + nodes.add(item) + } + } + } + } + return nodes +} + +function formParts(schema: unknown, ctx: Context): Map { + const parts = new Map() + for (const node of subschemas([schema], ctx)) { + if (isRecord(node) && isRecord(node.properties)) { + for (const [name, property] of Object.entries(node.properties)) { + parts.set(name, [...parts.get(name) ?? [], property]) + } + } + } + return parts +} + +function defaultsToOctetStream(schemas: readonly unknown[], ctx: Context, isItem = false): boolean { + const nodes = [...subschemas(schemas, ctx)] + if (!nodes.every(node => isRecord(node) || node === true)) { + return false + } + const records = nodes.filter(isRecord) + const types = records.flatMap(node => [node.type ?? []].flat()) + const kinds = new Set(types.filter(type => type !== 'null')) + if (types.length === 0) { + return true + } + if (kinds.size !== 1) { + return false + } + if (kinds.has('string')) { + return records.some(node => node.contentEncoding !== undefined) + } + const items = records.flatMap(node => [node.prefixItems ?? [], node.items ?? []].flat()) + return !isItem && kinds.has('array') && (items.length === 0 || defaultsToOctetStream(items, ctx, true)) +} + +function defaultsToOctetStreamIn30(schema: unknown): boolean { + const part = isRecord(schema) && schema.type === 'array' ? schema.items : schema + return isRecord(part) && part.type === 'string' && (part.format === 'binary' || part.format === 'byte') +} + +function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown { + const encoding = out.encoding ?? {} + if (!isRecord(encoding)) { + return out + } + const properties = child(out.schema, 'properties') + for (const [name, schemas] of formParts(mediaType.schema, ctx)) { + const entry = child(encoding, name) ?? {} + if ( + isRecord(entry) + && !CONTENT_TYPE_OVERRIDES.some(key => Object.hasOwn(entry, key)) + && !defaultsToOctetStreamIn30(child(properties, name)) + && defaultsToOctetStream(schemas, ctx) + ) { + setOwn(encoding, name, { ...entry, contentType: 'application/octet-stream' }) + out.encoding = encoding + } + } + return out +} + +function convertRequestMediaType(value: unknown, ctx: Context, type: string): unknown { + return FORM_MEDIA_TYPE.test(type) + ? convertObject(value, ctx, FORM_MEDIA_TYPE_FIELDS, finishFormMediaType) + : convertMediaType(value, ctx) +} + function convertEncoding(value: unknown, ctx: Context): unknown { return convertObject(value, ctx, ENCODING_FIELDS) } diff --git a/packages/downgrader/tests/e2e.test.ts b/packages/downgrader/tests/e2e.test.ts index 5e70005..06124fa 100644 --- a/packages/downgrader/tests/e2e.test.ts +++ b/packages/downgrader/tests/e2e.test.ts @@ -258,6 +258,50 @@ describe('3.1 example documents downgraded to 3.0', () => { await expectValidAs(converted, '3.0') expect(doc).toEqual(before) }) + + it('keeps untyped multipart parts sent as application/octet-stream', async () => { + const schema: OpenAPIV3_1.SchemaObject = { + properties: { + addresses: { items: { type: 'object' }, type: 'array' }, + file: { items: {}, type: 'array' }, + id: { format: 'uuid', type: 'string' }, + profileImage: {}, + }, + type: 'object', + } + const headers = { 'X-Rate-Limit-Limit': { schema: { type: 'integer' } } } as const + const doc: OpenAPIV3_1.OpenAPIObject = { + info: { title: 'Uploads', version: '1.0.0' }, + openapi: '3.1.0', + paths: { + '/profile': { + post: { + requestBody: { + content: { + 'multipart/form-data': { encoding: { profileImage: { headers } }, schema }, + }, + }, + responses: { 204: { description: 'saved' } }, + }, + }, + }, + } + const before = structuredClone(doc) + const converted = downgradeSpecV31ToV30(doc) + expect(converted.paths['/profile']?.post?.requestBody).toEqual({ + content: { + 'multipart/form-data': { + encoding: { + file: { contentType: 'application/octet-stream' }, + profileImage: { contentType: 'application/octet-stream', headers }, + }, + schema, + }, + }, + }) + await expectValidAs(converted, '3.0') + expect(doc).toEqual(before) + }) }) describe('3.2 example documents downgraded to 3.1 and chained to 3.0', () => { From 141b7f19ca800a861a439932987da9463d3069bb Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 30 Sep 2026 09:22:25 +0700 Subject: [PATCH 2/2] fix(downgrader): set contentType on every octet-stream form part The 3.0-default check only saw properties declared directly on the body schema, so a form behind a `$ref` got an explicit `contentType` for binary parts while the same form inlined got none. Every part that 3.1 sends as `application/octet-stream` now gets the explicit `contentType`, whether the body schema is inline or a reference. What is sent does not change. --- packages/downgrader/README.md | 16 +++++++-------- packages/downgrader/src/v3.1-to-v3.0.test.ts | 21 +++++++++++++++++--- packages/downgrader/src/v3.1-to-v3.0.ts | 7 ------- 3 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index d936c7f..1141ff7 100644 --- a/packages/downgrader/README.md +++ b/packages/downgrader/README.md @@ -90,14 +90,14 @@ Removed, with no 3.1 equivalent: Converted: -| 3.1 construct | 3.0 result | -| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `openapi: 3.1.x` | `openapi: 3.0.4` | -| missing `paths` | `{}` | -| missing operation `responses` | `{ "default": { "description": "" } }` | -| path parameter without `required: true` | `required: true` | -| security requirement scopes on `apiKey` and `http` schemes | `[]` | -| `multipart` or URL-encoded body part with no `type`, or a `string` with `contentEncoding` | `contentType: application/octet-stream`, the 3.1 default, unless the part converts to a `binary` or `byte` string or its Encoding Object sets a content type or RFC6570-style fields | +| 3.1 construct | 3.0 result | +| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `openapi: 3.1.x` | `openapi: 3.0.4` | +| missing `paths` | `{}` | +| missing operation `responses` | `{ "default": { "description": "" } }` | +| path parameter without `required: true` | `required: true` | +| security requirement scopes on `apiKey` and `http` schemes | `[]` | +| `multipart` or URL-encoded body part with no `type`, or a `string` with `contentEncoding` | `contentType: application/octet-stream`, the 3.1 default, unless its Encoding Object sets a content type or RFC6570-style fields | Removed, with no 3.0 equivalent: diff --git a/packages/downgrader/src/v3.1-to-v3.0.test.ts b/packages/downgrader/src/v3.1-to-v3.0.test.ts index 699e420..1111460 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.test.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.test.ts @@ -1438,8 +1438,11 @@ describe('downgradeSpecV31ToV30', () => { it.each([ ['a schema without type', {}], ['a true schema', true], + ['raw binary', { contentMediaType: 'image/png' }], + ['a base64 string', { contentEncoding: 'base64', type: 'string' }], ['a string with a contentEncoding that no 3.0 format expresses', { contentEncoding: 'base64url', type: 'string' }], ['an array of untyped items', { items: {}, type: 'array' }], + ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], ['an array without items', { type: 'array' }], ['untyped anyOf branches', { anyOf: [{ contentMediaType: 'image/png' }, { contentMediaType: 'image/jpeg' }] }], ['a reference to an untyped schema', { $ref: '#/components/schemas/Raw' }], @@ -1450,12 +1453,24 @@ describe('downgradeSpecV31ToV30', () => { }) }) + it('writes the same Encoding Object whether the body schema is inline or a reference', () => { + const result = convertSpec({ + components: { + requestBodies: { + Inline: { content: { 'multipart/form-data': { schema: { properties: { img: { contentMediaType: 'image/png' } } } } } }, + Referenced: { content: { 'multipart/form-data': { schema: { $ref: '#/components/schemas/Upload' } } } }, + }, + schemas: { Upload: { properties: { img: { contentMediaType: 'image/png' } } } }, + }, + }) + for (const name of ['Inline', 'Referenced']) { + expect(dig(result, 'components', 'requestBodies', name, 'content', 'multipart/form-data', 'encoding')).toEqual({ img: octetStream }) + } + }) + it.each([ ['a string', { format: 'uuid', type: 'string' }], ['an object', { type: 'object' }], - ['raw binary, which becomes format binary', { contentMediaType: 'image/png' }], - ['base64, which becomes format byte', { contentEncoding: 'base64', type: 'string' }], - ['an array of raw binary', { items: { contentMediaType: 'image/png' }, type: 'array' }], ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], ['a null type', { type: 'null' }], ['several types', { type: ['string', 'integer'] }], diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 2a850a1..a8105f9 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -401,23 +401,16 @@ function defaultsToOctetStream(schemas: readonly unknown[], ctx: Context, isItem return !isItem && kinds.has('array') && (items.length === 0 || defaultsToOctetStream(items, ctx, true)) } -function defaultsToOctetStreamIn30(schema: unknown): boolean { - const part = isRecord(schema) && schema.type === 'array' ? schema.items : schema - return isRecord(part) && part.type === 'string' && (part.format === 'binary' || part.format === 'byte') -} - function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown { const encoding = out.encoding ?? {} if (!isRecord(encoding)) { return out } - const properties = child(out.schema, 'properties') for (const [name, schemas] of formParts(mediaType.schema, ctx)) { const entry = child(encoding, name) ?? {} if ( isRecord(entry) && !CONTENT_TYPE_OVERRIDES.some(key => Object.hasOwn(entry, key)) - && !defaultsToOctetStreamIn30(child(properties, name)) && defaultsToOctetStream(schemas, ctx) ) { setOwn(encoding, name, { ...entry, contentType: 'application/octet-stream' })