From 9592d22213d6592a09ce1fcd0a9aed2276eddfd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:30:47 +0000 Subject: [PATCH 1/2] fix(downgrader): keep binary-format string parts sent as text/plain in 3.1 to 3.0 In 3.1.2 a `string` part without `contentEncoding` defaults to `text/plain`, but 3.0.4 defaults a `string` with `format: binary` or `byte` to `application/octet-stream`. A part such as `{ type: string, format: binary }`, or `{ type: string, contentMediaType: image/png }` (which converts to `format: binary`), therefore changed its wire Content-Type when downgraded. The form body pass now writes `contentType: text/plain` for such parts, looking through `$ref`, `allOf`, `anyOf`, `oneOf` and array items as it already did for `application/octet-stream`, and still leaving entries that set `contentType`, `style`, `explode` or `allowReserved` alone. `contentMediaType` is not used as the part's `contentType`: it does not change the 3.1 default, and the spec ignores it when it contradicts one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ZyitjJcKySVWCeAHFqvkr --- packages/downgrader/src/v3.1-to-v3.0.ts | 35 ++++++++---- .../v3.1-to-v3.0/spec/form-bodies.test.ts | 57 +++++++++++++++++-- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 4c88e07..6e0cd2f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -381,25 +381,36 @@ function formParts(schema: unknown, ctx: Context): Map { return parts } -function defaultsToOctetStream(schemas: readonly unknown[], ctx: Context, isItem = false): boolean { +function hasBinaryFormat(node: Record): boolean { + const format = node.format ?? (typeof node.contentMediaType === 'string' ? 'binary' : undefined) + return format === 'binary' || format === 'byte' +} + +function partContentType(schemas: readonly unknown[], ctx: Context, isItem = false): string | undefined { const nodes = [...subschemas(schemas, ctx)] if (!nodes.every(node => isRecord(node) || node === true)) { - return false + return undefined } 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 + return 'application/octet-stream' } if (kinds.size !== 1) { - return false + return undefined } if (kinds.has('string')) { - return records.some(node => node.contentEncoding !== undefined) + if (records.some(node => node.contentEncoding !== undefined)) { + return 'application/octet-stream' + } + return records.some(hasBinaryFormat) ? 'text/plain' : undefined + } + if (isItem || !kinds.has('array')) { + return undefined } const items = records.flatMap(node => [node.prefixItems ?? [], node.items ?? []].flat()) - return !isItem && kinds.has('array') && (items.length === 0 || defaultsToOctetStream(items, ctx, true)) + return items.length === 0 ? 'application/octet-stream' : partContentType(items, ctx, true) } function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown { @@ -409,12 +420,12 @@ function finishFormMediaType(out: Record, mediaType: Record Object.hasOwn(entry, key)) - && defaultsToOctetStream(schemas, ctx) - ) { - setOwn(encoding, name, { ...entry, contentType: 'application/octet-stream' }) + if (!isRecord(entry) || CONTENT_TYPE_OVERRIDES.some(key => Object.hasOwn(entry, key))) { + continue + } + const contentType = partContentType(schemas, ctx) + if (contentType !== undefined) { + setOwn(encoding, name, { ...entry, contentType }) out.encoding = encoding } } diff --git a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts index ff5bbe7..a57b5c8 100644 --- a/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts +++ b/packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts @@ -12,11 +12,18 @@ // other strings → text/plain, `object` → application/json, // `array` → the default of its `items`, and nothing for a missing `type` // -// The schema conversion keeps most of these aligned, but not an untyped -// part (`{}` has no 3.0 default) or a `contentEncoding` that `format: byte` -// cannot express (such as base64url, which becomes a plain string and so -// text/plain). For those parts the 3.1 default, application/octet-stream, -// is written into the Encoding Object so the wire format stays the same. +// The schema conversion keeps most of these aligned, but not: +// - an untyped part (`{}` has no 3.0 default) or a `contentEncoding` that +// `format: byte` cannot express (such as base64url, which becomes a plain +// string and so text/plain). 3.1 sends these as application/octet-stream. +// - a string without `contentEncoding` that has `format: binary` or `byte`, +// or gains `format: binary` from `contentMediaType`. 3.1 sends these as +// text/plain, 3.0 as application/octet-stream. +// For those parts the 3.1 default is written into the Encoding Object so +// the wire format stays the same. `contentMediaType` does not change the +// 3.1 default, and a contradicting one is ignored +// (https://spec.openapis.org/oas/v3.1.2.html#working-with-binary-data), +// so it does not become the part's `contentType`. // // An Encoding Object that sets `style`, `explode`, or `allowReserved` // switches the part to RFC6570-style serialization, where `contentType` @@ -28,7 +35,14 @@ import { convertComponent, convertSpec } from './helpers' const octetStream = { contentType: 'application/octet-stream' } -const schemas = { Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, Pet: { type: 'object' }, Raw: {} } +const textPlain = { contentType: 'text/plain' } + +const schemas = { + Form: { allOf: [{ properties: { a: {} } }], properties: { b: {} } }, + Pet: { type: 'object' }, + Png: { contentMediaType: 'image/png', type: 'string' }, + Raw: {}, +} function convertForm(mediaType: unknown, type = 'multipart/form-data'): unknown { return dig(convertComponent('requestBodies', { content: { [type]: mediaType } }, { schemas }), 'content', type) @@ -83,11 +97,29 @@ describe('parts that need the 3.1 default written out', () => { expect(Object.getPrototypeOf(encoding)).toBe(Object.prototype) expect(Object.getOwnPropertyDescriptor(encoding, '__proto__')?.value).toEqual(octetStream) }) + + it.each([ + ['a string with contentMediaType', { contentMediaType: 'image/png', type: 'string' }], + ['a binary string', { format: 'binary', type: 'string' }], + ['a byte string', { format: 'byte', type: 'string' }], + ['a nullable string with contentMediaType', { contentMediaType: 'text/csv', type: ['string', 'null'] }], + ['an array of strings with contentMediaType', { items: { contentMediaType: 'image/png', type: 'string' }, type: 'array' }], + ['a string with a binary format found through allOf', { allOf: [{ format: 'binary' }], type: 'string' }], + ['string anyOf branches, one of them binary', { anyOf: [{ format: 'byte', type: 'string' }, { type: 'string' }] }], + ['a reference to a string with contentMediaType', { $ref: '#/components/schemas/Png' }], + ])('sets contentType: text/plain on %s', (_name, part) => { + expect(convertForm({ schema: { properties: { part } } })).toEqual({ + encoding: { part: textPlain }, + schema: { properties: { part: expect.anything() } }, + }) + }) }) describe('parts whose 3.0 default already matches', () => { it.each([ ['a string', { format: 'uuid', type: 'string' }], + ['a string whose format wins over contentMediaType', { contentMediaType: 'text/csv', format: 'uuid', type: 'string' }], + ['a binary format on a non-string type', { format: 'binary', type: 'integer' }], ['an object', { type: 'object' }], ['a type found through allOf', { allOf: [{ $ref: '#/components/schemas/Pet' }] }], ['a null type', { type: 'null' }], @@ -136,6 +168,19 @@ describe('existing Encoding Objects', () => { }) }) + it('treats a text/plain default like an application/octet-stream one', () => { + const headers = { 'X-Id': { schema: { type: 'string' } } } + const binary = { format: 'binary', type: 'string' } + const schema = { properties: { explicit: binary, headed: binary, styled: binary } } + expect(convertForm({ + encoding: { explicit: { contentType: 'image/png' }, headed: { headers }, styled: { style: 'form' } }, + schema, + })).toEqual({ + encoding: { explicit: { contentType: 'image/png' }, headed: { ...textPlain, headers }, styled: { style: 'form' } }, + schema, + }) + }) + it('leaves an Encoding Object shared with another part unchanged', () => { const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } const result = dig(convertComponent('requestBodies', { From bd6bd2ead73cc6bb4d9ef2b85cfaaa97ec11b057 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 08:36:38 +0000 Subject: [PATCH 2/2] refactor(downgrader): share the content format rule between schemas and form parts `hasBinaryFormat` re-derived the `format` that `finishSchema` adds for `contentMediaType` and `contentEncoding`. Both now call `contentFormat`, so the form pass sees exactly the format the converted schema carries. Also drop a branch in `partContentType` that the recursion already covers: an array without items resolves to application/octet-stream. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018ZyitjJcKySVWCeAHFqvkr --- packages/downgrader/src/v3.1-to-v3.0.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index 6e0cd2f..22244da 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -277,6 +277,15 @@ function convertType(out: Record, type: unknown): boolean { return false } +function contentFormat(schema: Record): string | undefined { + if (schema.type !== undefined && !hasType(schema.type, 'string')) { + return undefined + } + return schema.contentEncoding === 'base64' + ? 'byte' + : schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string' ? 'binary' : undefined +} + function finishSchema(out: Record, schema: Record, ctx: Context): unknown { if (typeof schema.$ref === 'string') { out.allOf = [convertSchemaRef(schema.$ref, ctx), ...allOfItems(out.allOf)] @@ -308,10 +317,8 @@ function finishSchema(out: Record, schema: Record 0 && !('example' in schema)) { out.example = clone(schema.examples[0]) } - const format = schema.contentEncoding === 'base64' - ? 'byte' - : schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string' ? 'binary' : undefined - if (format !== undefined && (schema.type === undefined || hasType(schema.type, 'string'))) { + const format = contentFormat(schema) + if (format !== undefined) { out.format ??= format if (schema.type === undefined) { out.type = 'string' @@ -382,7 +389,7 @@ function formParts(schema: unknown, ctx: Context): Map { } function hasBinaryFormat(node: Record): boolean { - const format = node.format ?? (typeof node.contentMediaType === 'string' ? 'binary' : undefined) + const format = node.format ?? contentFormat(node) return format === 'binary' || format === 'byte' } @@ -410,7 +417,7 @@ function partContentType(schemas: readonly unknown[], ctx: Context, isItem = fal return undefined } const items = records.flatMap(node => [node.prefixItems ?? [], node.items ?? []].flat()) - return items.length === 0 ? 'application/octet-stream' : partContentType(items, ctx, true) + return partContentType(items, ctx, true) } function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown {