diff --git a/README.md b/README.md index 3b647a78d..69e86c490 100755 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # YAML Language Server -Supports JSON Schema 7 and below. +Supports JSON Schema draft-04, draft-07, 2019-09, and 2020-12. 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. ## Features diff --git a/package-lock.json b/package-lock.json index 90e8ada95..87db1183f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,8 @@ "version": "1.19.0", "license": "MIT", "dependencies": { + "@hyperjump/json-schema": "^1.16.2", "@vscode/l10n": "^0.0.18", - "ajv": "^8.17.1", - "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", @@ -482,6 +481,91 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@hyperjump/browser": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz", + "integrity": "sha512-Le5XZUjnVqVjkgLYv6yyWgALat/0HpB1XaCPuCZ+GCFki9NvXloSZITIJ0H+wRW7mb9At1SxvohKBbNQbrr/cw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.5", + "just-curry-it": "^5.3.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-pointer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.1.tgz", + "integrity": "sha512-M0T3s7TC2JepoWPMZQn1W6eYhFh06OXwpMqL+8c5wMVpvnCKNsPgpu9u7WyCI03xVQti8JAeAy4RzUa6SYlJLA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.16.2.tgz", + "integrity": "sha512-MJNvaEFc79+h5rvBPgAJK4OHEUr0RqsKcLC5rc3V9FEsJyQAjnP910deRFoZCE068kX/NrAPPhunMgUMwonPtg==", + "license": "MIT", + "dependencies": { + "@hyperjump/json-pointer": "^1.1.0", + "@hyperjump/pact": "^1.2.0", + "@hyperjump/uri": "^1.2.0", + "content-type": "^1.0.4", + "json-stringify-deterministic": "^1.0.12", + "just-curry-it": "^5.3.0", + "uuid": "^9.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + }, + "peerDependencies": { + "@hyperjump/browser": "^1.1.0" + } + }, + "node_modules/@hyperjump/json-schema/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@hyperjump/pact": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-1.4.0.tgz", + "integrity": "sha512-01Q7VY6BcAkp9W31Fv+ciiZycxZHGlR2N6ba9BifgyclHYHdbaZgITo0U6QMhYRlem4k8pf8J31/tApxvqAz8A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/uri": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hyperjump/uri/-/uri-1.3.1.tgz", + "integrity": "sha512-2ecKymxf6prQMgrNpAvlx4RhsuM5+PFT6oh6uUTZdv5qmBv0RZvxv8LJ7oR30ZxGhdPdZAl4We/1NFc0nqHeAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1202,36 +1286,6 @@ "node": ">=8" } }, - "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-draft-04": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", - "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", - "license": "MIT", - "peerDependencies": { - "ajv": "^8.5.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1809,6 +1863,15 @@ "dev": true, "license": "MIT" }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -2641,6 +2704,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -2694,22 +2758,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4072,12 +4120,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4085,6 +4127,15 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-deterministic": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/json-stringify-deterministic/-/json-stringify-deterministic-1.0.12.tgz", + "integrity": "sha512-q3PN0lbUdv0pmurkBNdJH3pfFvOTL/Zp0lquqpvcjfKzt6Y0j49EPHAmVHCAS4Ceq/Y+PejWTzyiVpoY71+D6g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4104,6 +4155,12 @@ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, + "node_modules/just-curry-it": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/just-curry-it/-/just-curry-it-5.3.0.tgz", + "integrity": "sha512-silMIRiFjUWlfaDhkgSzpuAyQ6EX/o09Eu8ZBfmFwQMbax7+LQzeIU2CBrICT6Ne4l86ITCGvUCBpCubWYy0Yw==", + "license": "MIT" + }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -5313,15 +5370,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", diff --git a/package.json b/package.json index 37af0ebdf..faf8ac725 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,8 @@ "url": "git+https://github.com/redhat-developer/yaml-language-server.git" }, "dependencies": { + "@hyperjump/json-schema": "^1.16.2", "@vscode/l10n": "^0.0.18", - "ajv": "^8.17.1", - "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", @@ -99,4 +98,4 @@ ], "all": true } -} +} \ No newline at end of file diff --git a/src/languageservice/services/yamlSchemaService.ts b/src/languageservice/services/yamlSchemaService.ts index a9ad24408..83cad71ff 100644 --- a/src/languageservice/services/yamlSchemaService.ts +++ b/src/languageservice/services/yamlSchemaService.ts @@ -27,23 +27,14 @@ import { SchemaVersions } from '../yamlTypes'; import { parse } from 'yaml'; import * as Json from 'jsonc-parser'; -import Ajv, { DefinedError } from 'ajv'; -import Ajv4 from 'ajv-draft-04'; import { getSchemaTitle } from '../utils/schemaUtils'; -const ajv = new Ajv(); -const ajv4 = new Ajv4(); - -// load JSON Schema 07 def to validate loaded schemas -// eslint-disable-next-line @typescript-eslint/no-var-requires -const jsonSchema07 = require('ajv/dist/refs/json-schema-draft-07.json'); -const schema07Validator = ajv.compile(jsonSchema07); - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const jsonSchema04 = require('ajv-draft-04/dist/refs/json-schema-draft-04.json'); -const schema04Validator = ajv4.compile(jsonSchema04); -const SCHEMA_04_URI_WITH_HTTPS = ajv4.defaultMeta().replace('http://', 'https://'); +import * as Draft04 from '@hyperjump/json-schema/draft-04'; +import * as Draft07 from '@hyperjump/json-schema/draft-07'; +import * as Draft201909 from '@hyperjump/json-schema/draft-2019-09'; +import * as Draft202012 from '@hyperjump/json-schema/draft-2020-12'; +type SupportedSchemaVersion = '2020-12' | '2019-09' | 'draft-07' | 'draft-04'; export declare type CustomSchemaProvider = (uri: string) => Promise; export enum MODIFICATION_ACTIONS { @@ -169,16 +160,59 @@ export class YAMLSchemaService extends JSONSchemaService { let schema: JSONSchema = schemaToResolve.schema; const contextService = this.contextService; - const validator = - this.normalizeId(schema.$schema) === ajv4.defaultMeta() || this.normalizeId(schema.$schema) === SCHEMA_04_URI_WITH_HTTPS - ? schema04Validator - : schema07Validator; - if (!validator(schema)) { - const errs: string[] = []; - for (const err of validator.errors as DefinedError[]) { - errs.push(`${err.instancePath} : ${err.message}`); + // Validate schema type before processing + if (schema === null) { + resolveErrors.push(`Wrong schema: "null", it MUST be an Object or Boolean`); + return new ResolvedSchema({}, resolveErrors); + } + + if (Array.isArray(schema)) { + resolveErrors.push(`Wrong schema: "array", it MUST be an Object or Boolean`); + return new ResolvedSchema({}, resolveErrors); + } + + if (typeof schema === 'string') { + resolveErrors.push(`Wrong schema: "string", it MUST be an Object or Boolean`); + return new ResolvedSchema({}, resolveErrors); + } + + if (typeof schema === 'number') { + resolveErrors.push(`Wrong schema: "number", it MUST be an Object or Boolean`); + return new ResolvedSchema({}, resolveErrors); + } + + // Only proceed if schema is an object or boolean + if (typeof schema !== 'object' && typeof schema !== 'boolean') { + resolveErrors.push(`Wrong schema: "${typeof schema}", it MUST be an Object or Boolean`); + return new ResolvedSchema({}, resolveErrors); + } + + const schemaVersion = this.detectSchemaVersion(schema); + const validator = this.getValidatorForVersion(schemaVersion); + const metaSchemaUrl = this.getSchemaMetaSchema(schemaVersion); + + // Only validate object schemas against their meta-schema + // Boolean schemas (true/false) are valid by definition and don't need meta-schema validation + if (typeof schema === 'object' && schema !== null) { + try { + const result = await validator.validate(metaSchemaUrl, schema, 'BASIC'); + if (!result.valid && result.errors) { + const errs: string[] = []; + for (const error of result.errors) { + if (error.instanceLocation && error.keyword) { + errs.push(`${error.instanceLocation}: ${this.extractKeywordName(error.keyword)} constraint violation`); + } + } + if (errs.length > 0) { + resolveErrors.push(`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\n${errs.join('\n')}`); + } + } + } catch (validationError) { + // If validation fails due to incompatible data, add a generic error + resolveErrors.push( + `Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' validation failed: ${validationError.message}` + ); } - resolveErrors.push(`Schema '${getSchemaTitle(schemaToResolve.schema, schemaURL)}' is not valid:\n${errs.join('\n')}`); } const findSection = (schema: JSONSchema, path: string): JSONSchema => { @@ -744,6 +778,82 @@ export class YAMLSchemaService extends JSONSchemaService { onResourceChange(uri: string): boolean { return super.onResourceChange(uri); } + + /** + * Detect the JSON Schema version from the $schema property + */ + private detectSchemaVersion(schema: JSONSchema): SupportedSchemaVersion { + if (!schema || typeof schema !== 'object') { + return 'draft-07'; + } + const schemaProperty = schema.$schema; + if (typeof schemaProperty === 'string') { + if (schemaProperty.includes('2020-12')) { + return '2020-12'; + } else if (schemaProperty.includes('2019-09')) { + return '2019-09'; + } else if (schemaProperty.includes('draft-07') || schemaProperty.includes('draft/7')) { + return 'draft-07'; + } else if (schemaProperty.includes('draft-04') || schemaProperty.includes('draft/4')) { + return 'draft-04'; + } + } + return 'draft-07'; + } + + /** + * Get the appropriate validator module for a schema version + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getValidatorForVersion(version: SupportedSchemaVersion): any { + switch (version) { + case '2020-12': + return Draft202012; + case '2019-09': + return Draft201909; + case 'draft-07': + return Draft07; + case 'draft-04': + default: + return Draft04; + } + } + + /** + * Get the correct schema meta URI for a given version + */ + private getSchemaMetaSchema(version: SupportedSchemaVersion): string { + switch (version) { + case '2020-12': + return 'https://json-schema.org/draft/2020-12/schema'; + case '2019-09': + return 'https://json-schema.org/draft/2019-09/schema'; + case 'draft-07': + return 'http://json-schema.org/draft-07/schema'; + case 'draft-04': + return 'http://json-schema.org/draft-04/schema'; + default: + return 'http://json-schema.org/draft-07/schema'; + } + } + + /** + * Extract a human-readable keyword name from a keyword URI + */ + private extractKeywordName(keywordUri: string): string { + if (typeof keywordUri !== 'string') { + return 'validation'; + } + + const parts = keywordUri.split('/'); + const lastPart = parts[parts.length - 1]; + + if (lastPart === 'validate') { + return 'schema validation'; + } + + return lastPart || 'validation'; + } } function toDisplayString(url: string): string { diff --git a/src/languageservice/utils/schemaUtils.ts b/src/languageservice/utils/schemaUtils.ts index bc763fb48..28b8c98aa 100644 --- a/src/languageservice/utils/schemaUtils.ts +++ b/src/languageservice/utils/schemaUtils.ts @@ -49,6 +49,12 @@ export function getSchemaTitle(schema: JSONSchema, url: string): string { if (!path.extname(uri.fsPath)) { baseName += '.json'; } + + // Handle null or undefined schemas + if (!schema || typeof schema !== 'object') { + return baseName; + } + if (Object.getOwnPropertyDescriptor(schema, 'name')) { return Object.getOwnPropertyDescriptor(schema, 'name').value + ` (${baseName})`; } else if (schema.title) { diff --git a/test/fixtures/schema-2019-09.json b/test/fixtures/schema-2019-09.json new file mode 100644 index 000000000..e9361327f --- /dev/null +++ b/test/fixtures/schema-2019-09.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://example.com/person.schema.json", + "title": "Person Schema 2019-09", + "type": "object", + "properties": { + "firstName": { + "type": "string", + "description": "The person's first name." + }, + "lastName": { + "type": "string", + "description": "The person's last name." + }, + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0 + }, + "email": { + "type": "string", + "format": "email" + }, + "address": { + "$ref": "#/$defs/address" + } + }, + "required": [ + "firstName", + "lastName" + ], + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country" + ] + } + }, + "unevaluatedProperties": false +} \ No newline at end of file diff --git a/test/fixtures/schema-2020-12.json b/test/fixtures/schema-2020-12.json new file mode 100644 index 000000000..44d1faf27 --- /dev/null +++ b/test/fixtures/schema-2020-12.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://example.com/product.schema.json", + "title": "Product Schema 2020-12", + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "The unique identifier for a product" + }, + "productName": { + "type": "string", + "description": "Name of the product" + }, + "price": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "dimensions": { + "$ref": "#/$defs/dimensions" + }, + "category": { + "enum": [ + "electronics", + "clothing", + "books", + "home" + ] + } + }, + "required": [ + "productId", + "productName", + "price" + ], + "$defs": { + "dimensions": { + "type": "object", + "properties": { + "length": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "length", + "width", + "height" + ] + } + }, + "unevaluatedProperties": false +} \ No newline at end of file diff --git a/test/fixtures/schema-draft-04.json b/test/fixtures/schema-draft-04.json new file mode 100644 index 000000000..3004a4850 --- /dev/null +++ b/test/fixtures/schema-draft-04.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "https://example.com/product-draft-04.schema.json", + "title": "Product Schema Draft 04", + "type": "object", + "properties": { + "productId": { + "type": "integer", + "description": "The unique identifier for a product" + }, + "productName": { + "type": "string", + "description": "Name of the product" + }, + "price": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "description": "The price of the product" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "dimensions": { + "$ref": "#/definitions/dimensions" + } + }, + "required": [ + "productId", + "productName", + "price" + ], + "definitions": { + "dimensions": { + "type": "object", + "properties": { + "length": { + "type": "number" + }, + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "length", + "width", + "height" + ] + } + } +} \ No newline at end of file diff --git a/test/fixtures/schema-draft-07.json b/test/fixtures/schema-draft-07.json new file mode 100644 index 000000000..a858af0ff --- /dev/null +++ b/test/fixtures/schema-draft-07.json @@ -0,0 +1,54 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema", + "$id": "https://example.com/person-draft-07.schema.json", + "title": "Person Schema Draft 07", + "type": "object", + "properties": { + "firstName": { + "type": "string", + "description": "The person's first name." + }, + "lastName": { + "type": "string", + "description": "The person's last name." + }, + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0 + }, + "email": { + "type": "string", + "format": "email", + "description": "The person's email address." + }, + "address": { + "$ref": "#/definitions/address" + } + }, + "required": [ + "firstName", + "lastName" + ], + "definitions": { + "address": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "country": { + "type": "string" + } + }, + "required": [ + "street", + "city", + "country" + ] + } + } +} \ No newline at end of file diff --git a/test/schemaValidation.test.ts b/test/schemaValidation.test.ts index edb73810f..1f6ff8e3c 100644 --- a/test/schemaValidation.test.ts +++ b/test/schemaValidation.test.ts @@ -1868,7 +1868,7 @@ obj: const content = `foo: bar`; const result = await parseSetup(content); expect(result).to.have.length(1); - expect(result[0].message).to.include("Schema 'default_schema_id.yaml' is not valid"); + expect(result[0].message).to.include('Wrong schema: "string", it MUST be an Object or Boolean'); expect(telemetry.messages).to.be.empty; }); diff --git a/test/yamlSchemaService.test.ts b/test/yamlSchemaService.test.ts index a98fb8791..ab2d7b7af 100644 --- a/test/yamlSchemaService.test.ts +++ b/test/yamlSchemaService.test.ts @@ -2,12 +2,16 @@ * Copyright (c) Red Hat. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { readFile } from 'fs/promises'; import * as sinon from 'sinon'; import * as chai from 'chai'; import * as sinonChai from 'sinon-chai'; import * as path from 'path'; import * as SchemaService from '../src/languageservice/services/yamlSchemaService'; import { parse } from '../src/languageservice/parser/yamlParser07'; +import { YAMLSchemaService } from '../src/languageservice/services/yamlSchemaService'; +import { UnresolvedSchema, ResolvedSchema } from 'vscode-json-languageservice/lib/umd/services/jsonSchemaService'; +import { JSONSchema } from '../src/languageservice/jsonSchema'; const expect = chai.expect; chai.use(sinonChai); @@ -67,6 +71,52 @@ describe('YAML Schema Service', () => { expect(schema.schema.type).eqls('array'); }); + it('should handle schemas that use draft-04', async () => { + const content = `openapi: "3.0.0" +info: + version: 1.0.0 + title: Minimal ping API server +paths: + /ping: + get: + responses: + '200': + description: pet response + content: + application/json: + schema: + $ref: '#/components/schemas/Pong' +components: + schemas: + # base types + Pong: + type: object + required: + - ping + properties: + ping: + type: string + example: pong`; + + const yamlDock = parse(content); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const openapiV3Schema = await readFile(path.join(__dirname, './fixtures/sample-openapiv3.0.0-schema.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(openapiV3Schema); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.registerCustomSchemaProvider(() => { + return new Promise((resolve) => { + resolve('http://fakeschema.faketld'); + }); + }); + + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + expect(requestServiceMock).calledWithExactly('http://fakeschema.faketld'); + expect(schema).to.not.be.null; + }); + it('should handle url with fragments when root object is schema', async () => { const content = `# yaml-language-server: $schema=https://json-schema.org/draft-07/schema#/definitions/schemaArray`; const yamlDock = parse(content); @@ -141,4 +191,1479 @@ describe('YAML Schema Service', () => { expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft-07/schema#'); }); }); + + describe('JSON Schema 2019-09 support', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should handle inline schema with 2019-09 meta-schema', () => { + const documentContent = `# yaml-language-server: $schema=https://json-schema.org/draft/2019-09/schema\n`; + const content = `${documentContent}\n---\nfirstName: John\nlastName: Doe`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft/2019-09/schema'); + }); + + it('should load and validate against 2019-09 schema', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2019-09.json +firstName: John +lastName: Doe +age: 30 +email: john.doe@example.com +address: + street: 123 Main St + city: Anytown + country: USA`; + + const yamlDock = parse(content); + const schema2019 = await readFile(path.join(__dirname, './fixtures/schema-2019-09.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2019); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnce; + expect(schema).to.not.be.null; + expect(schema.schema.title).to.equal('Person Schema 2019-09'); + expect(schema.schema.$schema).to.equal('https://json-schema.org/draft/2019-09/schema'); + }); + + it('should handle $defs keyword from 2019-09', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2019-09.json +firstName: John +lastName: Doe +address: + street: 123 Main St + city: Anytown + country: USA`; + + const yamlDock = parse(content); + const schema2019 = await readFile(path.join(__dirname, './fixtures/schema-2019-09.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2019); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema.schema.$defs).to.exist; + expect(schema.schema.$defs.address).to.exist; + expect(schema.schema.$defs.address.type).to.equal('object'); + }); + + it('should handle unevaluatedProperties keyword from 2019-09', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2019-09.json +firstName: John +lastName: Doe +extraProperty: shouldBeInvalid`; + + const yamlDock = parse(content); + const schema2019 = await readFile(path.join(__dirname, './fixtures/schema-2019-09.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2019); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema.schema.unevaluatedProperties).to.equal(false); + }); + }); + + describe('JSON Schema 2020-12 support', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should handle inline schema with 2020-12 meta-schema', () => { + const documentContent = `# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema\n`; + const content = `${documentContent}\n---\nproductId: 123\nproductName: Widget`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft/2020-12/schema'); + }); + + it('should load and validate against 2020-12 schema', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2020-12.json +productId: 123 +productName: Super Widget +price: 29.99 +tags: + - electronics + - gadget +dimensions: + length: 10.5 + width: 5.2 + height: 2.1 +category: electronics`; + + const yamlDock = parse(content); + const schema2020 = await readFile(path.join(__dirname, './fixtures/schema-2020-12.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2020); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnce; + expect(schema).to.not.be.null; + expect(schema.schema.title).to.equal('Product Schema 2020-12'); + expect(schema.schema.$schema).to.equal('https://json-schema.org/draft/2020-12/schema'); + }); + + it('should handle $defs keyword from 2020-12', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2020-12.json +productId: 123 +productName: Widget +price: 29.99 +dimensions: + length: 10.5 + width: 5.2 + height: 2.1`; + + const yamlDock = parse(content); + const schema2020 = await readFile(path.join(__dirname, './fixtures/schema-2020-12.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2020); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema.schema.$defs).to.exist; + expect(schema.schema.$defs.dimensions).to.exist; + expect(schema.schema.$defs.dimensions.type).to.equal('object'); + }); + + it('should handle exclusiveMinimum as boolean in 2020-12', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2020-12.json +productId: 123 +productName: Widget +price: 0`; + + const yamlDock = parse(content); + const schema2020 = await readFile(path.join(__dirname, './fixtures/schema-2020-12.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2020); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema.schema.properties.price.exclusiveMinimum).to.equal(true); + expect(schema.schema.properties.price.minimum).to.equal(0); + }); + + it('should handle unevaluatedProperties keyword from 2020-12', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-2020-12.json +productId: 123 +productName: Widget +price: 29.99 +unknownProperty: shouldBeInvalid`; + + const yamlDock = parse(content); + const schema2020 = await readFile(path.join(__dirname, './fixtures/schema-2020-12.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2020); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema.schema.unevaluatedProperties).to.equal(false); + }); + }); + + describe('Mixed schema version support', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should handle mixed schema versions in the same document', async () => { + // This tests that the service can handle references between different schema versions + const content = `# yaml-language-server: $schema=./fixtures/schema-2020-12.json +productId: 123 +productName: Widget +price: 29.99`; + + const yamlDock = parse(content); + const schema2020 = await readFile(path.join(__dirname, './fixtures/schema-2020-12.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schema2020); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema).to.not.be.null; + expect(schema.schema.$schema).to.equal('https://json-schema.org/draft/2020-12/schema'); + }); + + it('should gracefully handle unknown schema versions', () => { + const documentContent = `# yaml-language-server: $schema=https://json-schema.org/draft/2030-01/schema\n`; + const content = `${documentContent}\n---\nfoo: bar`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft/2030-01/schema'); + }); + }); + + describe('JSON Schema Draft-07 support', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should handle inline schema with draft-07 meta-schema', () => { + const documentContent = `# yaml-language-server: $schema=https://json-schema.org/draft-07/schema\n`; + const content = `${documentContent}\n---\nfirstName: John\nlastName: Doe`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('https://json-schema.org/draft-07/schema'); + }); + + it('should handle draft-07 schema with http prefix', () => { + const documentContent = `# yaml-language-server: $schema=http://json-schema.org/draft-07/schema#\n`; + const content = `${documentContent}\n---\nfirstName: John\nlastName: Doe`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('http://json-schema.org/draft-07/schema#'); + }); + + it('should load and validate against draft-07 schema', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-draft-07.json +firstName: John +lastName: Doe +age: 30 +email: john.doe@example.com +address: + street: 123 Main St + city: Anytown + country: USA`; + + const yamlDock = parse(content); + const schemaDraft07 = await readFile(path.join(__dirname, './fixtures/schema-draft-07.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schemaDraft07); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnce; + expect(schema).to.not.be.null; + expect(schema.schema.title).to.equal('Person Schema Draft 07'); + expect(schema.schema.$schema).to.equal('https://json-schema.org/draft-07/schema'); + }); + + it('should handle definitions keyword from draft-07', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-draft-07.json +firstName: John +lastName: Doe +address: + street: 123 Main St + city: Anytown + country: USA`; + + const yamlDock = parse(content); + const schemaDraft07 = await readFile(path.join(__dirname, './fixtures/schema-draft-07.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schemaDraft07); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema).to.not.be.null; + expect(schema.schema.definitions).to.exist; + expect(schema.schema.definitions.address).to.exist; + }); + }); + + describe('JSON Schema Draft-04 support', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should handle inline schema with draft-04 meta-schema', () => { + const documentContent = `# yaml-language-server: $schema=http://json-schema.org/draft-04/schema#\n`; + const content = `${documentContent}\n---\nproductId: 1\nproductName: Widget`; + const yamlDock = parse(content); + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnceWith('http://json-schema.org/draft-04/schema#'); + }); + + it('should load and validate against draft-04 schema', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-draft-04.json +productId: 1 +productName: Widget +price: 10.99 +tags: + - gadget + - widget +dimensions: + length: 10 + width: 5 + height: 2`; + + const yamlDock = parse(content); + const schemaDraft04 = await readFile(path.join(__dirname, './fixtures/schema-draft-04.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schemaDraft04); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(requestServiceMock).calledOnce; + expect(schema).to.not.be.null; + expect(schema.schema.title).to.equal('Product Schema Draft 04'); + expect(schema.schema.$schema).to.equal('http://json-schema.org/draft-04/schema#'); + }); + + it('should handle definitions keyword from draft-04', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-draft-04.json +productId: 1 +productName: Widget +price: 10.99 +dimensions: + length: 10 + width: 5 + height: 2`; + + const yamlDock = parse(content); + const schemaDraft04 = await readFile(path.join(__dirname, './fixtures/schema-draft-04.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schemaDraft04); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema).to.not.be.null; + expect(schema.schema.definitions).to.exist; + expect(schema.schema.definitions.dimensions).to.exist; + }); + + it('should handle exclusiveMinimum as boolean in draft-04', async () => { + const content = `# yaml-language-server: $schema=./fixtures/schema-draft-04.json +productId: 1 +productName: Widget +price: 0`; + + const yamlDock = parse(content); + const schemaDraft04 = await readFile(path.join(__dirname, './fixtures/schema-draft-04.json'), { + encoding: 'utf-8', + }); + + requestServiceMock = sandbox.fake.resolves(schemaDraft04); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + const schema = await service.getSchemaForResource('', yamlDock.documents[0]); + + expect(schema).to.not.be.null; + expect(schema.schema.properties.price.exclusiveMinimum).to.be.true; + }); + }); + + describe('Schema version detection and default behavior', () => { + let requestServiceMock: sinon.SinonSpy; + + beforeEach(() => { + requestServiceMock = sandbox.fake.resolves(undefined); + }); + + it('should default to draft-07 when no schema version is specified', async () => { + const schemaContent = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + requestServiceMock = sandbox.fake.resolves(JSON.stringify(schemaContent)); + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // Access the private method via any to test it + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-07'); + }); + + it('should detect 2020-12 schema version', async () => { + const schemaContent = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('2020-12'); + }); + + it('should detect 2019-09 schema version', async () => { + const schemaContent = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('2019-09'); + }); + + it('should detect draft-07 schema version', async () => { + const schemaContent = { + $schema: 'https://json-schema.org/draft-07/schema', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-07'); + }); + + it('should detect draft-07 schema version with http prefix', async () => { + const schemaContent = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-07'); + }); + + it('should detect draft-04 schema version', async () => { + const schemaContent = { + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-04'); + }); + + it('should handle malformed schema version gracefully', async () => { + const schemaContent = { + $schema: 'https://some-invalid-schema-url.com/schema', + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-07'); // Should default to draft-07 + }); + + it('should handle schema without $schema property gracefully', async () => { + const schemaContent = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const service = new SchemaService.YAMLSchemaService(requestServiceMock); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const detectedVersion = (service as any).detectSchemaVersion(schemaContent); + + expect(detectedVersion).to.equal('draft-07'); // Should default to draft-07 + }); + }); + describe('resolveSchemaContent Tests', () => { + let yamlSchemaService: YAMLSchemaService; + let requestServiceMock: sinon.SinonStub; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + requestServiceMock = sandbox.stub(); + yamlSchemaService = new YAMLSchemaService(requestServiceMock); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('Basic Schema Type Validation', () => { + it('should accept valid object schema', async () => { + const validSchema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(validSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result).to.be.instanceOf(ResolvedSchema); + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.equal(validSchema); + }); + + it('should reject null schema', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unresolvedSchema = new UnresolvedSchema(null as any, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(1); + expect(result.errors[0]).to.include('it MUST be an Object or Boolean'); + }); + + it('should reject array schema', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unresolvedSchema = new UnresolvedSchema([] as any, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(1); + expect(result.errors[0]).to.include('Wrong schema: "array", it MUST be an Object or Boolean'); + }); + + it('should reject primitive schema', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unresolvedSchema = new UnresolvedSchema('invalid' as any, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(1); + expect(result.errors[0]).to.include('Wrong schema: "string", it MUST be an Object or Boolean'); + }); + + it('should reject number schema', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const unresolvedSchema = new UnresolvedSchema(42 as any, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(1); + expect(result.errors[0]).to.include('Wrong schema: "number", it MUST be an Object or Boolean'); + }); + }); + + describe('Schema Meta-Schema Validation', () => { + it('should validate draft-04 schema correctly', async () => { + const draft04Schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + age: { + type: 'number', + minimum: 0, + exclusiveMinimum: true, // Boolean in draft-04 + }, + }, + required: ['name'], + additionalProperties: false, + }; + + const unresolvedSchema = new UnresolvedSchema(draft04Schema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.include(draft04Schema); + }); + + it('should validate draft-07 schema correctly', async () => { + const draft07Schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + age: { + type: 'number', + minimum: 0, + }, + email: { + type: 'string', + format: 'email', + }, + }, + required: ['name'], + additionalProperties: false, + if: { properties: { age: { minimum: 18 } } }, + then: { properties: { canVote: { const: true } } }, + }; + + const unresolvedSchema = new UnresolvedSchema(draft07Schema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.include(draft07Schema); + }); + + it('should validate 2019-09 schema correctly', async () => { + const schema201909: JSONSchema = { + $schema: 'https://json-schema.org/draft/2019-09/schema', + type: 'object', + properties: { + name: { type: 'string' }, + }, + definitions: { + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schema201909, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.include(schema201909); + }); + + it('should validate 2020-12 schema correctly', async () => { + const schema202012: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + productId: { type: 'number' }, + price: { + type: 'number', + minimum: 0, + // In 2020-12, exclusiveMinimum should be a number or omitted with minimum + }, + }, + definitions: { + dimensions: { + type: 'object', + properties: { + length: { type: 'number' }, + width: { type: 'number' }, + height: { type: 'number' }, + }, + required: ['length', 'width', 'height'], + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schema202012, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.include(schema202012); + }); + + it('should default to draft-07 when no $schema is specified', async () => { + const schemaWithoutVersion: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithoutVersion, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.include(schemaWithoutVersion); + }); + + it('should handle invalid schema structure gracefully', async () => { + const invalidSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + name: 'invalid-property-definition' as any, // Should be an object + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + // Should not crash, but might have validation errors + expect(result).to.be.instanceOf(ResolvedSchema); + expect(result.schema).to.exist; + }); + + it('should handle meta-schema validation errors gracefully', async () => { + // Create a spy to simulate meta-schema validation failure + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(yamlSchemaService as any, 'detectSchemaVersion').returns('draft-07'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(yamlSchemaService as any, 'getValidatorForVersion').returns({ + validate: sandbox.stub().throws(new Error('Validation failed')), + }); + + const schema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + }; + + const unresolvedSchema = new UnresolvedSchema(schema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + // Should not crash and should complete resolution + expect(result).to.be.instanceOf(ResolvedSchema); + expect(result.schema).to.deep.include(schema); + }); + }); + + describe('Reference Resolution', () => { + it('should resolve internal $ref correctly', async () => { + const schemaWithInternalRef: JSONSchema = { + type: 'object', + properties: { + user: { $ref: '#/definitions/user' }, + address: { $ref: '#/definitions/address' }, + }, + definitions: { + user: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + }, + address: { + type: 'object', + properties: { + street: { type: 'string' }, + city: { type: 'string' }, + }, + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithInternalRef, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.properties.user).to.deep.include({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'number' }, + }, + _$ref: '#/definitions/user', + }); + }); + + it('should handle external $ref resolution setup', async () => { + // Mock the getOrAddSchemaHandle method + const mockSchemaHandle = { + getUnresolvedSchema: sandbox.stub().resolves({ + schema: { + type: 'string', + description: 'External schema', + }, + errors: [], + }), + dependencies: {}, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sandbox.stub(yamlSchemaService, 'getOrAddSchemaHandle').returns(mockSchemaHandle as any); + + const schemaWithExternalRef: JSONSchema = { + type: 'object', + properties: { + externalProp: { $ref: 'external://schema.json#/definitions/prop' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithExternalRef, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + // External refs may produce errors in test environment - that's expected + expect(result).to.be.instanceOf(ResolvedSchema); + expect(result.schema).to.exist; + }); + + it('should handle invalid $ref gracefully', async () => { + const schemaWithInvalidRef: JSONSchema = { + type: 'object', + properties: { + invalidProp: { $ref: '#/definitions/nonexistent' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithInvalidRef, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(1); + // Handle both localized and non-localized error messages + const errorMessage = result.errors[0]; + const isLocalizedMessage = + errorMessage.includes('/definitions/nonexistent') && errorMessage.includes('can not be resolved'); + const isKeyMessage = errorMessage.includes('json.schema.invalidref'); + + expect(isLocalizedMessage || isKeyMessage).to.be.true; + }); + + it('should handle circular $ref without infinite loop', async () => { + const schemaWithCircularRef: JSONSchema = { + type: 'object', + properties: { + self: { $ref: '#' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithCircularRef, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.exist; + // Should not hang or throw + }); + + it('should handle multiple nested $refs', async () => { + const schemaWithNestedRefs: JSONSchema = { + definitions: { + level1: { + type: 'object', + properties: { + level2Ref: { $ref: '#/definitions/level2' }, + }, + }, + level2: { + type: 'object', + properties: { + level3Ref: { $ref: '#/definitions/level3' }, + }, + }, + level3: { + type: 'string', + enum: ['value1', 'value2'], + }, + }, + type: 'object', + properties: { + root: { $ref: '#/definitions/level1' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithNestedRefs, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.exist; + }); + }); + + describe('Schema Keywords Handling', () => { + it('should handle allOf correctly', async () => { + const schemaWithAllOf: JSONSchema = { + type: 'object', + allOf: [ + { + properties: { + name: { type: 'string' }, + }, + }, + { + properties: { + age: { type: 'number' }, + }, + }, + ], + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithAllOf, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.allOf).to.have.length(2); + }); + + it('should handle anyOf correctly', async () => { + const schemaWithAnyOf: JSONSchema = { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean' }], + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithAnyOf, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.anyOf).to.have.length(3); + }); + + it('should handle oneOf correctly', async () => { + const schemaWithOneOf: JSONSchema = { + oneOf: [ + { type: 'string', minLength: 5 }, + { type: 'number', minimum: 0 }, + ], + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithOneOf, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.oneOf).to.have.length(2); + }); + + it('should handle if/then/else correctly', async () => { + const schemaWithConditional: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + age: { type: 'number' }, + canVote: { type: 'boolean' }, + }, + if: { + properties: { age: { minimum: 18 } }, + }, + then: { + properties: { canVote: { const: true } }, + }, + else: { + properties: { canVote: { const: false } }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithConditional, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.if).to.exist; + expect(result.schema.then).to.exist; + expect(result.schema.else).to.exist; + }); + + it('should handle nested object properties', async () => { + const schemaWithNestedObjects: JSONSchema = { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + profile: { + type: 'object', + properties: { + name: { type: 'string' }, + settings: { + type: 'object', + properties: { + theme: { type: 'string' }, + notifications: { type: 'boolean' }, + }, + }, + }, + }, + }, + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithNestedObjects, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.properties.user.properties.profile.properties.settings).to.exist; + }); + + it('should handle array items correctly', async () => { + const schemaWithArrays: JSONSchema = { + type: 'object', + properties: { + tags: { + type: 'array', + items: { type: 'string' }, + }, + matrix: { + type: 'array', + items: { + type: 'array', + items: { type: 'number' }, + }, + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schemaWithArrays, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema.properties.tags.items).to.deep.equal({ type: 'string' }); + expect(result.schema.properties.matrix.items.items).to.deep.equal({ type: 'number' }); + }); + }); + + describe('Schema Validation Errors', () => { + it('should detect invalid property definition in schema', async () => { + const invalidPropertySchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + name: 'invalid-type-should-be-object' as any, // Invalid: should be object, not string + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidPropertySchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid type constraint in schema', async () => { + const invalidTypeSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: 'invalid-type' as any, // Invalid: not a valid JSON Schema type + properties: { + name: { type: 'string' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidTypeSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid enum values constraint', async () => { + const invalidEnumSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + status: { + type: 'string', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + enum: 'should-be-array' as any, // Invalid: enum should be array + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidEnumSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid minimum constraint for draft-07', async () => { + const invalidMinimumSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + age: { + type: 'number', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + minimum: 'should-be-number' as any, // Invalid: minimum should be number + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidMinimumSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid exclusiveMinimum for 2020-12 schema', async () => { + const invalidExclusiveMinSchema: JSONSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + price: { + type: 'number', + minimum: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + exclusiveMinimum: true as any, // Invalid for 2020-12: should be number, not boolean + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidExclusiveMinSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + expect(result.errors[0]).to.include('constraint violation'); + }); + + it('should detect invalid required property constraint', async () => { + const invalidRequiredSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + required: 'should-be-array' as any, // Invalid: required should be array + }; + + const unresolvedSchema = new UnresolvedSchema(invalidRequiredSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid additionalProperties constraint', async () => { + const invalidAdditionalPropsSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + name: { type: 'string' }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + additionalProperties: 'invalid-should-be-boolean-or-object' as any, // Invalid + }; + + const unresolvedSchema = new UnresolvedSchema(invalidAdditionalPropsSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid array items constraint', async () => { + const invalidArrayItemsSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + tags: { + type: 'array', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: 'invalid-should-be-object-or-array' as any, // Invalid + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidArrayItemsSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid pattern constraint', async () => { + const invalidPatternSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + email: { + type: 'string', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + pattern: 123 as any, // Invalid: pattern should be string + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidPatternSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect invalid oneOf constraint structure', async () => { + const invalidOneOfSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + properties: { + value: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oneOf: 'should-be-array' as any, // Invalid: oneOf should be array + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidOneOfSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + }); + + it('should detect multiple validation errors in complex schema', async () => { + const multipleErrorsSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: 'invalid-type' as any, // Error 1: invalid type + properties: { + name: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: 'invalid-property-type' as any, // Error 2: invalid property type + }, + age: { + type: 'number', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + minimum: 'not-a-number' as any, // Error 3: invalid minimum + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + required: 'not-an-array' as any, // Error 4: invalid required + }; + + const unresolvedSchema = new UnresolvedSchema(multipleErrorsSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0); + expect(result.errors[0]).to.include('is not valid'); + // Should contain information about constraint violations + expect(result.errors[0]).to.include('constraint violation'); + }); + + it('should handle schema validation errors for different versions', async () => { + // Test for each supported schema version + const testCases = [ + { + version: 'http://json-schema.org/draft-04/schema#', + name: 'draft-04', + }, + { + version: 'http://json-schema.org/draft-07/schema#', + name: 'draft-07', + }, + { + version: 'https://json-schema.org/draft/2019-09/schema', + name: '2019-09', + }, + { + version: 'https://json-schema.org/draft/2020-12/schema', + name: '2020-12', + }, + ]; + + for (const testCase of testCases) { + const invalidSchema: JSONSchema = { + $schema: testCase.version, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type: 'invalid-type-for-any-version' as any, + }; + + const unresolvedSchema = new UnresolvedSchema(invalidSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors.length).to.be.greaterThan(0, `Should detect errors for ${testCase.name}`); + expect(result.errors[0]).to.include('is not valid', `Should include validation error for ${testCase.name}`); + } + }); + }); + + describe('Error Handling and Edge Cases', () => { + it('should preserve existing errors from UnresolvedSchema', async () => { + const schema: JSONSchema = { type: 'object' }; + const existingErrors = ['Existing error 1', 'Existing error 2']; + const unresolvedSchema = new UnresolvedSchema(schema, existingErrors); + + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.include.members(existingErrors); + }); + + it('should handle schema with URL fragments', async () => { + const schema: JSONSchema = { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(schema, []); + // For schemas with URL fragments, external link resolution may fail in test environment + // but the schema should still be processed + try { + const result = await yamlSchemaService.resolveSchemaContent( + unresolvedSchema, + 'test://schema.json#/definitions/user', + {} + ); + expect(result).to.be.instanceOf(ResolvedSchema); + expect(result.schema).to.exist; + } catch (error) { + // External link resolution may fail in test environment - that's acceptable + expect(error).to.exist; + } + }); + + it('should handle empty schema object', async () => { + const emptySchema: JSONSchema = {}; + const unresolvedSchema = new UnresolvedSchema(emptySchema, []); + + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.equal({}); + }); + + it('should handle schema with only $schema property', async () => { + const schemaOnlyMeta: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + }; + + const unresolvedSchema = new UnresolvedSchema(schemaOnlyMeta, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.deep.equal(schemaOnlyMeta); + }); + + it('should handle complex real-world schema', async () => { + const complexSchema: JSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + title: 'Complex Real-World Schema', + type: 'object', + properties: { + apiVersion: { + type: 'string', + enum: ['v1', 'v2'], + }, + metadata: { + type: 'object', + properties: { + name: { type: 'string', pattern: '^[a-z0-9-]+$' }, + labels: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + required: ['name'], + }, + spec: { + type: 'object', + properties: { + replicas: { type: 'integer', minimum: 1, maximum: 10 }, + selector: { $ref: '#/definitions/selector' }, + template: { $ref: '#/definitions/template' }, + }, + required: ['selector', 'template'], + }, + }, + required: ['apiVersion', 'metadata', 'spec'], + definitions: { + selector: { + type: 'object', + properties: { + matchLabels: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + }, + template: { + type: 'object', + properties: { + metadata: { $ref: '#/properties/metadata' }, + spec: { + type: 'object', + properties: { + containers: { + type: 'array', + items: { $ref: '#/definitions/container' }, + }, + }, + }, + }, + }, + container: { + type: 'object', + properties: { + name: { type: 'string' }, + image: { type: 'string' }, + ports: { + type: 'array', + items: { + type: 'object', + properties: { + containerPort: { type: 'integer' }, + protocol: { type: 'string', enum: ['TCP', 'UDP'] }, + }, + }, + }, + }, + required: ['name', 'image'], + }, + }, + }; + + const unresolvedSchema = new UnresolvedSchema(complexSchema, []); + const result = await yamlSchemaService.resolveSchemaContent(unresolvedSchema, 'test://schema.json', {}); + + expect(result.errors).to.have.length(0); + expect(result.schema).to.exist; + expect(result.schema.properties.spec.properties.selector).to.deep.include({ + type: 'object', + properties: { + matchLabels: { + type: 'object', + additionalProperties: { type: 'string' }, + }, + }, + _$ref: '#/definitions/selector', + }); + }); + }); + }); });