diff --git a/packages/downgrader/README.md b/packages/downgrader/README.md index 3d499d1..1141ff7 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 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..1111460 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,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 = { 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..a8105f9 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,82 @@ 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 finishFormMediaType(out: Record, mediaType: Record, 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) } 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', () => {