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
9 changes: 5 additions & 4 deletions packages/downgrader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,35 +58,36 @@ All types come from [`@openapi-spec/types`](https://github.com/middleapi/openapi

## 3.2 → 3.1

Schema Objects pass through unchanged. 3.2 keeps the 3.1 JSON Schema keyword set and only adds two fields to the OAS vocabulary, `discriminator.defaultMapping` and `xml.nodeType`, and both are kept. 3.1 tooling ignores them, so a `defaultMapping` fallback stops taking effect, while `nodeType` is picked up again on the 3.1 → 3.0 hop. The standard OpenAPI 3.1 document schema accepts them, but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects and will flag them.
Schema Objects pass through unchanged, apart from `$ref`s into removed parts of the document (see below). 3.2 keeps the 3.1 JSON Schema keyword set and only adds two fields to the OAS vocabulary, `discriminator.defaultMapping` and `xml.nodeType`, and both are kept. 3.1 tooling ignores them, so a `defaultMapping` fallback stops taking effect, while `nodeType` is picked up again on the 3.1 → 3.0 hop. The standard OpenAPI 3.1 document schema accepts them, but the strict OAS 3.1 base-vocabulary meta-schema closes the XML and Discriminator Objects and will flag them.

Converted:

| 3.2 construct | 3.1 result |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openapi: 3.2.x` | `openapi: 3.1.2` |
| `jsonSchemaDialect` naming a 3.2 OAS dialect | `https://spec.openapis.org/oas/3.1/dialect/base`; other dialects pass through |
| `components.mediaTypes` and content-map `$ref`s to it | references inlined and the component map removed. Entries whose target cannot be inlined (external, unknown, or cyclic) are removed, since 3.1 content maps cannot hold references. A parameter or header that loses its entire `content` that way is removed too, because 3.1 requires exactly one entry there |
| `components.mediaTypes` and content-map `$ref`s | references inlined and the component map removed. Entries whose target cannot be inlined (external, unknown, or cyclic) are removed, since 3.1 content maps cannot hold references. A parameter or header that loses its entire `content` that way is removed too, because 3.1 requires exactly one entry there |
| media type `itemSchema` without a sibling `schema` | `schema: { type: "array", items: … }`, the sequential media type data model |
| response `summary` without a `description` | promoted to `description`; `""` when neither exists, since 3.1 requires it |
| example `dataValue` / `serializedValue` without `value` or `externalValue` | promoted to `value`, `dataValue` taking precedence |
| parameter `style: "cookie"` | removed so the 3.1 default `form` applies |
| `$ref` into a removed part | the target inlined in converted form, following reference chains, e.g. for `#/components/mediaTypes/Pet/schema`, anything under a `query` operation, or an index into a parameter list that lost entries. Beside other schema keywords it joins `allOf`; a cycle is cut by removing the reference |

Removed, with no 3.1 equivalent:

- `$self`
- server `name`
- tag `summary`, `parent`, and `kind`
- the Path Item `query` operation and `additionalOperations`
- `in: "querystring"` parameters, in parameter lists and in `components.parameters`, together with references to removed component parameters and headers (chains of reference aliases included)
- `in: "querystring"` parameters, in parameter lists and in `components.parameters`, together with references that resolve to a removed parameter or header (chains of reference aliases included)
- `allowReserved` on non-query parameters
- media type `description`
- `prefixEncoding`, `itemEncoding`, and nested `encoding` on media types and encodings
- `itemSchema` beside an existing `schema`, and response `summary` beside an existing `description`
- OAuth `deviceAuthorization` flows
- security scheme `oauth2MetadataUrl` and `deprecated`

Known limitations: security requirements keyed by URI, `$self`-relative reference resolution, and a `$schema` keyword inside a Schema Object that names the 3.2 dialect all pass through unchanged.
Known limitations: security requirements keyed by URI, `$self`-relative reference resolution, Link `operationRef` and discriminator `mapping` values that point into removed parts, and a `$schema` keyword inside a Schema Object that names the 3.2 dialect all pass through unchanged.

## 3.1 → 3.0

Expand Down
84 changes: 84 additions & 0 deletions packages/downgrader/src/shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import {
convertRecord,
deepClone,
DROP,
getChild,
getRef,
HTTP_METHODS_UP_TO_V31,
isConverting,
isRecord,
mapArray,
mapRecord,
operationFields,
parseLocalRef,
resolveLocalRef,
setOwn,
} from './shared'

Expand Down Expand Up @@ -417,6 +421,86 @@ describe('getRef', () => {
})
})

