Skip to content

Commit 98cf9e0

Browse files
authored
Merge pull request #107 from mati-o/fix-ref-to-id
Fix $ref to $id
2 parents 7d9158a + e244bcf commit 98cf9e0

File tree

4 files changed

+497
-24
lines changed

4 files changed

+497
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
4.2.0 /
44
================
55
* new API `LanguageService.getLanguageStatus`
6+
* support for $ref with $id
67

78
4.1.6 / 2021-07-16
89
================

src/services/jsonSchemaService.ts

Lines changed: 118 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ class SchemaHandle implements ISchemaHandle {
129129

130130
public readonly uri: string;
131131
public readonly dependencies: SchemaDependencies;
132-
132+
public readonly anchors: Map<string, JSONSchema>;
133133
private resolvedSchema: Thenable<ResolvedSchema> | undefined;
134134
private unresolvedSchema: Thenable<UnresolvedSchema> | undefined;
135135
private readonly service: JSONSchemaService;
@@ -138,6 +138,7 @@ class SchemaHandle implements ISchemaHandle {
138138
this.service = service;
139139
this.uri = uri;
140140
this.dependencies = new Set();
141+
this.anchors = new Map();
141142
if (unresolvedSchemaContent) {
142143
this.unresolvedSchema = this.service.promise.resolve(new UnresolvedSchema(unresolvedSchemaContent));
143144
}
@@ -153,7 +154,7 @@ class SchemaHandle implements ISchemaHandle {
153154
public getResolvedSchema(): Thenable<ResolvedSchema> {
154155
if (!this.resolvedSchema) {
155156
this.resolvedSchema = this.getUnresolvedSchema().then(unresolved => {
156-
return this.service.resolveSchemaContent(unresolved, this.uri, this.dependencies);
157+
return this.service.resolveSchemaContent(unresolved, this);
157158
});
158159
}
159160
return this.resolvedSchema;
@@ -164,6 +165,7 @@ class SchemaHandle implements ISchemaHandle {
164165
this.resolvedSchema = undefined;
165166
this.unresolvedSchema = undefined;
166167
this.dependencies.clear();
168+
this.anchors.clear();
167169
return hasChanges;
168170
}
169171
}
@@ -404,7 +406,7 @@ export class JSONSchemaService implements IJSONSchemaService {
404406
);
405407
}
406408

407-
public resolveSchemaContent(schemaToResolve: UnresolvedSchema, schemaURL: string, dependencies: SchemaDependencies): Thenable<ResolvedSchema> {
409+
public resolveSchemaContent(schemaToResolve: UnresolvedSchema, handle: SchemaHandle): Thenable<ResolvedSchema> {
408410

409411
const resolveErrors: string[] = schemaToResolve.errors.slice(0);
410412
const schema = schemaToResolve.schema;
@@ -438,38 +440,91 @@ export class JSONSchemaService implements IJSONSchemaService {
438440
return current;
439441
};
440442

441-
const merge = (target: JSONSchema, sourceRoot: JSONSchema, sourceURI: string, refSegment: string | undefined): void => {
443+
const merge = (target: JSONSchema, section: any): void => {
444+
for (const key in section) {
445+
if (section.hasOwnProperty(key) && !target.hasOwnProperty(key)) {
446+
(<any>target)[key] = section[key];
447+
}
448+
}
449+
};
450+
451+
const mergeByJsonPointer = (target: JSONSchema, sourceRoot: JSONSchema, sourceURI: string, refSegment: string | undefined): void => {
442452
const path = refSegment ? decodeURIComponent(refSegment) : undefined;
443453
const section = findSection(sourceRoot, path);
444454
if (section) {
445-
for (const key in section) {
446-
if (section.hasOwnProperty(key) && !target.hasOwnProperty(key)) {
447-
(<any>target)[key] = section[key];
448-
}
449-
}
455+
merge(target, section);
450456
} else {
451457
resolveErrors.push(localize('json.schema.invalidref', '$ref \'{0}\' in \'{1}\' can not be resolved.', path, sourceURI));
452458
}
453459
};
454460

455-
const resolveExternalLink = (node: JSONSchema, uri: string, refSegment: string | undefined, parentSchemaURL: string, parentSchemaDependencies: SchemaDependencies): Thenable<any> => {
461+
const isSubSchemaRef = (refSegment?: string): boolean => {
462+
// Check if the first character is not '/' to determine whether it's a sub schema reference or a JSON Pointer
463+
return !!refSegment && refSegment.charAt(0) !== '/';
464+
};
465+
466+
const reconstructRefURI = (uri: string, fragment?: string, separator: string = '#'): string => {
467+
return normalizeId(`${uri}${separator}${fragment}`);
468+
};
469+
470+
// To find which $refs point to which $ids we keep two maps:
471+
// pendingSubSchemas '$id' we expect to encounter (if they exist)
472+
// handle.anchors for the ones we have encountered
473+
const pendingSubSchemas: Map<string, JSONSchema[]> = new Map();
474+
475+
const tryMergeSubSchema = (target: JSONSchema, id: string, handle: SchemaHandle): boolean => {
476+
// Get the full URI for the current schema to avoid matching schema1#hello and schema2#hello to the same
477+
// reference by accident
478+
const fullId = reconstructRefURI(handle.uri, id);
479+
const resolved = handle.anchors.get(fullId);
480+
if (resolved) {
481+
merge(target, resolved);
482+
return true; // return success
483+
}
484+
485+
// This subschema has not been resolved yet
486+
// Remember the target to merge later once resolved
487+
let pending = pendingSubSchemas.get(fullId);
488+
if (!pending) {
489+
pending = [];
490+
pendingSubSchemas.set(fullId, pending);
491+
}
492+
pending.push(target);
493+
return false; // return failure - merge didn't occur
494+
};
495+
496+
const resolveExternalLink = (node: JSONSchema, uri: string, refSegment: string | undefined, parentHandle: SchemaHandle): Thenable<any> => {
456497
if (contextService && !/^[A-Za-z][A-Za-z0-9+\-.+]*:\/\/.*/.test(uri)) {
457-
uri = contextService.resolveRelativePath(uri, parentSchemaURL);
498+
uri = contextService.resolveRelativePath(uri, parentHandle.uri);
458499
}
459500
uri = normalizeId(uri);
460501
const referencedHandle = this.getOrAddSchemaHandle(uri);
461502
return referencedHandle.getUnresolvedSchema().then(unresolvedSchema => {
462-
parentSchemaDependencies.add(uri);
503+
parentHandle.dependencies.add(uri);
463504
if (unresolvedSchema.errors.length) {
464505
const loc = refSegment ? uri + '#' + refSegment : uri;
465506
resolveErrors.push(localize('json.schema.problemloadingref', 'Problems loading reference \'{0}\': {1}', loc, unresolvedSchema.errors[0]));
466507
}
467-
merge(node, unresolvedSchema.schema, uri, refSegment);
468-
return resolveRefs(node, unresolvedSchema.schema, uri, referencedHandle.dependencies);
508+
509+
// A placeholder promise that might execute later a ref resolution for the newly resolved schema
510+
let externalLinkPromise: Thenable<any> = Promise.resolve(true);
511+
if (refSegment === undefined || !isSubSchemaRef(refSegment)) {
512+
// This is not a sub schema, merge the regular way
513+
mergeByJsonPointer(node, unresolvedSchema.schema, uri, refSegment);
514+
} else {
515+
// This is a reference to a subschema
516+
if (!tryMergeSubSchema(node, refSegment, referencedHandle)) {
517+
// We weren't able to merge currently so we'll try to resolve this schema first to obtain subschemas
518+
// that could be missed
519+
// to improve: it would be enough to find the nodes, no need to resolve the full schema
520+
externalLinkPromise = resolveRefs(unresolvedSchema.schema, unresolvedSchema.schema, referencedHandle);
521+
}
522+
}
523+
return externalLinkPromise.then(() => resolveRefs(node, unresolvedSchema.schema, referencedHandle));
469524
});
470525
};
471526

472-
const resolveRefs = (node: JSONSchema, parentSchema: JSONSchema, parentSchemaURL: string, parentSchemaDependencies: SchemaDependencies): Thenable<any> => {
527+
const resolveRefs = (node: JSONSchema, parentSchema: JSONSchema, parentHandle: SchemaHandle): Thenable<any> => {
473528
if (!node || typeof node !== 'object') {
474529
return Promise.resolve(null);
475530
}
@@ -517,11 +572,18 @@ export class JSONSchemaService implements IJSONSchemaService {
517572
const segments = ref.split('#', 2);
518573
delete next.$ref;
519574
if (segments[0].length > 0) {
520-
openPromises.push(resolveExternalLink(next, segments[0], segments[1], parentSchemaURL, parentSchemaDependencies));
575+
// This is a reference to an external schema
576+
openPromises.push(resolveExternalLink(next, segments[0], segments[1], parentHandle));
521577
return;
522578
} else {
579+
// This is a reference inside the current schema
523580
if (!seenRefs.has(ref)) {
524-
merge(next, parentSchema, parentSchemaURL, segments[1]); // can set next.$ref again, use seenRefs to avoid circle
581+
const id = segments[1];
582+
if (id !== undefined && isSubSchemaRef(id)) { // A $ref to a sub-schema with an $id (i.e #hello)
583+
tryMergeSubSchema(next, id, handle);
584+
} else { // A $ref to a JSON Pointer (i.e #/definitions/foo)
585+
mergeByJsonPointer(next, parentSchema, parentHandle.uri, id); // can set next.$ref again, use seenRefs to avoid circle
586+
}
525587
seenRefs.add(ref);
526588
}
527589
}
@@ -532,18 +594,54 @@ export class JSONSchemaService implements IJSONSchemaService {
532594
collectArrayEntries(next.anyOf, next.allOf, next.oneOf, <JSONSchema[]>next.items);
533595
};
534596

597+
const handleId = (next: JSONSchema) => {
598+
// TODO figure out should loops be preventse
599+
const id = next.$id || next.id;
600+
if (typeof id === 'string' && id.charAt(0) === '#') {
601+
delete next.$id;
602+
delete next.id;
603+
// Use a blank separator, as the $id already has the '#'
604+
const fullId = reconstructRefURI(parentHandle.uri, id, '');
605+
606+
const resolved = parentHandle.anchors.get(fullId);
607+
if (!resolved) {
608+
// it's resolved now
609+
parentHandle.anchors.set(fullId, next);
610+
} else if (resolved !== next) {
611+
// Duplicate may occur in recursive $refs, but as long as they are the same object
612+
// it's ok, otherwise report and error
613+
resolveErrors.push(localize('json.schema.duplicateid', 'Duplicate id declaration: \'{0}\'', id));
614+
}
615+
616+
// Resolve all pending requests and cleanup the queue list
617+
const pending = pendingSubSchemas.get(fullId);
618+
if (pending) {
619+
for (const target of pending) {
620+
merge(target, next);
621+
}
622+
pendingSubSchemas.delete(fullId);
623+
}
624+
}
625+
};
626+
535627
while (toWalk.length) {
536628
const next = toWalk.pop()!;
537629
if (seen.has(next)) {
538630
continue;
539631
}
540632
seen.add(next);
633+
handleId(next);
541634
handleRef(next);
542635
}
543636
return this.promise.all(openPromises);
544637
};
545638

546-
return resolveRefs(schema, schema, schemaURL, dependencies).then(_ => new ResolvedSchema(schema, resolveErrors));
639+
return resolveRefs(schema, schema, handle).then(_ => {
640+
for (const unresolvedSubschemaId in pendingSubSchemas) {
641+
resolveErrors.push(localize('json.schema.idnotfound', 'Subschema with id \'{0}\' was not found', unresolvedSubschemaId));
642+
}
643+
return new ResolvedSchema(schema, resolveErrors);
644+
});
547645
}
548646
private getSchemaFromProperty(resource: string, document: Parser.JSONDocument): string | undefined {
549647
if (document.root?.type === 'object') {
@@ -618,7 +716,8 @@ export class JSONSchemaService implements IJSONSchemaService {
618716
public getMatchingSchemas(document: TextDocument, jsonDocument: Parser.JSONDocument, schema?: JSONSchema): Thenable<MatchingSchema[]> {
619717
if (schema) {
620718
const id = schema.id || ('schemaservice://untitled/matchingSchemas/' + idCounter++);
621-
return this.resolveSchemaContent(new UnresolvedSchema(schema), id, new Set()).then(resolvedSchema => {
719+
const handle = this.addSchemaHandle(id, schema);
720+
return handle.getResolvedSchema().then(resolvedSchema => {
622721
return jsonDocument.getMatchingSchemas(resolvedSchema.schema).filter(s => !s.inverted);
623722
});
624723
}

src/services/jsonValidation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,8 @@ export class JSONValidation {
103103

104104
if (schema) {
105105
const id = schema.id || ('schemaservice://untitled/' + idCounter++);
106-
return this.jsonSchemaService.resolveSchemaContent(new UnresolvedSchema(schema), id, new Set()).then(resolvedSchema => {
106+
const handle = this.jsonSchemaService.registerExternalSchema(id, [], schema);
107+
return handle.getResolvedSchema().then(resolvedSchema => {
107108
return getDiagnostics(resolvedSchema);
108109
});
109110
}

0 commit comments

Comments
 (0)