Skip to content
Draft
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
93 changes: 73 additions & 20 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,17 @@ export async function dereference(
const externalDocuments = new Set<JSONSchema>()
const nonSchemaTargets: NonSchemaTarget[] = []
const onDereference = ($ref: string, schema: JSONSchema, holder?: object, key?: string) => {
// The target of a $ref need not be an object: it can be a boolean schema (`true`/`false`),
// or -- for a pointer into a keyword's value -- any JSON value. Only objects can be WeakMap
// keys, and only objects are named after the path they were referenced by.
// The target of a $ref need not be an object schema: it can be a boolean (`true`/`false`),
// or -- for a pointer into a keyword's value, or a file holding one -- any JSON value, an
// array included. Only objects can be WeakMap keys, and only objects are named after the
// path they were referenced by.
if (schema !== null && typeof schema === 'object') {
dereferencedPaths.set(schema, $ref)
if (isWholeDocumentRef($ref)) {
externalDocuments.add(schema)
}
} else if (typeof schema !== 'boolean' && holder && key !== undefined) {
}
if (!isSchema(schema) && holder && key !== undefined) {
nonSchemaTargets.push({$ref, holder, key, value: schema}) // fine or not depending on where it sits: see below
}
}
Expand All @@ -80,6 +82,9 @@ export async function dereference(
let dereferencedSchema = schema
if (targets) {
dereferenceInDocument(schema, targets, onDereference)
if (nonSchemaTargets.length) {
rejectNonSchemaTargets(schema, schema, nonSchemaTargets, () => ({})) // (pointers within the document: no file to name)
}
} else {
const parser = new $RefParser()
const documents = new IdScopeMask()
Expand All @@ -103,6 +108,13 @@ export async function dereference(
} finally {
documents.restore()
}
// The root's own `$ref` is reported to no hook, and with a keyword beside it (a `title`; the
// `definitions` a pointer into this document leads through) the ref-parser merges its target
// into the root -- an array's members as keys `0`, `1`, ... -- so that one is looked up first
const rootTarget = typeof schema.$ref === 'string' ? rootRefTarget(parser, schema.$ref, options) : undefined
if (Array.isArray(rootTarget)) {
rejectNonSchemaTargets(schema, rootTarget, [], () => parser.$refs.values() as object)
}
dereferenceInternal(parser, options)
if (JSONParserErrorGroup.getParserErrors(parser).length) {
throw new JSONParserErrorGroup(parser) // what `continueOnError: true` collected instead of throwing
Expand Down Expand Up @@ -180,21 +192,42 @@ function isWholeDocumentRef($ref: string): boolean {

/*
* A `$ref` that stands where a schema is expected has to lead to one: an object or a boolean. One
* that leads to an empty document, `null`, a string or a number instead -- a schema file saved
* empty, a `.yaml` file that is really prose, a pointer onto a keyword's value -- would otherwise
* surface as a crash somewhere in the parser, or as that value printed as a literal type. A `$ref`
* anywhere else (`description: {$ref: "intro.md"}`, or under some key of the user's own) may lead
* to anything, so the few suspects `onDereference` collects are only judged once dereferencing is
* done, by where they ended up: in a subschema position -- under a keyword that holds subschemas,
* of a schema that is itself in one -- or standing in for such a keyword's whole map or list of
* them. None of this runs for a document whose `$ref`s all lead to objects and booleans.
* that leads to an empty document, `null`, a string, a number or an array instead -- a schema file
* saved empty, a `.yaml` file that is really prose, a pointer onto a keyword's value, an `enum` or
* `required` list among them -- would otherwise surface as a crash somewhere in the parser, or as
* that value printed as a literal type (a list as a tuple of its members). A `$ref` anywhere else
* (`description: {$ref: "intro.md"}`, `enum: {$ref: "#/definitions/x/enum"}`, or under some key of
* the user's own) may lead to anything, so the few suspects `onDereference` collects are only
* judged once dereferencing is done, by where they ended up: in a subschema position -- under a
* keyword that holds subschemas, of a schema that is itself in one -- or standing in for such a
* keyword's whole map or list of them, where a list of schemas is what an array may be. None of
* this runs for a document whose `$ref`s all lead to objects and booleans.
*/

/** A `$ref` that resolved to `undefined` (an empty document), `null`, a string or a number */
/** A `$ref` that resolved to `undefined` (an empty document), `null`, a string, a number or an array */
type NonSchemaTarget = {$ref: string; holder: object; key: string; value: unknown}

/** An object or a boolean: what a `$ref` in place of a schema must lead to (an array is a list, of values or of schemas) */
function isSchema(value: unknown): boolean {
return typeof value === 'boolean' || (typeof value === 'object' && value !== null)
return typeof value === 'boolean' || (typeof value === 'object' && value !== null && !Array.isArray(value))
}

function isSchemaList(value: unknown): boolean {
return Array.isArray(value) && value.every(isSchema)
}

/**
* What the root's own `$ref` leads to, once every document is read, as far as the ref-parser can
* tell without dereferencing: undefined when it cannot (a pointer it finds missing, which its own
* error reports once it dereferences -- so this lookup's failure is not to be collected as one
* more error under `continueOnError`).
*/
function rootRefTarget(parser: $RefParser, $ref: string, options: $RefOptions): unknown {
try {
return parser.$refs.get($ref, {...options, continueOnError: false})
} catch {
return undefined
}
}

/**
Expand Down Expand Up @@ -244,19 +277,36 @@ function misplacedTargets(root: unknown, suspects: NonSchemaTarget[]): Misplaced
continue
}
const held = node[keyword]
/** The suspect whose `$ref` stood in for the keyword's whole value, if that is what it did */
const whole = byHolder.get(node)?.filter(_ => _.key === keyword) ?? []
if (
holds === 'schemaMap' ||
holds === 'schemaArray' ||
(holds === 'schemaOrSchemaArray' && Array.isArray(held))
) {
if (isObjectLike(held)) {
byHolder.get(held as object)?.forEach(_ => misplaced.push({where: `${keyword}/${_.key}`, ..._}))
// A map or list of schemas, judged entry by entry. A `$ref` that stood in for the whole
// of it led to an array (the one object that is no schema): fine as the list of schemas
// an `items` or `extends` may hold, otherwise as misplaced as at an entry (a `$ref` in
// place of an `allOf` list never gets this far: the prenormalizer's walk trips on it).
// An array at an entry of `dependencies` is that name's list of the properties it
// requires, not a schema.
whole.forEach(
_ => (holds !== 'schemaOrSchemaArray' || !isSchemaList(_.value)) && misplaced.push({where: keyword, ..._}),
)
byHolder
.get(held as object)
?.forEach(
_ =>
(keyword !== 'dependencies' || !Array.isArray(_.value)) &&
misplaced.push({where: `${keyword}/${_.key}`, ..._}),
)
Object.values(held as object).forEach(visit)
continue
}
// else a `$ref` may have stood in for the whole map or list: judged like a single schema
}
byHolder.get(node)?.forEach(_ => _.key === keyword && misplaced.push({where: keyword, ..._}))
whole.forEach(_ => misplaced.push({where: keyword, ..._}))
visit(held)
}
}
Expand All @@ -271,7 +321,9 @@ function describeNonSchemaTarget({where, $ref, value}: Misplaced, files: Record<
? `the string ${JSON.stringify(value.length > 40 ? `${value.slice(0, 39)}…` : value)}`
: typeof value === 'number'
? `the number ${value}`
: String(value) // null; or undefined, which no pointer reaches: then it is a whole, empty file
: Array.isArray(value)
? `an array of ${value.length} item${value.length === 1 ? '' : 's'}`
: String(value) // null; or undefined, which no pointer reaches: then it is a whole, empty file
const file = fileBehind($ref, value, files)
const target =
file !== undefined && isWholeDocumentRef($ref)
Expand Down Expand Up @@ -513,12 +565,13 @@ function pointerTarget(root: JSONSchema, pointer: string): object | undefined {
* dereference step would: a `$ref` with sibling keywords becomes a new object, the siblings laid over the
* target; each replacement is reported to `onDereference`. The bookkeeping is $RefParser's too -- which
* objects are done, which are on the path from the root, a cache per pointer that is bypassed while its
* target is on that path -- because it decides which objects get shared and which copied.
* target is on that path -- because it decides which objects get shared and which copied. The report
* says where the replacement sits, as $RefParser's does.
*/
export function dereferenceInDocument(
root: JSONSchema,
targets: Map<string, object>,
onDereference: ($ref: string, schema: JSONSchema) => void,
onDereference: ($ref: string, schema: JSONSchema, holder: object, key: string) => void,
): void {
type Resolution = {value: object; circular: boolean}
const visited = new Set<unknown>()
Expand All @@ -542,7 +595,7 @@ export function dereferenceInDocument(
if (isRef(value)) {
const resolution = resolve(value)
node[key] = resolution.value
onDereference(value.$ref, resolution.value)
onDereference(value.$ref, resolution.value, node, key)
circular = resolution.circular || circular
} else {
circular = parents.has(value) || crawl(value) || circular
Expand Down
15 changes: 15 additions & 0 deletions test/e2e/refToNonSchemaDocument.arrayDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* refToNonSchemaDocument.emptyFile.ts, for a file whose whole content is the JSON array
* `[1, 2]`: an error naming the `$ref` and the file, not a property of type `[1, 2]`.
*/
export const input = {
title: 'RefToArrayDocument',
type: 'object',
properties: {
y: {$ref: 'test/resources/NotASchema/array.json'},
},
additionalProperties: false,
}

export const error =
/\$ref "test\/resources\/NotASchema\/array\.json" at properties\/y resolves to .*NotASchema[\\/]array\.json, which is not a JSON Schema: it parses to an array of 2 items/
12 changes: 12 additions & 0 deletions test/e2e/refToNonSchemaDocument.arrayRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* refToNonSchemaDocument.arrayDocument.ts, with the `$ref` at the root and a `title` beside it:
* the ref parser merges the array's members into the root as keys `0` and `1`, and the whole
* schema used to compile to `unknown` without complaint.
*/
export const input = {
title: 'RefToArrayDocumentRoot',
$ref: 'test/resources/NotASchema/array.json',
}

export const error =
/\$ref "test\/resources\/NotASchema\/array\.json" at the root resolves to .*NotASchema[\\/]array\.json, which is not a JSON Schema: it parses to an array of 2 items/
19 changes: 19 additions & 0 deletions test/e2e/refToNonSchemaValue.array.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* refToNonSchemaValue.ts, for a pointer that lands on a keyword's list -- an `enum`, a
* `required` -- rather than on a schema: an array is no more a schema than a string is. (It used
* to come out as a tuple of the list's members, `y?: ["metres", "feet"]`.)
*/
export const input = {
title: 'RefToNonSchemaArray',
type: 'object',
definitions: {
unit: {type: 'string', enum: ['metres', 'feet']},
},
properties: {
y: {$ref: '#/definitions/unit/enum'},
},
additionalProperties: false,
}

export const error =
/\$ref "#\/definitions\/unit\/enum" at properties\/y resolves to an array of 2 items, which is not a JSON Schema/
15 changes: 15 additions & 0 deletions test/e2e/refToNonSchemaValue.arrayInFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* refToNonSchemaValue.array.ts, with the pointer leading into another file: the error names the
* file the list was found in. (It used to come out as `y?: ["firstName", "lastName"]`.)
*/
export const input = {
title: 'RefToNonSchemaArrayInFile',
type: 'object',
properties: {
y: {$ref: 'test/resources/ReferencedType.json#/required'},
},
additionalProperties: false,
}

export const error =
/\$ref "test\/resources\/ReferencedType\.json#\/required" at properties\/y resolves to an array of 2 items in .*ReferencedType\.json, which is not a JSON Schema/
14 changes: 14 additions & 0 deletions test/e2e/refToNonSchemaValue.arrayRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* refToNonSchemaValue.array.ts, with the pointer at the root: the ref parser would merge the
* list's members into the root as keys `0` and `1`, and the whole schema used to compile to
* `unknown` without complaint.
*/
export const input = {
$ref: '#/definitions/unit/enum',
definitions: {
unit: {type: 'string', enum: ['metres', 'feet']},
},
}

export const error =
/\$ref "#\/definitions\/unit\/enum" at the root resolves to an array of 2 items, which is not a JSON Schema/
47 changes: 47 additions & 0 deletions test/refTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,50 @@ suite('$ref targets that are not object schemas', () => {
expect(compiled).toContain(' y?: string;\n')
})
})

suite('$ref targets that are arrays', () => {
test('a list of schemas where `items` may hold one, a list of names in `dependencies`', async () => {
const schema: JSONSchema = {
title: 'Lists',
type: 'object',
definitions: {pair: {items: [{type: 'string'}, {type: 'number'}]}},
properties: {
pair: {type: 'array', items: {$ref: '#/definitions/pair/items'}},
a: {type: 'string'},
b: {type: 'string'},
},
dependencies: {b: {$ref: '#/required'} as unknown as string[]},
required: ['a'],
additionalProperties: false,
}
expect(await compile(schema, 'Lists', options)).toBe(
'export interface Lists {\n pair?: [] | [string] | [string, number, ...unknown[]];\n a: string;\n b?: string;\n}\n',
)
})

test('looking the root `$ref` up leaves `continueOnError` nothing to collect', async () => {
// A named anchor the ref-parser cannot resolve (it is resolved after dereferencing), at the root
const schema: JSONSchema = {$ref: '#unit', definitions: {unit: {$id: '#unit', type: 'string'}}}
expect(await compile(schema, 'Unit', {...options, $refOptions: {continueOnError: true}})).toBe(
'export type Unit = string;\n',
)
})

test('outside schema positions: an `enum` or `examples` list kept under a definition', async () => {
const list: unknown = {$ref: '#/definitions/unit/enum'} // (typed as the list it becomes)
const schema: JSONSchema = {
title: 'Units',
type: 'object',
definitions: {unit: {type: 'string', enum: ['metres', 'feet'], examples: ['metres']}},
properties: {
y: {
type: 'string',
enum: list as string[],
examples: {$ref: '#/definitions/unit/examples'} as unknown as string[],
},
},
additionalProperties: false,
}
expect(await compile(schema, 'Units', options)).toBe('export interface Units {\n y?: "metres" | "feet";\n}\n')
})
})
1 change: 1 addition & 0 deletions test/resources/NotASchema/array.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[1, 2]
Loading