Skip to content

Commit e814b0a

Browse files
authored
fix: root $ref resolving
Reworks schema resolving to be able to handle $refs in the root of the JSON Schema. Also adds circle reference detection, preventing runtime crashes. Fixes #2471 Also adjusts core ava settings to fix incorrect source mappings.
1 parent 883f108 commit e814b0a

File tree

4 files changed

+536
-51
lines changed

4 files changed

+536
-51
lines changed

packages/core/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,7 @@
5454
"ts"
5555
],
5656
"require": [
57-
"./test-config/ts-node.config.js",
58-
"source-map-support/register.js"
57+
"./test-config/ts-node.config.js"
5958
]
6059
},
6160
"nyc": {

packages/core/src/util/resolvers.ts

Lines changed: 161 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -102,32 +102,89 @@ export const findAllRefs = (
102102
const invalidSegment = (pathSegment: string) =>
103103
pathSegment === '#' || pathSegment === undefined || pathSegment === '';
104104

105+
/**
106+
* Map for tracking schema resolution to prevent infinite recursion.
107+
* Key: schema object reference, Value: Set of paths being resolved from that schema
108+
*/
109+
type ResolutionTrackingMap = Map<JsonSchema, Set<string>>;
110+
interface ResolveContext {
111+
rootSchema: JsonSchema;
112+
resolutionMap: ResolutionTrackingMap;
113+
}
114+
105115
/**
106116
* Resolve the given schema path in order to obtain a subschema.
107117
* @param {JsonSchema} schema the root schema from which to start
108118
* @param {string} schemaPath the schema path to be resolved
109119
* @param {JsonSchema} rootSchema the actual root schema
110-
* @returns {JsonSchema} the resolved sub-schema
120+
* @returns {JsonSchema} the resolved sub-schema or undefined
111121
*/
112122
export const resolveSchema = (
113123
schema: JsonSchema,
114124
schemaPath: string,
115125
rootSchema: JsonSchema
116-
): JsonSchema => {
117-
const segments = schemaPath?.split('/').map(decode);
118-
return resolveSchemaWithSegments(schema, segments, rootSchema);
126+
): JsonSchema | undefined => {
127+
const result = doResolveSchema(schema, schemaPath, {
128+
rootSchema,
129+
resolutionMap: new Map(),
130+
});
131+
return result;
119132
};
120133

121-
const resolveSchemaWithSegments = (
134+
const doResolveSchema = (
122135
schema: JsonSchema,
123-
pathSegments: string[],
124-
rootSchema: JsonSchema
136+
schemaPath: string,
137+
ctx: ResolveContext
138+
): JsonSchema | undefined => {
139+
let resolvedSchema: JsonSchema | undefined = undefined;
140+
// If the schema has a $ref, we resolve it first before continuing.
141+
if (schema && typeof schema.$ref === 'string') {
142+
const baseSchema = resolvePath(ctx.rootSchema, schema.$ref, ctx);
143+
if (baseSchema !== undefined) {
144+
resolvedSchema = resolvePath(baseSchema, schemaPath, ctx);
145+
}
146+
}
147+
// With later versions of JSON Schema, the $ref can also be used next to other properties,
148+
// therefore we also try to resolve the path from the schema itself, even if it has a $ref.
149+
if (resolvedSchema === undefined) {
150+
resolvedSchema = resolvePath(schema, schemaPath, ctx);
151+
}
152+
return resolvedSchema;
153+
};
154+
155+
const resolvePath = (
156+
schema: JsonSchema,
157+
schemaPath: string | undefined,
158+
ctx: ResolveContext
125159
): JsonSchema => {
126-
// use typeof because schema can by of any type - check singleSegmentResolveSchema below
127-
if (typeof schema?.$ref === 'string') {
128-
schema = resolveSchema(rootSchema, schema.$ref, rootSchema);
160+
let visitedPaths: Set<string> | undefined = ctx.resolutionMap.get(schema);
161+
if (!visitedPaths) {
162+
visitedPaths = new Set();
163+
ctx.resolutionMap.set(schema, visitedPaths);
164+
}
165+
if (visitedPaths.has(schemaPath)) {
166+
// We were already asked to resolve this path from this schema, we must be stuck in a circular reference.
167+
return undefined;
129168
}
130169

170+
visitedPaths.add(schemaPath);
171+
172+
const resolvedSchema = resolvePathSegmentsWithCombinatorFallback(
173+
schema,
174+
schemaPath?.split('/').map(decode),
175+
ctx
176+
);
177+
178+
visitedPaths.delete(schemaPath);
179+
180+
return resolvedSchema;
181+
};
182+
183+
const resolvePathSegmentsWithCombinatorFallback = (
184+
schema: JsonSchema,
185+
pathSegments: string[],
186+
ctx: ResolveContext
187+
): JsonSchema | undefined => {
131188
if (!pathSegments || pathSegments.length === 0) {
132189
return schema;
133190
}
@@ -136,49 +193,109 @@ const resolveSchemaWithSegments = (
136193
return undefined;
137194
}
138195

139-
const [segment, ...remainingSegments] = pathSegments;
196+
const resolvedSchema = resolvePathSegments(schema, pathSegments, ctx);
197+
if (resolvedSchema !== undefined) {
198+
return resolvedSchema;
199+
}
140200

141-
if (invalidSegment(segment)) {
142-
return resolveSchemaWithSegments(schema, remainingSegments, rootSchema);
201+
// If the schema is not found, try combinators
202+
const subSchemas = [].concat(
203+
schema.oneOf ?? [],
204+
schema.allOf ?? [],
205+
schema.anyOf ?? [],
206+
(schema as JsonSchema7).then ?? [],
207+
(schema as JsonSchema7).else ?? []
208+
);
209+
210+
for (const subSchema of subSchemas) {
211+
let resolvedSubSchema = subSchema;
212+
// check whether the subSchema is a $ref. If it is, resolve it first.
213+
if (subSchema && typeof subSchema.$ref === 'string') {
214+
resolvedSubSchema = doResolveSchema(ctx.rootSchema, subSchema.$ref, ctx);
215+
}
216+
const alternativeResolveResult = resolvePathSegmentsWithCombinatorFallback(
217+
resolvedSubSchema,
218+
pathSegments,
219+
ctx
220+
);
221+
if (alternativeResolveResult) {
222+
return alternativeResolveResult;
223+
}
143224
}
144225

145-
const singleSegmentResolveSchema = get(schema, segment);
226+
return undefined;
227+
};
146228

147-
const resolvedSchema = resolveSchemaWithSegments(
148-
singleSegmentResolveSchema,
149-
remainingSegments,
150-
rootSchema
151-
);
152-
if (resolvedSchema) {
153-
return resolvedSchema;
229+
const resolvePathSegments = (
230+
schema: JsonSchema,
231+
pathSegments: string[],
232+
ctx: ResolveContext
233+
): JsonSchema | undefined => {
234+
if (!pathSegments || pathSegments.length === 0) {
235+
return schema;
154236
}
155237

156-
if (segment === 'properties' || segment === 'items') {
157-
// Let's try to resolve the path, assuming oneOf/allOf/anyOf/then/else was omitted.
158-
// We only do this when traversing an object or array as we want to avoid
159-
// following a property which is named oneOf, allOf, anyOf, then or else.
160-
let alternativeResolveResult = undefined;
238+
if (isEmpty(schema)) {
239+
return undefined;
240+
}
241+
242+
// perform a single step
243+
const singleStepResult = resolveSingleStep(schema, pathSegments);
161244

162-
const subSchemas = [].concat(
163-
schema.oneOf ?? [],
164-
schema.allOf ?? [],
165-
schema.anyOf ?? [],
166-
(schema as JsonSchema7).then ?? [],
167-
(schema as JsonSchema7).else ?? []
245+
// Check whether resolving the next step was successful and returned a schema which has a $ref itself.
246+
// In this case, we need to resolve the $ref first before continuing.
247+
if (
248+
singleStepResult.schema &&
249+
typeof singleStepResult.schema.$ref === 'string'
250+
) {
251+
singleStepResult.schema = doResolveSchema(
252+
ctx.rootSchema,
253+
singleStepResult.schema.$ref,
254+
ctx
168255
);
256+
}
169257

170-
for (const subSchema of subSchemas) {
171-
alternativeResolveResult = resolveSchemaWithSegments(
172-
subSchema,
173-
[segment, ...remainingSegments],
174-
rootSchema
175-
);
176-
if (alternativeResolveResult) {
177-
break;
178-
}
179-
}
180-
return alternativeResolveResult;
258+
return resolvePathSegmentsWithCombinatorFallback(
259+
singleStepResult.schema,
260+
singleStepResult.remainingPathSegments,
261+
ctx
262+
);
263+
};
264+
265+
interface ResolveSingleStepResult {
266+
schema: JsonSchema | undefined;
267+
remainingPathSegments: string[];
268+
resolvedSegment?: string;
269+
}
270+
/**
271+
* Tries to resolve the next "step" of the pathSegments.
272+
* Often this will be a single segment, but it might be multiples ones in case there are invalid segments.
273+
*/
274+
const resolveSingleStep = (
275+
schema: JsonSchema,
276+
pathSegments: string[]
277+
): ResolveSingleStepResult => {
278+
if (!pathSegments || pathSegments.length === 0) {
279+
return { schema, remainingPathSegments: [] };
181280
}
182281

183-
return undefined;
282+
if (isEmpty(schema)) {
283+
return {
284+
schema: undefined,
285+
remainingPathSegments: pathSegments,
286+
};
287+
}
288+
289+
const [segment, ...remainingPathSegments] = pathSegments;
290+
291+
if (invalidSegment(segment)) {
292+
return resolveSingleStep(schema, remainingPathSegments);
293+
}
294+
295+
const singleSegmentResolveSchema = get(schema, segment);
296+
return {
297+
schema: singleSegmentResolveSchema,
298+
remainingPathSegments,
299+
resolvedSegment: segment,
300+
};
184301
};

0 commit comments

Comments
 (0)