diff --git a/src/prenormalizer.ts b/src/prenormalizer.ts index be2f57ca..30b58474 100644 --- a/src/prenormalizer.ts +++ b/src/prenormalizer.ts @@ -226,18 +226,7 @@ function resolveRootRef(schema: JSONSchema): void { } seenPointers.add(pointer) - const target = pointer - .slice(2) - .split('/') - .reduce((node, segment) => { - if (!isPlainObject(node) && !Array.isArray(node)) { - return undefined - } - const key = safeDecodeURIComponent(segment.replace(/~1/g, '/').replace(/~0/g, '~')) - // Only an own property is a real JSON Pointer match -- otherwise a segment - // like `__proto__` would resolve via the prototype chain instead of failing. - return Object.prototype.hasOwnProperty.call(node, key) ? (node as Record)[key] : undefined - }, documentRoot) + const target = inDocumentPointerValue(documentRoot, pointer) if (typeof target === 'boolean') { // A boolean schema, which the root cannot become in place: keep it as the root's one more @@ -247,7 +236,7 @@ function resolveRootRef(schema: JSONSchema): void { break } if (!isPlainObject(target)) { - break // not a schema in this document; let $RefParser handle/report it + break // missing, or not a schema: `dereference` reports the latter, $RefParser the former } delete schema.$ref @@ -262,6 +251,25 @@ function resolveRootRef(schema: JSONSchema): void { } } +/** + * The value a `#/...` pointer leads to in `document`: what `resolveRootRef` merges into the root + * when it is a schema, and what `dereference` judges when it is not. Walks plain objects and + * arrays by their own keys only -- otherwise a segment like `__proto__` would resolve via the + * prototype chain instead of failing. Undefined where the pointer leads nowhere. + */ +export function inDocumentPointerValue(document: unknown, pointer: string): unknown { + return pointer + .slice(2) + .split('/') + .reduce((node, segment) => { + if (!isPlainObject(node) && !Array.isArray(node)) { + return undefined + } + const key = safeDecodeURIComponent(segment.replace(/~1/g, '/').replace(/~0/g, '~')) + return Object.prototype.hasOwnProperty.call(node, key) ? (node as Record)[key] : undefined + }, document) +} + // A JSON Pointer segment isn't guaranteed to be a valid percent-encoding (it may // contain a literal, unescaped `%`), so fall back to the raw segment rather than // letting decodeURIComponent throw. diff --git a/src/resolver.ts b/src/resolver.ts index 7ba60414..c2646bb3 100644 --- a/src/resolver.ts +++ b/src/resolver.ts @@ -10,7 +10,7 @@ import { jsonSchemaParserNormalizeArgs, } from '@apidevtools/json-schema-ref-parser' import {isObjectLike, isPlainObject} from 'lodash' -import {prenormalizeDocument} from './prenormalizer' +import {inDocumentPointerValue, prenormalizeDocument} from './prenormalizer' import {SCHEMA_HOLDING_KEYWORDS} from './keywords' import {DefinitionKey, JSONSchema, SchemaSource, Source} from './types/JSONSchema' import {eachSchemaNode, log} from './utils' @@ -76,6 +76,16 @@ export async function dereference( // $RefParser does. A member of a file set goes to $RefParser too: it is registered there under // its own path, for the other members' `$ref`s to find. const optionsConcernOtherFiles = Object.keys($refOptions).every(_ => _ === 'resolve' || _ === 'parse') + // The root's own `$ref` into this document was resolved by `prenormalizeDocument` if it led to a + // schema. One that leads to a keyword's value instead (`#/definitions/unit/default`) is judged + // here, before either route: the ref-parser (16 on) follows the root's `$ref` while walking that + // very pointer's tokens, and so reports the pointer as missing when it is not. + if (typeof schema.$ref === 'string' && schema.$ref.startsWith('#/')) { + const target = inDocumentPointerValue(schema, schema.$ref) + if (target !== undefined && !isSchema(target)) { + rejectNonSchemaTargets(schema, target, [], () => ({})) + } + } const targets = optionsConcernOtherFiles && !set ? inDocumentTargets(schema) : undefined let dereferencedSchema = schema if (targets) { diff --git a/test/e2e/refToNonSchemaValue.root.ts b/test/e2e/refToNonSchemaValue.root.ts new file mode 100644 index 00000000..aba8a2be --- /dev/null +++ b/test/e2e/refToNonSchemaValue.root.ts @@ -0,0 +1,14 @@ +/** + * refToNonSchemaValue.ts, with the `$ref` at the root: the whole schema would be the string + * `"metres"`. (The ref-parser, following the root's own `$ref` while walking the pointer's tokens, + * used to report the pointer as missing -- "Token "definitions" does not exist" -- when it is not.) + */ +export const input = { + $ref: '#/definitions/unit/default', + definitions: { + unit: {type: 'string', default: 'metres'}, + }, +} + +export const error = + /\$ref "#\/definitions\/unit\/default" at the root resolves to the string "metres", which is not a JSON Schema/