Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 32 additions & 17 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,15 @@ function convertType(out: Record<string, unknown>, type: unknown): boolean {
return false
}

function contentFormat(schema: Record<string, unknown>): 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<string, unknown>, schema: Record<string, unknown>, ctx: Context): unknown {
if (typeof schema.$ref === 'string') {
out.allOf = [convertSchemaRef(schema.$ref, ctx), ...allOfItems(out.allOf)]
Expand Down Expand Up @@ -328,10 +337,8 @@ function finishSchema(out: Record<string, unknown>, schema: Record<string, unkno
if (Array.isArray(schema.examples) && schema.examples.length > 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'
Expand Down Expand Up @@ -421,34 +428,42 @@ function formParts(schema: unknown, ctx: Context): Map<string, Map<unknown, stri
return parts
}

function defaultsToOctetStream(schemas: Map<unknown, string>, ctx: Context, isItem = false): boolean {
function hasBinaryFormat(node: Record<string, unknown>): boolean {
const format = node.format ?? contentFormat(node)
return format === 'binary' || format === 'byte'
}

function partContentType(schemas: Map<unknown, string>, 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<unknown, string>()
for (const [node, base] of bases) {
for (const item of [child(node, 'prefixItems') ?? [], child(node, 'items') ?? []].flat()) {
place(items, item, base, ctx)
}
}
return items.size === 0 || defaultsToOctetStream(items, ctx, true)
return partContentType(items, ctx, true)
}

function finishFormMediaType(out: Record<string, unknown>, mediaType: Record<string, unknown>, ctx: Context): unknown {
Expand All @@ -458,12 +473,12 @@ function finishFormMediaType(out: Record<string, unknown>, mediaType: Record<str
}
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))
&& 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
}
}
Expand Down
57 changes: 51 additions & 6 deletions packages/downgrader/tests/v3.1-to-v3.0/spec/form-bodies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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)
Expand Down Expand Up @@ -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' }],
Expand Down Expand Up @@ -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', {
Expand Down
Loading