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
36 changes: 18 additions & 18 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,24 @@ Removed, with no 3.0 equivalent:

Schema Objects:

| 3.1 construct | 3.0 result |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `true` / `false` boolean schemas | `{}` / `{ not: {} }` |
| `$ref` with sibling keywords | siblings kept, `$ref` moved into `allOf` |
| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` |
| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed. A sibling `items` moves into the `array` variant |
| `type: "null"` | `enum: [null]`, since 3.0 ignores `nullable` without a `type`. A sibling `enum` or `const` is intersected with the null type: an `enum` containing `null` collapses to `[null]`, and one excluding it yields `not: {}`, since the source accepted no value |
| `const` | single-value `enum` |
| numeric `exclusiveMinimum` / `exclusiveMaximum` | `minimum` / `maximum` plus the boolean flag; a tighter existing bound wins |
| `examples` | first entry becomes `example` when none exists |
| `contentEncoding: base64` | `format: byte` when no `format` exists |
| `contentMediaType: application/octet-stream` without `contentEncoding` | `format: binary` when no `format` exists |
| `type: "array"` without `items` | `items: {}` added (required in 3.0) |
| `enum: []` | removed (3.0 requires a non-empty `enum`) |
| `required: []` / duplicate `required` entries | removed / deduplicated (3.0 requires a non-empty, unique `required`) |
| XML `nodeType`, carried over from a 3.2 chain | `attribute: true` / `wrapped: true` where expressible, then removed (3.0 forbids unknown XML Object fields) |

Removed, with no 3.0 equivalent: `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`, `$dynamicAnchor`, `$vocabulary`, `$comment`, `if` / `then` / `else`, `dependentSchemas`, `dependentRequired`, `prefixItems` (with its trailing `items`), `contains`, `minContains`, `maxContains`, `patternProperties` (with its sibling `additionalProperties`, whose meaning would otherwise tighten onto the pattern-matched keys), `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, and `contentSchema`. In positive schema positions dropping these only loosens validation, the safe direction for a downgrade.
| 3.1 construct | 3.0 result |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `true` / `false` boolean schemas | `{}` / `{ not: {} }` |
| `$ref` with sibling keywords | siblings kept, `$ref` moved into `allOf` |
| `type: ["T", "null"]` | `type: "T"` plus `nullable: true` |
| `type` with several non-null entries | `anyOf` of single-type schemas, each `nullable` when `null` was listed. A sibling `items` moves into the `array` variant |
| `type: "null"` | `enum: [null]`, since 3.0 ignores `nullable` without a `type`. A sibling `enum` or `const` is intersected with the null type: an `enum` containing `null` collapses to `[null]`, and one excluding it yields `not: {}`, since the source accepted no value |
| `const` | single-value `enum` |
| numeric `exclusiveMinimum` / `exclusiveMaximum` | `minimum` / `maximum` plus the boolean flag; a tighter existing bound wins |
| `examples` | first entry becomes `example` when none exists |
| `contentEncoding: base64` | `format: byte` unless `format` exists, plus `type: string` when `type` is missing. Skipped when `type` excludes `string` |
| `contentMediaType` without `contentEncoding` | as above, with `format: binary` |
| `type: "array"` without `items` | `items: {}` added (required in 3.0) |
| `enum: []` | removed (3.0 requires a non-empty `enum`) |
| `required: []` / duplicate `required` entries | removed / deduplicated (3.0 requires a non-empty, unique `required`) |
| XML `nodeType`, carried over from a 3.2 chain | `attribute: true` / `wrapped: true` where expressible, then removed (3.0 forbids unknown XML Object fields) |

Removed, with no 3.0 equivalent: `$schema`, `$id`, `$defs`, `$anchor`, `$dynamicRef`, `$dynamicAnchor`, `$vocabulary`, `$comment`, `if` / `then` / `else`, `dependentSchemas`, `dependentRequired`, `prefixItems` (with its trailing `items`), `contains`, `minContains`, `maxContains`, `patternProperties` (with its sibling `additionalProperties`, whose meaning would otherwise tighten onto the pattern-matched keys), `propertyNames`, `unevaluatedItems`, `unevaluatedProperties`, `contentSchema`, and a non-`base64` `contentEncoding` (`base64url` included) with its `contentMediaType`. In positive schema positions dropping these only loosens validation, the safe direction for a downgrade.

Known limitations:

