250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
import {
|
|
schemaArrays,
|
|
schemaChildren,
|
|
schemaMaps,
|
|
} from "./jsonSchemaKeywords.js";
|
|
|
|
type SchemaObject = Readonly<Record<string, unknown>>;
|
|
type Dialect = "draft-07" | "draft-2020-12";
|
|
|
|
/** A fragment's resource and URI base are distinct facts. */
|
|
export interface JsonSchemaScope {
|
|
readonly root: SchemaObject;
|
|
readonly baseUri: string | undefined;
|
|
readonly dialect: Dialect | undefined;
|
|
readonly parent?: JsonSchemaScope;
|
|
}
|
|
|
|
type LocalReference =
|
|
| {
|
|
readonly kind: "target";
|
|
readonly value: unknown;
|
|
readonly path: readonly string[];
|
|
}
|
|
| { readonly kind: "anchor" }
|
|
| {
|
|
readonly kind: "unresolved";
|
|
readonly reason:
|
|
| "scope"
|
|
| "external"
|
|
| "fragment"
|
|
| "pointer"
|
|
| "resource-boundary"
|
|
| "dynamic";
|
|
};
|
|
|
|
const isObject = (value: unknown): value is SchemaObject =>
|
|
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const dialectOf = (
|
|
schema: SchemaObject,
|
|
inherited: Dialect | undefined,
|
|
): Dialect | undefined =>
|
|
schema.$schema === undefined
|
|
? inherited
|
|
: schema.$schema === "http://json-schema.org/draft-07/schema#" ||
|
|
schema.$schema === "http://json-schema.org/draft-07/schema"
|
|
? "draft-07"
|
|
: schema.$schema === "https://json-schema.org/draft/2020-12/schema"
|
|
? "draft-2020-12"
|
|
: undefined;
|
|
|
|
/** Per-projection scope ownership; no external resource loading or dynamic-reference evaluation. */
|
|
export class JsonSchemaReferences {
|
|
readonly rootScope: JsonSchemaScope;
|
|
readonly #scopes = new WeakMap<
|
|
object,
|
|
WeakMap<JsonSchemaScope, JsonSchemaScope | undefined>
|
|
>();
|
|
readonly #referenceSites: {
|
|
scope: JsonSchemaScope;
|
|
reference: string;
|
|
keyword: string;
|
|
}[] = [];
|
|
readonly #identities = new Map<string, JsonSchemaScope>();
|
|
readonly #relativeIdentities = new WeakMap<
|
|
JsonSchemaScope,
|
|
Map<string, JsonSchemaScope>
|
|
>();
|
|
readonly #ambiguous = new WeakSet<JsonSchemaScope>();
|
|
|
|
constructor(root: SchemaObject) {
|
|
let baseUri: string | undefined;
|
|
if (typeof root.$id === "string" && URL.canParse(root.$id)) {
|
|
const url = new URL(root.$id);
|
|
url.hash = "";
|
|
baseUri = url.href;
|
|
}
|
|
this.rootScope = {
|
|
root,
|
|
baseUri,
|
|
dialect: dialectOf(root, "draft-2020-12"),
|
|
};
|
|
if (baseUri !== undefined) this.#identities.set(baseUri, this.rootScope);
|
|
// Index resources before consumers resolve anything, so later duplicate IDs
|
|
// cannot invalidate facts that have already escaped from the owner.
|
|
const pending = [{ value: root as unknown, scope: this.rootScope }];
|
|
const visited = new WeakMap<JsonSchemaScope, WeakSet<object>>();
|
|
while (pending.length > 0) {
|
|
const { value, scope: parent } = pending.pop()!;
|
|
if (!isObject(value)) continue;
|
|
const scope = this.#scope(value, parent);
|
|
if (scope === undefined) continue;
|
|
let seen = visited.get(scope);
|
|
if (seen === undefined) {
|
|
seen = new WeakSet();
|
|
visited.set(scope, seen);
|
|
}
|
|
if (seen.has(value)) continue;
|
|
seen.add(value);
|
|
for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"]) {
|
|
const reference = value[keyword];
|
|
if (typeof reference !== "string") continue;
|
|
this.#referenceSites.push({ scope, reference, keyword });
|
|
if (keyword !== "$ref") continue;
|
|
const target = this.resolve(scope, reference);
|
|
if (target.kind === "target")
|
|
pending.push({ value: target.value, scope });
|
|
}
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (schemaMaps.has(key) && isObject(child)) {
|
|
for (const item of Object.values(child))
|
|
pending.push({ value: item, scope });
|
|
} else if (
|
|
(schemaArrays.has(key) || schemaChildren.has(key)) &&
|
|
Array.isArray(child)
|
|
) {
|
|
for (const item of child) pending.push({ value: item, scope });
|
|
} else if (schemaChildren.has(key))
|
|
pending.push({ value: child, scope });
|
|
}
|
|
}
|
|
}
|
|
|
|
enter(
|
|
schema: SchemaObject,
|
|
parent: JsonSchemaScope | undefined,
|
|
): JsonSchemaScope | undefined {
|
|
if (parent === undefined) return undefined;
|
|
const scope = this.#scope(schema, parent);
|
|
for (let current = scope; current !== undefined; current = current.parent)
|
|
if (this.#ambiguous.has(current)) return undefined;
|
|
return scope?.dialect === undefined ? undefined : scope;
|
|
}
|
|
|
|
#scope(
|
|
schema: SchemaObject,
|
|
parent: JsonSchemaScope,
|
|
): JsonSchemaScope | undefined {
|
|
if (schema === parent.root) return parent;
|
|
if (schema.$id === undefined) return parent;
|
|
let cached = this.#scopes.get(schema);
|
|
if (cached?.has(parent)) return cached.get(parent);
|
|
if (cached === undefined) {
|
|
cached = new WeakMap();
|
|
this.#scopes.set(schema, cached);
|
|
}
|
|
let scope: JsonSchemaScope | undefined;
|
|
if (
|
|
typeof schema.$id === "string" &&
|
|
schema.$id !== "" &&
|
|
(!schema.$id.includes("#") ||
|
|
(schema.$id !== "#" &&
|
|
schema.$id.indexOf("#") === schema.$id.length - 1))
|
|
) {
|
|
let baseUri: string | undefined;
|
|
try {
|
|
if (parent.baseUri !== undefined || URL.canParse(schema.$id)) {
|
|
const url = new URL(schema.$id, parent.baseUri);
|
|
url.hash = "";
|
|
baseUri = url.href;
|
|
}
|
|
scope = {
|
|
root: schema,
|
|
baseUri,
|
|
dialect: dialectOf(schema, parent.dialect),
|
|
parent,
|
|
};
|
|
if (baseUri !== undefined) {
|
|
const existing = this.#identities.get(baseUri);
|
|
if (existing !== undefined && existing.root !== schema) {
|
|
this.#ambiguous.add(existing);
|
|
this.#ambiguous.add(scope);
|
|
} else if (existing !== undefined) scope = existing;
|
|
else this.#identities.set(baseUri, scope);
|
|
} else {
|
|
let identities = this.#relativeIdentities.get(parent);
|
|
if (identities === undefined) {
|
|
identities = new Map();
|
|
this.#relativeIdentities.set(parent, identities);
|
|
}
|
|
const identity = schema.$id.replace(/#$/u, "");
|
|
const existing = identities.get(identity);
|
|
if (existing !== undefined && existing.root !== schema) {
|
|
this.#ambiguous.add(existing);
|
|
this.#ambiguous.add(scope);
|
|
} else if (existing !== undefined) scope = existing;
|
|
else identities.set(identity, scope);
|
|
}
|
|
} catch {
|
|
// An opaque or unknown URI base must not be replaced with a guessed scope.
|
|
}
|
|
}
|
|
// Empty/fragment IDs can alias a resource or be legacy anchors. Keep them
|
|
// unresolved rather than resetting the pointer root to that subschema.
|
|
cached.set(parent, scope);
|
|
return scope;
|
|
}
|
|
|
|
*references(): IterableIterator<{
|
|
readonly scope: JsonSchemaScope;
|
|
readonly target: LocalReference;
|
|
}> {
|
|
for (const { scope, reference, keyword } of this.#referenceSites)
|
|
yield {
|
|
scope,
|
|
target:
|
|
keyword === "$ref"
|
|
? this.resolve(scope, reference)
|
|
: { kind: "unresolved", reason: "dynamic" },
|
|
};
|
|
}
|
|
|
|
resolve(
|
|
scope: JsonSchemaScope | undefined,
|
|
reference: string,
|
|
): LocalReference {
|
|
if (scope === undefined || this.enter(scope.root, scope) === undefined)
|
|
return { kind: "unresolved", reason: "scope" };
|
|
if (!reference.startsWith("#"))
|
|
return { kind: "unresolved", reason: "external" };
|
|
let fragment: string;
|
|
try {
|
|
fragment = decodeURIComponent(reference.slice(1));
|
|
} catch {
|
|
return { kind: "unresolved", reason: "fragment" };
|
|
}
|
|
if (fragment === "") return { kind: "target", value: scope.root, path: [] };
|
|
if (!fragment.startsWith("/")) return { kind: "anchor" };
|
|
let value: unknown = scope.root;
|
|
const path: string[] = [];
|
|
for (const token of fragment.slice(1).split("/")) {
|
|
if (/~(?:[^01]|$)/u.test(token))
|
|
return { kind: "unresolved", reason: "pointer" };
|
|
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
|
if (
|
|
value === null ||
|
|
typeof value !== "object" ||
|
|
!Object.hasOwn(value, key) ||
|
|
(Array.isArray(value) && !/^(?:0|[1-9][0-9]*)$/u.test(key))
|
|
)
|
|
return { kind: "unresolved", reason: "pointer" };
|
|
if (value !== scope.root && isObject(value) && "$id" in value)
|
|
return { kind: "unresolved", reason: "resource-boundary" };
|
|
path.push(key);
|
|
value = Reflect.get(value, key);
|
|
}
|
|
return { kind: "target", value, path };
|
|
}
|
|
}
|