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
3 changes: 3 additions & 0 deletions src/keywords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,9 @@ export const CONTAINER_KEYWORDS = keywordsWhere(
({holds}, name) => holds === 'schemaArray' || holds === 'schemaMap' || LEGACY.containers.has(name),
)

/** Keywords that hold a list of schemas (`allOf`, `anyOf`, `oneOf`, `prefixItems`), and nothing else */
export const SCHEMA_LIST_KEYWORDS = keywordsWhere(({holds}) => holds === 'schemaArray')

/**
* Keywords that hold instance data, never a schema: nothing underneath them is a `$ref`, an
* `$id` or a subschema, whatever it looks like.
Expand Down
32 changes: 30 additions & 2 deletions src/prenormalizer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import {isEmpty, isPlainObject} from 'lodash'
import {JSONSchema, LinkedJSONSchema} from './types/JSONSchema'
import {JSONSchema, LinkedJSONSchema, ListsToJudge} from './types/JSONSchema'
import {eachSchemaNode, hasType, traverse} from './utils'
import {EXTENDING_KEYWORDS, META_KEYWORDS, TYPE_RELEVANT_KEYWORDS, VALIDATION_KEYWORDS} from './keywords'
import {
EXTENDING_KEYWORDS,
META_KEYWORDS,
SCHEMA_LIST_KEYWORDS,
TYPE_RELEVANT_KEYWORDS,
VALIDATION_KEYWORDS,
} from './keywords'

/**
* Rewrites that have to happen on the raw document, before it goes to the ref-parser --
Expand Down Expand Up @@ -184,6 +190,28 @@ rules.set('Drop `unevaluatedProperties` next to a `$ref`', schema => {
}
})

/**
* A `$ref` may stand in for a whole `allOf`, `anyOf`, `oneOf` or `prefixItems` list
* (`allOf: {$ref: "mixins.json"}`), and a mistake may put `true`, `null` or a string there.
* Only once dereferencing is done can what it leads to be judged (the resolver's
* `rejectNonSchemaLists`); the document is marked so that the walk that judges it runs for the
* documents that need it and no other. A schema object without a `$ref` is not marked: it is
* what a property named `allOf` looks like from the `properties` map holding it (the second walk
* visits such maps as nodes, and real schemas have such properties), and where it really sits in
* a list's place the parser reports it, as before.
*/
rules.set('Mark a document in which a `$ref` or a non-schema stands in for a list of schemas', (schema, document) => {
// (property reads, nothing more: this runs on every node of every document)
for (const keyword of LIST_KEYWORDS) {
const held = schema[keyword]
if (held !== undefined && !Array.isArray(held) && (!isPlainObject(held) || typeof held.$ref === 'string')) {
Object.defineProperty(document, ListsToJudge, {value: true, configurable: true})
return
}
}
})
const LIST_KEYWORDS = [...SCHEMA_LIST_KEYWORDS]

