Skip to content
Merged
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
15 changes: 8 additions & 7 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 its Encoding Object sets a content type or RFC6570-style fields |

Removed, with no 3.0 equivalent:

Expand Down
4 changes: 4 additions & 0 deletions packages/downgrader/src/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
})
Expand Down
4 changes: 2 additions & 2 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}
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)
}
Expand Down
148 changes: 148 additions & 0 deletions packages/downgrader/src/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,154 @@ 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],
['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' }],
])('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('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' }],
['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<string, unknown> = { 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({
Expand Down
85 changes: 84 additions & 1 deletion packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object>()

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)
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -343,6 +350,82 @@ function convertMediaType(value: unknown, ctx: Context): unknown {
return convertObject(value, ctx, MEDIA_TYPE_FIELDS)
}

function subschemas(schemas: readonly unknown[], ctx: Context): Set<unknown> {
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<string, unknown[]> {
const parts = new Map<string, unknown[]>()
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 finishFormMediaType(out: Record<string, unknown>, mediaType: Record<string, unknown>, ctx: Context): unknown {
const encoding = out.encoding ?? {}
if (!isRecord(encoding)) {
return out
}
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' })
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)
}
Expand Down
44 changes: 44 additions & 0 deletions packages/downgrader/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading