Skip to content

Commit bfa433e

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

File tree

8 files changed

+1821
-2
lines changed

8 files changed

+1821
-2
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: 102 additions & 1 deletion
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 => {
@@ -725,6 +753,79 @@ export class YAMLSchemaService extends JSONSchemaService {
725753
onResourceChange(uri: string): boolean {
726754
return super.onResourceChange(uri);
727755
}
756+
757+
/**
758+
* Detect the JSON Schema version from the $schema property
759+
*/
760+
private detectSchemaVersion(schema: JSONSchema): SupportedSchemaVersions {
761+
const schemaProperty = schema.$schema;
762+
if (typeof schemaProperty === 'string') {
763+
if (schemaProperty.includes('2020-12')) {
764+
return '2020-12';
765+
} else if (schemaProperty.includes('2019-09')) {
766+
return '2019-09';
767+
} else if (schemaProperty.includes('draft-07') || schemaProperty.includes('draft/7')) {
768+
return 'draft-07';
769+
} else if (schemaProperty.includes('draft-04') || schemaProperty.includes('draft/4')) {
770+
return 'draft-04';
771+
}
772+
}
773+
return 'draft-07';
774+
}
775+
776+
/**
777+
* Get the appropriate validator module for a schema version
778+
*/
779+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
780+
private getValidatorForVersion(version: SupportedSchemaVersions): any {
781+
switch (version) {
782+
case '2020-12':
783+
return Draft202012;
784+
case '2019-09':
785+
return Draft201909;
786+
case 'draft-07':
787+
return Draft07;
788+
case 'draft-04':
789+
default:
790+
return Draft04;
791+
}
792+
}
793+
794+
/**
795+
* Get the correct schema meta URI for a given version
796+
*/
797+
private getSchemaMetaSchema(version: SupportedSchemaVersions): string {
798+
switch (version) {
799+
case '2020-12':
800+
return 'https://json-schema.org/draft/2020-12/schema';
801+
case '2019-09':
802+
return 'https://json-schema.org/draft/2019-09/schema';
803+
case 'draft-07':
804+
return 'http://json-schema.org/draft-07/schema';
805+
case 'draft-04':
806+
return 'http://json-schema.org/draft-04/schema';
807+
default:
808+
return 'http://json-schema.org/draft-07/schema';
809+
}
810+
}
811+
812+
/**
813+
* Extract a human-readable keyword name from a keyword URI
814+
*/
815+
private extractKeywordName(keywordUri: string): string {
816+
if (typeof keywordUri !== 'string') {
817+
return 'validation';
818+
}
819+
820+
const parts = keywordUri.split('/');
821+
const lastPart = parts[parts.length - 1];
822+
823+
if (lastPart === 'validate') {
824+
return 'schema validation';
825+
}
826+
827+
return lastPart || 'validation';
828+
}
728829
}
729830

730831
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+
}

test/fixtures/schema-draft-07.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-07/schema",
3+
"$id": "https://example.com/person-draft-07.schema.json",
4+
"title": "Person Schema Draft 07",
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+
"description": "The person's email address."
24+
},
25+
"address": {
26+
"$ref": "#/definitions/address"
27+
}
28+
},
29+
"required": [
30+
"firstName",
31+
"lastName"
32+
],
33+
"definitions": {
34+
"address": {
35+
"type": "object",
36+
"properties": {
37+
"street": {
38+
"type": "string"
39+
},
40+
"city": {
41+
"type": "string"
42+
},
43+
"country": {
44+
"type": "string"
45+
}
46+
},
47+
"required": [
48+
"street",
49+
"city",
50+
"country"
51+
]
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)