/**
* $RefParser can't correctly dereference a schema whose root is itself a
* `$ref`: it leaves `$ref: "#"` behind on the root instead of resolving it,
Expand Down
138 changes: 136 additions & 2 deletions src/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import {isObjectLike, isPlainObject} from 'lodash'
import {prenormalizeDocument} from './prenormalizer'
import {SCHEMA_HOLDING_KEYWORDS} from './keywords'
import {DefinitionKey, JSONSchema, SchemaSource, Source} from './types/JSONSchema'
import {DefinitionKey, JSONSchema, ListsToJudge, SchemaSource, Source} from './types/JSONSchema'
import {eachSchemaNode, log} from './utils'

export type DereferencedPaths = WeakMap<JSONSchema, string>
Expand Down Expand Up @@ -59,6 +59,7 @@ export async function dereference(
}
}
let prepare: Prepare = prenormalizeDocument
let judgeLists = hasListsToJudge(schema)
let resolve = $refOptions.resolve
if (set) {
stampSource(schema, fileKey(set.file))
Expand Down Expand Up @@ -90,7 +91,11 @@ export async function dereference(
...$refOptions,
mutateInputSchema: true, // `schema` is this module's own copy already
resolve,
parse: prenormalizingParsers($refOptions.parse, (document, file) => documents.hide(prepare(document, file))),
parse: prenormalizingParsers($refOptions.parse, (document, file) => {
const prepared = prepare(document, file)
judgeLists = judgeLists || hasListsToJudge(prepared)
return documents.hide(prepared)
}),
dereference: {...$refOptions.dereference, onDereference},
},
])
Expand All @@ -117,6 +122,9 @@ export async function dereference(
}
tagExternalDefinitions(externalDocuments, dereferencedSchema)
}
if (judgeLists) {
rejectNonSchemaLists(dereferencedSchema, dereferencedPaths)
}
return {dereferencedPaths, dereferencedSchema: resolveNamedAnchors(dereferencedSchema)}
}

Expand Down Expand Up @@ -304,6 +312,132 @@ function fileBehind($ref: string, value: unknown, files: Record<string, unknown>
return candidates.length === 1 ? candidates[0] : undefined
}

/*
* `allOf`, `anyOf`, `oneOf` and `prefixItems` hold a list of schemas, and a `$ref` may stand in for
* the whole list (`allOf: {$ref: "mixins.json"}`, `{$ref: "#/definitions/base/allOf"}`): the walks
* before dereferencing let it through, and what it led to is judged here, once dereferencing is
* done -- for the documents the prenormalizer marked as holding such a value (`ListsToJudge`),
* so that the rest pay nothing for it. An array of schemas is the list. Anything else -- a single schema, a boolean, an `enum`'s
* values -- would crash the parser or print as a type, as would a value that is no array written
* there directly; each is reported by position, with the `$ref` when one led there. (A `$ref`
* that led to no schema at all, a string or `null`, is reported by `rejectNonSchemaTargets`
* before this runs; a `$ref` at a member of a list written in place is judged where it sits.)
*/
function rejectNonSchemaLists(root: JSONSchema, dereferencedPaths: DereferencedPaths): void {
const visited = new Set<object>()
const trail: string[] = [] // the keys from the root to the node being visited
function visit(schema: unknown): void {
if (!isPlainObject(schema) || visited.has(schema as object)) {
return
}
const node = schema as Record<string, unknown>
visited.add(node)
for (const keyword of Object.keys(node)) {
const holds = SCHEMA_HOLDS.get(keyword)
if (holds === undefined) {
continue
}
const held = node[keyword]
if (held === undefined) {
continue // as good as absent
}
if (holds === 'schemaArray') {
// A list written in place is judged member by member downstream: only its shape is checked here
const $ref = isObjectLike(held) ? dereferencedPaths.get(held as JSONSchema) : undefined
if (!Array.isArray(held) || ($ref !== undefined && !isListOfSchemas(held))) {
const where = `${keyword} at ${trail.length ? trail.join('/') : 'the root'} must be a list of schemas`
const wrapped = isPlainObject(held) ? standInWithSiblings(held as JSONSchema, dereferencedPaths) : undefined
throw new ReferenceError(
wrapped
? `${where}: $ref "${wrapped.$ref}" leads to one, but the keywords beside it${wrapped.beside} make an object of it ` +
'(a $ref standing in for a whole list stands alone).'
: `${where}: ${$ref === undefined ? 'found' : `$ref "${$ref}" leads to`} ${describeNonSchemaList(held)}.`,
)
}
}
trail.push(keyword)
if (holds === 'schemaMap' || Array.isArray(held)) {
if (isObjectLike(held)) {
for (const key of Object.keys(held as object)) {
trail.push(key)
visit((held as Record<string, unknown>)[key])
trail.pop()
}
}
} else {
visit(held)
}
trail.pop()
}
}
visit(root)
}

/** What each schema-holding keyword holds, by name */
const SCHEMA_HOLDS = new Map<string, string>(SCHEMA_HOLDING_KEYWORDS)

