diff --git a/packages/downgrader/src/v3.1-to-v3.0.ts b/packages/downgrader/src/v3.1-to-v3.0.ts index c39b4b4..34cf95f 100644 --- a/packages/downgrader/src/v3.1-to-v3.0.ts +++ b/packages/downgrader/src/v3.1-to-v3.0.ts @@ -297,6 +297,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)] @@ -328,10 +337,8 @@ function finishSchema(out: Record, schema: Record 0 && !has(schema, 'example')) { 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' @@ -421,26 +428,34 @@ function formParts(schema: unknown, ctx: Context): Map, ctx: Context, isItem = false): boolean { +function hasBinaryFormat(node: Record): boolean { + const format = node.format ?? contentFormat(node) + return format === 'binary' || format === 'byte' +} + +function partContentType(schemas: Map, ctx: Context, isItem = false): string | undefined { const bases = addSubschemas(schemas, ctx) const nodes = [...bases.keys()] 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 false + return undefined } const items = new Map() for (const [node, base] of bases) { @@ -448,7 +463,7 @@ function defaultsToOctetStream(schemas: Map, ctx: Context, isIt place(items, item, base, ctx) } } - return items.size === 0 || defaultsToOctetStream(items, ctx, true) + return partContentType(items, ctx, true) } function finishFormMediaType(out: Record, mediaType: Record, ctx: Context): unknown { @@ -458,12 +473,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 bd6e26b..f0decce 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` @@ -34,7 +41,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) @@ -117,11 +131,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' }], @@ -170,6 +202,19 @@ describe('existing Encoding Objects', () => { expect(convertForm({ encoding, schema }, 'application/x-www-form-urlencoded')).toEqual({ encoding, schema }) }) + 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: textPlain }, + schema, + }) + }) + it('leaves an Encoding Object shared with another part unchanged', () => { const entry = { headers: { 'X-Id': { schema: { type: 'string' } } } } const result = dig(convertComponent('requestBodies', {