Skip to content

Commit c2874e5

Browse files
committed
feat: Add comprehensive test support for JSON Schema 2019-09 and 2020-12
1 parent 3ae9936 commit c2874e5

File tree

8 files changed

+1827
-9
lines changed

8 files changed

+1827
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# YAML Language Server
44

5-
Supports JSON Schema 7 and below.
5+
Supports JSON Schema draft-04, draft-07, 2019-09, and 2020-12.
66
Starting from `1.0.0` the language server uses [eemeli/yaml](https://github.com/eemeli/yaml) as the new YAML parser, which strictly enforces the specified YAML spec version. Default YAML spec version is `1.2`, it can be changed with `yaml.yamlVersion` setting.
77

88
## Features

src/languageservice/services/yamlSchemaService.ts

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ import { parse } from 'yaml';
2929
import * as Json from 'jsonc-parser';
3030
import { getSchemaTitle } from '../utils/schemaUtils';
3131

32+
import * as Draft04 from '@hyperjump/json-schema/draft-04';
33+
import * as Draft07 from '@hyperjump/json-schema/draft-07';
34+
import * as Draft201909 from '@hyperjump/json-schema/draft-2019-09';
35+
import * as Draft202012 from '@hyperjump/json-schema/draft-2020-12';
36+
37+
type SupportedSchemaVersions = '2020-12' | '2019-09' | 'draft-07' | 'draft-04';
3238
export declare type CustomSchemaProvider = (uri: string) => Promise<string | string[]>;
3339

3440
export enum MODIFICATION_ACTIONS {
@@ -154,12 +160,34 @@ export class YAMLSchemaService extends JSONSchemaService {
154160
let schema: JSONSchema = schemaToResolve.schema;
155161
const contextService = this.contextService;
156162

157-
// Basic schema validation - check if schema is a valid object
158163
if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
159164
const invalidSchemaType = Array.isArray(schema) ? 'array' : typeof schema;
160165
resolveErrors.push(
161166
`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\nWrong schema: "${invalidSchemaType}", it MUST be an Object or Boolean`
162167
);
168+
} else {
169+
try {
170+
const schemaVersion = this.detectSchemaVersion(schema);
171+
const validator = this.getValidatorForVersion(schemaVersion);
172+
const metaSchemaUrl = this.getSchemaMetaSchema(schemaVersion);
173+
174+
// Validate the schema against its meta-schema using the URL directly
175+
const result = await validator.validate(metaSchemaUrl, schema, 'BASIC');
176+
if (!result.valid && result.errors) {
177+
const errs: string[] = [];
178+
for (const error of result.errors) {
179+
if (error.instanceLocation && error.keyword) {
180+
errs.push(`${error.instanceLocation}: ${this.extractKeywordName(error.keyword)} constraint violation`);
181+
}
182+
}
183+
if (errs.length > 0) {
184+
resolveErrors.push(`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\n${errs.join('\n')}`);
185+
}
186+
}
187+
} catch (error) {
188+
// If meta-schema validation fails, log but don't block schema loading
189+
console.error(`Failed to validate schema meta-schema: ${error.message}`);
190+
}
163191
}
164192

165193
const findSection = (schema: JSONSchema, path: string): JSONSchema => {
@@ -268,16 +296,16 @@ export class YAMLSchemaService extends JSONSchemaService {
268296
const seenRefs = new Set();
269297
while (next.$ref) {
270298
const ref = decodeURIComponent(next.$ref);
271-
const segments = ref.split('#', 2);
272299
//return back removed $ref. We lost info about referenced type without it.
300+
const segments = ref.split('#', 2);
273301
next._$ref = next.$ref;
274302
delete next.$ref;
275303
if (segments[0].length > 0) {
276304
openPromises.push(resolveExternalLink(next, segments[0], segments[1], parentSchemaURL, parentSchemaDependencies));
277305
return;
278306
} else {
279307
if (!seenRefs.has(ref)) {
280-
merge(next, parentSchema, parentSchemaURL, segments[1]); // can set next.$ref again, use seenRefs to avoid circle
308+
merge(next, parentSchema, parentSchemaURL, segments[1]);
281309
seenRefs.add(ref);
282310
}
283311
}
@@ -475,7 +503,6 @@ export class YAMLSchemaService extends JSONSchemaService {
475503
if (prio > highestPrio) {
476504
highestPrio = prio;
477505
}
478-
479506
// Build up a mapping of priority to schemas so that we can easily get the highest priority schemas easier
480507
let currPriorityArray = priorityMapping.get(prio);
481508
if (currPriorityArray) {
@@ -620,10 +647,10 @@ export class YAMLSchemaService extends JSONSchemaService {
620647

621648
loadSchema(schemaUri: string): Promise<UnresolvedSchema> {
622649
const requestService = this.requestService;
650+
// If json-language-server failed to parse the schema, attempt to parse it as YAML instead.
651+
// If the YAML file starts with %YAML 1.x or contains a comment with a number the schema will
652+
// contain a number instead of being undefined, so we need to check for that too.
623653
return super.loadSchema(schemaUri).then(async (unresolvedJsonSchema: UnresolvedSchema) => {
624-
// If json-language-server failed to parse the schema, attempt to parse it as YAML instead.
625-
// If the YAML file starts with %YAML 1.x or contains a comment with a number the schema will
626-
// contain a number instead of being undefined, so we need to check for that too.
627654
if (
628655
unresolvedJsonSchema.errors &&
629656
(unresolvedJsonSchema.schema === undefined || typeof unresolvedJsonSchema.schema === 'number')
@@ -656,9 +683,9 @@ export class YAMLSchemaService extends JSONSchemaService {
656683
// eslint-disable-next-line @typescript-eslint/no-explicit-any
657684
(error: any) => {
658685
let errorMessage = error.toString();
686+
// more concise error message, URL and context are attached by caller anyways
659687
const errorSplit = error.toString().split('Error: ');
660688
if (errorSplit.length > 1) {
661-
// more concise error message, URL and context are attached by caller anyways
662689
errorMessage = errorSplit[1];
663690
}
664691
return new UnresolvedSchema(<JSONSchema>{}, [errorMessage]);
@@ -725,6 +752,79 @@ export class YAMLSchemaService extends JSONSchemaService {
725752
onResourceChange(uri: string): boolean {
726753
return super.onResourceChange(uri);
727754
}
755+
756+
/**
757+
* Detect the JSON Schema version from the $schema property
758+
*/
759+
private detectSchemaVersion(schema: JSONSchema): SupportedSchemaVersions {
760+
const schemaProperty = schema.$schema;
761+
if (typeof schemaProperty === 'string') {
762+
if (schemaProperty.includes('2020-12')) {
763+
return '2020-12';
764+
} else if (schemaProperty.includes('2019-09')) {
765+
return '2019-09';
766+
} else if (schemaProperty.includes('draft-07') || schemaProperty.includes('draft/7')) {
767+
return 'draft-07';
768+
} else if (schemaProperty.includes('draft-04') || schemaProperty.includes('draft/4')) {
769+
return 'draft-04';
770+
}
771+
}
772+
return 'draft-07';
773+
}
774+
775+
/**
776+
* Get the appropriate validator module for a schema version
777+
*/
778+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
779+
private getValidatorForVersion(version: SupportedSchemaVersions): any {
780+
switch (version) {
781+
case '2020-12':
782+
return Draft202012;
783+
case '2019-09':
784+
return Draft201909;
785+
case 'draft-07':
786+
return Draft07;
787+
case 'draft-04':
788+
default:
789+
return Draft04;
790+
}
791+
}
792+
793+
/**
794+
* Get the correct schema meta URI for a given version
795+
*/
796+
private getSchemaMetaSchema(version: SupportedSchemaVersions): string {
797+
switch (version) {
798+
case '2020-12':
799+
return 'https://json-schema.org/draft/2020-12/schema';
800+
case '2019-09':
801+
return 'https://json-schema.org/draft/2019-09/schema';
802+
case 'draft-07':
803+
return 'http://json-schema.org/draft-07/schema';
804+
case 'draft-04':
805+
return 'http://json-schema.org/draft-04/schema';
806+
default:
807+
return 'http://json-schema.org/draft-07/schema';
808+
}
809+
}
810+
811+
/**
812+
* Extract a human-readable keyword name from a keyword URI
813+
*/
814+
private extractKeywordName(keywordUri: string): string {
815+
if (typeof keywordUri !== 'string') {
816+
return 'validation';
817+
}
818+
819+
const parts = keywordUri.split('/');
820+
const lastPart = parts[parts.length - 1];
821+
822+
if (lastPart === 'validate') {
823+
return 'schema validation';
824+
}
825+
826+
return lastPart || 'validation';
827+
}
728828
}
729829

730830
function toDisplayString(url: string): string {

src/languageservice/utils/schemaUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export function getSchemaTitle(schema: JSONSchema, url: string): string {
4949
if (!path.extname(uri.fsPath)) {
5050
baseName += '.json';
5151
}
52+
53+
// Handle null or undefined schemas
54+
if (!schema || typeof schema !== 'object') {
55+
return baseName;
56+
}
57+
5258
if (Object.getOwnPropertyDescriptor(schema, 'name')) {
5359
return Object.getOwnPropertyDescriptor(schema, 'name').value + ` (${baseName})`;
5460
} else if (schema.title) {

test/fixtures/schema-2019-09.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2019-09/schema",
3+
"$id": "https://example.com/person.schema.json",
4+
"title": "Person Schema 2019-09",
5+
"type": "object",
6+
"properties": {
7+
"firstName": {
8+
"type": "string",
9+
"description": "The person's first name."
10+
},
11+
"lastName": {
12+
"type": "string",
13+
"description": "The person's last name."
14+
},
15+
"age": {
16+
"description": "Age in years which must be equal to or greater than zero.",
17+
"type": "integer",
18+
"minimum": 0
19+
},
20+
"email": {
21+
"type": "string",
22+
"format": "email"
23+
},
24+
"address": {
25+
"$ref": "#/$defs/address"
26+
}
27+
},
28+
"required": [
29+
"firstName",
30+
"lastName"
31+
],
32+
"$defs": {
33+
"address": {
34+
"type": "object",
35+
"properties": {
36+
"street": {
37+
"type": "string"
38+
},
39+
"city": {
40+
"type": "string"
41+
},
42+
"country": {
43+
"type": "string"
44+
}
45+
},
46+
"required": [
47+
"street",
48+
"city",
49+
"country"
50+
]
51+
}
52+
},
53+
"unevaluatedProperties": false
54+
}

test/fixtures/schema-2020-12.json

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://example.com/product.schema.json",
4+
"title": "Product Schema 2020-12",
5+
"type": "object",
6+
"properties": {
7+
"productId": {
8+
"type": "integer",
9+
"description": "The unique identifier for a product"
10+
},
11+
"productName": {
12+
"type": "string",
13+
"description": "Name of the product"
14+
},
15+
"price": {
16+
"type": "number",
17+
"minimum": 0,
18+
"exclusiveMinimum": true
19+
},
20+
"tags": {
21+
"type": "array",
22+
"items": {
23+
"type": "string"
24+
},
25+
"minItems": 1,
26+
"uniqueItems": true
27+
},
28+
"dimensions": {
29+
"$ref": "#/$defs/dimensions"
30+
},
31+
"category": {
32+
"enum": [
33+
"electronics",
34+
"clothing",
35+
"books",
36+
"home"
37+
]
38+
}
39+
},
40+
"required": [
41+
"productId",
42+
"productName",
43+
"price"
44+
],
45+
"$defs": {
46+
"dimensions": {
47+
"type": "object",
48+
"properties": {
49+
"length": {
50+
"type": "number"
51+
},
52+
"width": {
53+
"type": "number"
54+
},
55+
"height": {
56+
"type": "number"
57+
}
58+
},
59+
"required": [
60+
"length",
61+
"width",
62+
"height"
63+
]
64+
}
65+
},
66+
"unevaluatedProperties": false
67+
}

test/fixtures/schema-draft-04.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-04/schema#",
3+
"id": "https://example.com/product-draft-04.schema.json",
4+
"title": "Product Schema Draft 04",
5+
"type": "object",
6+
"properties": {
7+
"productId": {
8+
"type": "integer",
9+
"description": "The unique identifier for a product"
10+
},
11+
"productName": {
12+
"type": "string",
13+
"description": "Name of the product"
14+
},
15+
"price": {
16+
"type": "number",
17+
"minimum": 0,
18+
"exclusiveMinimum": true,
19+
"description": "The price of the product"
20+
},
21+
"tags": {
22+
"type": "array",
23+
"items": {
24+
"type": "string"
25+
},
26+
"minItems": 1,
27+
"uniqueItems": true
28+
},
29+
"dimensions": {
30+
"$ref": "#/definitions/dimensions"
31+
}
32+
},
33+
"required": [
34+
"productId",
35+
"productName",
36+
"price"
37+
],
38+
"definitions": {
39+
"dimensions": {
40+
"type": "object",
41+
"properties": {
42+
"length": {
43+
"type": "number"
44+
},
45+
"width": {
46+
"type": "number"
47+
},
48+
"height": {
49+
"type": "number"
50+
}
51+
},
52+
"required": [
53+
"length",
54+
"width",
55+
"height"
56+
]
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)