/**
* Whether the prenormalizer found a list of schemas that is not a list in `document` (see
* `ListsToJudge`). An array document -- a file that is a list of schemas -- gets no prenormalizing,
* so what its members hold is judged whole.
*/
function hasListsToJudge(document: unknown): boolean {
return (
Array.isArray(document) || (isPlainObject(document) && (document as Record<symbol, unknown>)[ListsToJudge] === true)
)
}

/** An object or a boolean: what a member of a list of schemas is */
function isSchemaItem(value: unknown): boolean {
return typeof value === 'boolean' || (isObjectLike(value) && !Array.isArray(value))
}

/** An array whose every item is a schema */
function isListOfSchemas(value: unknown): boolean {
return Array.isArray(value) && value.every(isSchemaItem)
}

/**
* The `$ref` that stood in for a list, and what stood beside it, when a keyword next to it made
* an object of the stand-in: the prenormalizer moves such a `$ref` into a one-member `allOf`
* (annotations stay beside it; assertions join it as a second member), so after dereferencing
* the list is that member.
*/
function standInWithSiblings(
held: JSONSchema,
dereferencedPaths: DereferencedPaths,
): {$ref: string; beside: string} | undefined {
const list = Array.isArray(held.allOf) && Array.isArray(held.allOf[0]) ? held.allOf[0] : undefined
const $ref = list && dereferencedPaths.get(list)
if ($ref === undefined) {
return undefined
}
const beside = Object.keys(held).filter(key => key !== 'allOf')
return {$ref, beside: beside.length ? ` (${beside.join(', ')})` : ''}
}

/** `a list whose item 1 is the number 2`, `an object (one schema, not a list of them)`, `true` */
function describeNonSchemaList(value: unknown): string {
if (Array.isArray(value)) {
const i = value.findIndex(_ => !isSchemaItem(_))
return `a list whose item ${i} is ${describeValue(value[i])}`
}
return isObjectLike(value) ? 'an object (one schema, not a list of them)' : describeValue(value)
}

function describeValue(value: unknown): string {
switch (typeof value) {
case 'string':
return `the string ${JSON.stringify(value.length > 40 ? `${value.slice(0, 39)}…` : value)}`
case 'number':
return `the number ${value}`
case 'undefined':
return 'nothing'
default:
return Array.isArray(value) ? 'a list' : String(value) // a boolean, or null
}
}

/**
* A schema brought in by a $ref to a separate document keeps its own `definitions`/`$defs`,
* but once dereferenced into the referencing document that map sits somewhere below the
Expand Down
8 changes: 8 additions & 0 deletions src/types/JSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ export interface JSONSchema extends JSONSchema4 {

export const Parent = Symbol('Parent')
export const Shared = Symbol('Shared')
/**
* Set on a document (the schema being compiled, or a file loaded for it) in which a keyword
* that holds a list of schemas (`allOf`, `anyOf`, `oneOf`, `prefixItems`) holds a `$ref`
* standing in for the whole list, or no schema at all (`true`, `null`, a string), as the
* prenormalizer found it: what such a value leads to is judged by the resolver, once
* dereferencing is done, in a walk that only marked documents pay for.
*/
export const ListsToJudge = Symbol('ListsToJudge')
/**
* Where a schema node was read from: the file (as the resolver addressed it) and the
* JSON Pointer inside that file. Only stamped when compiling a set of files together
Expand Down
7 changes: 6 additions & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@ export function traverse(
}
break
case 'schemaArray':
traverseArray(child, callback, processed)
if (Array.isArray(child)) {
traverseArray(child, callback, processed)
} else if (typeof child === 'object') {
// a `$ref` standing in for the whole list, until dereferenced (the resolver judges what it led to)
traverse(child, callback, processed)
}
break
case 'schemaMap':
traverseObjectKeys(child, callback, processed)
Expand Down
61 changes: 61 additions & 0 deletions test/__snapshots__/e2e.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -389107,6 +389107,67 @@ export interface Target {
"
`;

exports[`refInPlaceOfSchemaList.externalFile.ts 1`] = `
"/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/

export interface RefInPlaceOfSchemaList {
record: {
id: string;
} & {
createdAt?: number;
};
value: string | number;
kind: "file" | "folder";
pair: [] | [string] | [string, boolean, ...unknown[]];
}
"
`;

exports[`refInPlaceOfSchemaList.inDocument.ts 1`] = `
"/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/

export interface RefInPlaceOfSchemaListInDocument {
record: {
id: string;
} & {
createdAt?: number;
};
value: string | number;
kind: "file" | "folder";
pair: [] | [string] | [string, boolean, ...unknown[]];
}
"
`;

exports[`refInPlaceOfSchemaList.inArrayDocument.ts 1`] = `
"/* eslint-disable */
/**
* This file was automatically generated by json-schema-to-typescript.
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
* and run json-schema-to-typescript to regenerate this file.
*/