Expand Down
43 changes: 27 additions & 16 deletions packages/downgrader/src/v3.1-to-v3.0.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1105,32 +1105,43 @@ describe('downgradeSchemaV31ToV30', () => {
describe('content keywords', () => {
it.each([
[
'converts contentEncoding base64 into format byte',
{ contentEncoding: 'base64' },
{ format: 'byte' },
'converts encoded binary into type string with format byte',
{ contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' },
{ format: 'byte', type: 'string' },
],
[
'converts raw binary into type string with format binary',
{ contentMediaType: 'image/png' },
{ format: 'binary', type: 'string' },
],
[
'keeps nullable on binary strings',
{ contentMediaType: 'image/png', type: ['string', 'null'] },
{ format: 'binary', nullable: true, type: 'string' },
],
[
'keeps format beside a multi-type anyOf that includes string',
{ contentMediaType: 'image/png', type: ['string', 'integer'] },
{ anyOf: [{ type: 'string' }, { type: 'integer' }], format: 'binary' },
],
[
'keeps an existing format over contentEncoding',
{ contentEncoding: 'base64', format: 'custom' },
{ format: 'custom' },
{ format: 'custom', type: 'string' },
],
['drops other content encodings', { contentEncoding: 'gzip' }, {}],
[
'converts contentMediaType application/octet-stream into format binary',
{ contentMediaType: 'application/octet-stream' },
{ format: 'binary' },
'drops content keywords on non-string types',
{ contentMediaType: 'image/png', type: 'object' },
{ type: 'object' },
],
[
'does not emit format binary when a contentEncoding is present',
{
contentEncoding: 'gzip',
contentMediaType: 'application/octet-stream',
},
{},
'drops base64url, which format byte does not accept',
{ contentEncoding: 'base64url', contentMediaType: 'image/png', type: 'string' },
{ type: 'string' },
],
[
'drops other content media types',
{ contentMediaType: 'image/png' },
'drops non-string content media types',
{ contentMediaType: 42 },
{},
],
['drops contentSchema', { contentSchema: { type: 'string' } }, {}],
Expand Down
28 changes: 22 additions & 6 deletions packages/downgrader/src/v3.1-to-v3.0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ function applyTypes(types: string[], schema: Record<string, unknown>, out: Recor
}
}

function hasType(type: unknown, name: string): boolean {
return type === name || (Array.isArray(type) && type.includes(name))
}

function convertType(schema: Record<string, unknown>, out: Record<string, unknown>): void {
const { type } = schema
if (type === undefined) {
Expand Down Expand Up @@ -123,15 +127,27 @@ function convertExclusiveBounds(schema: Record<string, unknown>, out: Record<str
}
}

function getContentFormat(schema: Record<string, unknown>): string | undefined {
if (schema.contentEncoding === 'base64') {
return 'byte'
}
if (schema.contentEncoding === undefined && typeof schema.contentMediaType === 'string') {
return 'binary'
}
return undefined
}

function convertContentKeywords(schema: Record<string, unknown>, out: Record<string, unknown>): void {
if (out.format !== undefined) {
const format = getContentFormat(schema)
const { type } = schema
if (format === undefined || (type !== undefined && !hasType(type, 'string'))) {
return
}
if (schema.contentEncoding === 'base64') {
out.format = 'byte'
if (type === undefined) {
out.type = 'string'
Comment thread
pullfrog[bot] marked this conversation as resolved.
}
else if (schema.contentEncoding === undefined && schema.contentMediaType === 'application/octet-stream') {
out.format = 'binary'
if (out.format === undefined) {
out.format = format
}
}

Expand All @@ -140,7 +156,7 @@ function convertXml(value: unknown, schemaType: unknown): unknown {
if (xml.nodeType === 'attribute') {
out.attribute = true
}
else if (xml.nodeType === 'element' && (schemaType === 'array' || (Array.isArray(schemaType) && schemaType.includes('array')))) {
else if (xml.nodeType === 'element' && hasType(schemaType, 'array')) {
out.wrapped = true
}
return out
Expand Down
30 changes: 30 additions & 0 deletions packages/downgrader/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,36 @@ describe('3.1 example documents downgraded to 3.0', () => {
await expectValidAs(converted, '3.0')
expect(doc).toEqual(before)
})

it('converts raw and encoded binary schemas to the 3.0 binary and byte formats', async () => {
const doc: OpenAPIV3_1.OpenAPIObject = {
info: { title: 'Uploads', version: '1.0.0' },
openapi: '3.1.0',
paths: {
'/avatar': {
put: {
requestBody: {
content: {
'image/png': { schema: { contentMediaType: 'image/png' } },
'text/plain': { schema: { contentEncoding: 'base64', contentMediaType: 'image/png', type: 'string' } },
},
},
responses: { 204: { description: 'saved' } },
},
},
},
}
const before = structuredClone(doc)
const converted = downgradeSpecV31ToV30(doc)
expect(converted.paths['/avatar']?.put?.requestBody).toEqual({
content: {
'image/png': { schema: { format: 'binary', type: 'string' } },
'text/plain': { schema: { format: 'byte', type: 'string' } },
},
})
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