describe('isConverting', () => {
it('reports only source records whose conversion is still in progress', () => {
const child = { a: 1 }
const source = { child }
const seen: boolean[] = []
convertRecord(source, {
child: (item) => {
seen.push(isConverting(source), isConverting(item))
return item
},
})
expect(seen).toEqual([true, false])
expect(isConverting(source)).toBe(false)
expect(isConverting('text')).toBe(false)
})
})

describe('parseLocalRef', () => {
it('splits a local JSON pointer into unescaped tokens', () => {
expect(parseLocalRef('#/components/schemas/Pet')).toEqual(['components', 'schemas', 'Pet'])
expect(parseLocalRef('#/paths/~1pets~1{id}/a~0b')).toEqual(['paths', '/pets/{id}', 'a~b'])
expect(parseLocalRef('#/~01')).toEqual(['~1'])
})

it('percent-decodes the fragment before splitting it', () => {
expect(parseLocalRef('#/paths/~1pets~1%7Bid%7D')).toEqual(['paths', '/pets/{id}'])
expect(parseLocalRef('#/a%2Fb')).toEqual(['a', 'b'])
})

it('returns no tokens for the whole-document pointer', () => {
expect(parseLocalRef('#')).toEqual([])
expect(parseLocalRef('#/')).toEqual([''])
})

it('returns undefined for external refs, anchors, and malformed percent-encoding', () => {
expect(parseLocalRef('other.json#/a')).toBeUndefined()
expect(parseLocalRef('#anchor')).toBeUndefined()
expect(parseLocalRef('#/%E0%A4%A')).toBeUndefined()
})
})

describe('getChild', () => {
it('reads own record keys, including __proto__', () => {
expect(getChild({ a: 1 }, 'a')).toBe(1)
expect(getChild(JSON.parse('{"__proto__": 2}'), '__proto__')).toBe(2)
})

it('reads canonical array indices only', () => {
const list = ['a', 'b']
expect(getChild(list, '1')).toBe('b')
expect(getChild(list, '2')).toBeUndefined()
expect(getChild(list, '01')).toBeUndefined()
expect(getChild(list, '-')).toBeUndefined()
expect(getChild(list, 'length')).toBeUndefined()
// eslint-disable-next-line no-sparse-arrays
expect(getChild([, 'b'], '0')).toBeUndefined()
})

it('does not read inherited members or step into primitives', () => {
expect(getChild({}, 'hasOwnProperty')).toBeUndefined()
expect(getChild('text', 'length')).toBeUndefined()
expect(getChild(null, 'a')).toBeUndefined()
})
})

describe('resolveLocalRef', () => {
const root = { a: [{ 'b/c': 1 }] }

it('resolves a local pointer against the root', () => {
expect(resolveLocalRef(root, '#/a/0/b~1c')).toBe(1)
expect(resolveLocalRef(root, '#')).toBe(root)
})

it('returns undefined for unresolvable or non-local pointers', () => {
expect(resolveLocalRef(root, '#/a/1')).toBeUndefined()
expect(resolveLocalRef(root, '#/x/y/z')).toBeUndefined()
expect(resolveLocalRef(root, 'other.json#/a')).toBeUndefined()
})
})

describe('setOwn', () => {
it('defines an enumerable, writable, configurable own property', () => {
const target: Record<string, unknown> = {}
Expand Down
38 changes: 38 additions & 0 deletions packages/downgrader/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ export function convertRecord(value: unknown, fields: FieldTable, finish?: Finis
}
}

export function isConverting(value: unknown): boolean {
return isRecord(value) && conversions.get(value)?.done === false
}

export function mapRecord(value: unknown, convert: (item: unknown, key: string) => unknown): unknown {
if (!isRecord(value)) {
return deepClone(value)
Expand Down Expand Up @@ -146,3 +150,37 @@ export function getRef(value: unknown): string | undefined {
}
return undefined
}

export function parseLocalRef(ref: string): string[] | undefined {
if (!ref.startsWith('#')) {
return undefined
}
let pointer = ref.slice(1)
if (pointer.includes('%')) {
try {
pointer = decodeURIComponent(pointer)
}
catch {
return undefined
}
}
if (pointer === '') {
return []
}
if (!pointer.startsWith('/')) {
return undefined
}
const tokens = pointer.slice(1).split('/')
return pointer.includes('~') ? tokens.map(token => token.replaceAll('~1', '/').replaceAll('~0', '~')) : tokens
}

export function getChild(value: unknown, token: string): unknown {
if (Array.isArray(value)) {
return /^(?:0|[1-9]\d*)$/.test(token) && Object.hasOwn(value, token) ? value[Number(token)] : undefined
}
return isRecord(value) && Object.hasOwn(value, token) ? value[token] : undefined
}

export function resolveLocalRef(root: unknown, ref: string): unknown {
return parseLocalRef(ref)?.reduce<unknown>((node, token) => getChild(node, token), root)
}
Loading
Loading