export interface RefInPlaceOfSchemaListInArrayDocument {
record: {
id: string;
} & {
createdAt?: number;
};
value: string | number;
}
"
`;

exports[`options.unreachableDefinitions-declareExternallyReferenced-false.ts 1`] = `
"/* eslint-disable */
/**
Expand Down
21 changes: 21 additions & 0 deletions test/e2e/refInPlaceOfSchemaList.externalFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* A `$ref` standing in for a whole `allOf`, `anyOf`, `oneOf` or `prefixItems` list, leading to
* a file that is an array of schemas, or to such an array inside a file. The compiler used to
* crash (`TypeError: arr.forEach is not a function`) walking the document before dereferencing.
*/
export const input = {
title: 'RefInPlaceOfSchemaList',
type: 'object',
properties: {
record: {type: 'object', allOf: {$ref: 'SchemaLists/ObjectSchemas.json'}},
value: {anyOf: {$ref: 'SchemaLists/lists.json#/scalars'}},
kind: {oneOf: {$ref: 'SchemaLists/lists.json#/kinds'}},
pair: {type: 'array', prefixItems: {$ref: 'SchemaLists/lists.json#/pair'}},
},
required: ['record', 'value', 'kind', 'pair'],
additionalProperties: false,
}

export const options = {
cwd: 'test/resources/',
}
19 changes: 19 additions & 0 deletions test/e2e/refInPlaceOfSchemaList.inArrayDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* refInPlaceOfSchemaList.externalFile.ts, where the schemas holding the stand-in `$ref`s are
* themselves members of a file that is a list (reached by pointers into it): such a file gets no
* rewriting before dereferencing, so what its members hold is only judged after.
*/
export const input = {
title: 'RefInPlaceOfSchemaListInArrayDocument',
type: 'object',
properties: {
record: {$ref: 'SchemaLists/Members.json#/0'},
value: {$ref: 'SchemaLists/Members.json#/1'},
},
required: ['record', 'value'],
additionalProperties: false,
}

export const options = {
cwd: 'test/resources/',
}
27 changes: 27 additions & 0 deletions test/e2e/refInPlaceOfSchemaList.inDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* refInPlaceOfSchemaList.externalFile.ts, with JSON Pointers into the document itself: each list
* is borrowed from a definition's own `allOf`/`anyOf`/`oneOf`/`prefixItems`.
*/
export const input = {
title: 'RefInPlaceOfSchemaListInDocument',
type: 'object',
definitions: {
base: {
allOf: [
{type: 'object', properties: {id: {type: 'string'}}, required: ['id'], additionalProperties: false},
{type: 'object', properties: {createdAt: {type: 'integer'}}, additionalProperties: false},
],
},
scalar: {anyOf: [{type: 'string'}, {type: 'number'}]},
kind: {oneOf: [{enum: ['file']}, {enum: ['folder']}]},
pair: {type: 'array', prefixItems: [{type: 'string'}, {type: 'boolean'}]},
},
properties: {
record: {type: 'object', allOf: {$ref: '#/definitions/base/allOf'}},
value: {anyOf: {$ref: '#/definitions/scalar/anyOf'}},
kind: {oneOf: {$ref: '#/definitions/kind/oneOf'}},
pair: {type: 'array', prefixItems: {$ref: '#/definitions/pair/prefixItems'}},
},
required: ['record', 'value', 'kind', 'pair'],
additionalProperties: false,
}
Loading
Loading