@apidevtools/json-schema-ref-parser
Version:
Parse, Resolve, and Dereference JSON Schema $ref pointers
355 lines (354 loc) • 12.4 kB
JavaScript
import Pointer, { nullSymbol } from "./pointer.js";
import { InvalidPointerError, isHandledError, normalizeError } from "./util/errors.js";
import { safePointerToPath, stripHash, getHash } from "./util/url.js";
/**
* This class represents a single JSON reference and its resolved value.
*
* @class
*/
class $Ref {
/**
* The file path or URL of the referenced file.
* This path is relative to the path of the main JSON schema file.
*
* This path does NOT contain document fragments (JSON pointers). It always references an ENTIRE file.
* Use methods such as {@link $Ref#get}, {@link $Ref#resolve}, and {@link $Ref#exists} to get
* specific JSON pointers within the file.
*
* @type {string}
*/
path;
/**
* The resolved value of the JSON reference.
* Can be any JSON type, not just objects. Unknown file types are represented as Buffers (byte arrays).
*
* @type {?*}
*/
value;
/**
* The {@link $Refs} object that contains this {@link $Ref} object.
*
* @type {$Refs}
*/
$refs;
/**
* Indicates the type of {@link $Ref#path} (e.g. "file", "http", etc.)
*/
pathType;
/**
* Whether this document/resource should use JSON Schema 2019-09+ nested $id scope semantics.
*/
dynamicIdScope = false;
/** Whether this resource uses draft-04's legacy `id` keyword. */
legacyIdScope = false;
/**
* List of all errors. Undefined if no errors.
*/
errors = [];
constructor($refs) {
this.$refs = $refs;
}
/**
* Pushes an error to errors array.
*
* @param err - The error to be pushed
* @returns
*/
addError(err) {
if (this.errors === undefined) {
this.errors = [];
}
const existingErrors = this.errors.map(({ footprint }) => footprint);
// the path has been almost certainly set at this point,
// but just in case something went wrong, normalizeError injects path if necessary
// moreover, certain errors might point at the same spot, so filter them out to reduce noise
if ("errors" in err && Array.isArray(err.errors)) {
this.errors.push(...err.errors.map(normalizeError).filter(({ footprint }) => !existingErrors.includes(footprint)));
}
else if (!("footprint" in err) || !existingErrors.includes(err.footprint)) {
this.errors.push(normalizeError(err));
}
}
/**
* Determines whether the given JSON reference exists within this {@link $Ref#value}.
*
* @param path - The full path being resolved, optionally with a JSON pointer in the hash
* @param options
* @returns
*/
exists(path, options) {
try {
this.resolve(path, options);
return true;
}
catch {
return false;
}
}
/**
* Resolves the given JSON reference within this {@link $Ref#value} and returns the resolved value.
*
* @param path - The full path being resolved, optionally with a JSON pointer in the hash
* @param options
* @returns - Returns the resolved value
*/
get(path, options) {
return this.resolve(path, options)?.value;
}
/**
* Resolves the given JSON reference within this {@link $Ref#value}.
*
* @param path - The full path being resolved, optionally with a JSON pointer in the hash
* @param options
* @param friendlyPath - The original user-specified path (used for error messages)
* @param pathFromRoot - The path of `obj` from the schema root
* @param visitedRefPaths - the active paths in the current reference chain
* @param resolveFinalReference - whether to follow a `$ref` at the resolved value
* @returns
*/
resolve(path, options, friendlyPath, pathFromRoot, visitedRefPaths, resolveFinalReference = true) {
const pointer = new Pointer(this, path, friendlyPath);
try {
const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolveFinalReference);
if (resolved.value === nullSymbol) {
resolved.value = null;
}
return resolved;
}
catch (err) {
if (!options || !options.continueOnError || !isHandledError(err)) {
throw err;
}
if (err.path === null) {
err.path = safePointerToPath(getHash(pathFromRoot));
}
if (err instanceof InvalidPointerError) {
err.source = safelyDecodeURI(stripHash(pathFromRoot || friendlyPath || path));
}
this.addError(err);
return null;
}
}
/**
* Sets the value of a nested property within this {@link $Ref#value}.
* If the property, or any of its parents don't exist, they will be created.
*
* @param path - The full path of the property to set, optionally with a JSON pointer in the hash
* @param value - The value to assign
*/
set(path, value) {
const pointer = new Pointer(this, path);
this.value = pointer.set(this.value, value);
if (this.value === nullSymbol) {
this.value = null;
}
}
/**
* Determines whether the given value is a JSON reference.
*
* @param value - The value to inspect
* @returns
*/
static is$Ref(value) {
return (Boolean(value) &&
typeof value === "object" &&
value !== null &&
"$ref" in value &&
typeof value.$ref === "string" &&
value.$ref.length > 0);
}
/**
* Determines whether the given value is an external JSON reference.
*
* @param value - The value to inspect
* @returns
*/
static isExternal$Ref(value) {
return $Ref.is$Ref(value) && value.$ref[0] !== "#";
}
/**
* Determines whether the given value is a JSON reference, and whether it is allowed by the options.
* For example, if it references an external file, then options.resolve.external must be true.
*
* @param value - The value to inspect
* @param options
* @returns
*/
static isAllowed$Ref(value, options) {
if (this.is$Ref(value)) {
if (value.$ref.substring(0, 2) === "#/" || value.$ref === "#") {
// It's a JSON Pointer reference, which is always allowed
return true;
}
else if (value.$ref[0] !== "#" && (!options || options.resolve?.external)) {
// It's an external reference, which is allowed by the options
return true;
}
}
return undefined;
}
/**
* Determines whether the given value is a JSON reference that "extends" its resolved value.
* That is, it has extra properties (in addition to "$ref"), so rather than simply pointing to
* an existing value, this $ref actually creates a NEW value that is a shallow copy of the resolved
* value, plus the extra properties.
*
* @example: {
person: {
properties: {
firstName: { type: string }
lastName: { type: string }
}
}
employee: {
properties: {
$ref: #/person/properties
salary: { type: number }
}
}
}
* In this example, "employee" is an extended $ref, since it extends "person" with an additional
* property (salary). The result is a NEW value that looks like this:
*
* {
* properties: {
* firstName: { type: string }
* lastName: { type: string }
* salary: { type: number }
* }
* }
*
* @param value - The value to inspect
* @returns
*/
static isExtended$Ref(value) {
return $Ref.is$Ref(value) && Object.keys(value).length > 1;
}
/**
* Returns the resolved value of a JSON Reference.
* If necessary, the resolved value is merged with the JSON Reference to create a new object
*
* @example: {
person: {
properties: {
firstName: { type: string }
lastName: { type: string }
}
}
employee: {
properties: {
$ref: #/person/properties
salary: { type: number }
}
}
} When "person" and "employee" are merged, you end up with the following object:
*
* {
* properties: {
* firstName: { type: string }
* lastName: { type: string }
* salary: { type: number }
* }
* }
*
* @param $ref - The JSON reference object (the one with the "$ref" property)
* @param resolvedValue - The resolved value, which can be any type
* @param options - The options
* @returns - Returns the dereferenced value
*/
static dereference($ref, resolvedValue, options) {
if (resolvedValue && typeof resolvedValue === "object" && $Ref.isExtended$Ref($ref)) {
const merged = {};
for (const key of Object.keys($ref)) {
if (key !== "$ref") {
defineDataProperty(merged, key, $ref[key]);
}
}
const mergeKeys = options?.dereference?.mergeKeys ?? true;
const resolvedObject = resolvedValue;
for (const key of Object.keys(resolvedObject)) {
if (!Object.hasOwn(merged, key)) {
defineDataProperty(merged, key, resolvedObject[key]);
}
else {
// Key is already in merged, so we should merge them if both are objects
if (mergeKeys &&
typeof merged[key] === "object" &&
merged[key] !== null &&
typeof resolvedObject[key] === "object" &&
resolvedObject[key] !== null) {
defineDataProperty(merged, key, deepMerge(resolvedObject[key], merged[key]));
}
}
}
return merged;
}
else {
// Completely replace the original reference with the resolved value
return resolvedValue;
}
}
}
function safelyDecodeURI(value) {
try {
return decodeURI(value);
}
catch {
return value;
}
}
function deepMerge(target, source) {
// If either isn't an object, just return source (overwrite)
if (typeof target !== "object" || target === null) {
return source;
}
if (typeof source !== "object" || source === null) {
return source;
}
if (Array.isArray(source)) {
return cloneEnumerableProperties(source);
}
// Ensure we don't mutate target directly
const output = cloneEnumerableProperties(target);
const targetObject = target;
const sourceObject = source;
for (const key of Object.keys(sourceObject)) {
const sourceValue = sourceObject[key];
let mergedValue;
if (Array.isArray(sourceValue)) {
// Arrays from the extending value replace the target array entirely.
mergedValue = cloneEnumerableProperties(sourceValue);
}
else if (typeof sourceValue === "object" && sourceValue !== null) {
const targetValue = Object.hasOwn(targetObject, key) ? targetObject[key] : undefined;
mergedValue = deepMerge(targetValue, sourceValue);
}
else {
mergedValue = sourceValue;
}
defineDataProperty(output, key, mergedValue);
}
return output;
}
/**
* Creates a shallow copy using only enumerable own properties. Defining each key as a data
* property is important for JSON keys such as `__proto__`, which must not invoke Object.prototype's
* legacy prototype setter.
*/
function cloneEnumerableProperties(value) {
const clone = Array.isArray(value) ? new Array(value.length) : {};
const source = value;
for (const key of Object.keys(source)) {
defineDataProperty(clone, key, source[key]);
}
return clone;
}
function defineDataProperty(target, key, value) {
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
value,
writable: true,
});
}
export default $Ref;