diff --git a/package-lock.json b/package-lock.json index 61fcac0369..6a9c4f1af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@hyperjump/json-schema": "^1.16.1", + "@hyperjump/json-schema-coverage": "^1.1.0", "c8": "^10.1.3", "markdownlint-cli2": "^0.18.1", "vitest": "^3.2.4", @@ -482,12 +482,11 @@ } }, "node_modules/@hyperjump/browser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.0.tgz", - "integrity": "sha512-bf2ZTqpjfvcEq3DAZSg1h0FuliNUddR6nDPuaPb9qNoPPBQQzD1ldtuXX0QggXKQZl0OgsI3eovGCR3Dl5kToA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@hyperjump/browser/-/browser-1.3.1.tgz", + "integrity": "sha512-Le5XZUjnVqVjkgLYv6yyWgALat/0HpB1XaCPuCZ+GCFki9NvXloSZITIJ0H+wRW7mb9At1SxvohKBbNQbrr/cw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@hyperjump/json-pointer": "^1.1.0", "@hyperjump/uri": "^1.2.0", @@ -503,9 +502,9 @@ } }, "node_modules/@hyperjump/json-pointer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.0.tgz", - "integrity": "sha512-tFCKxMKDKK3VEdtUA3EBOS9GmSOS4mbrTjh9v3RnK10BphDMOb6+bxTh++/ae1AyfHyWb6R54O/iaoAtPMZPCg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@hyperjump/json-pointer/-/json-pointer-1.1.1.tgz", + "integrity": "sha512-M0T3s7TC2JepoWPMZQn1W6eYhFh06OXwpMqL+8c5wMVpvnCKNsPgpu9u7WyCI03xVQti8JAeAy4RzUa6SYlJLA==", "dev": true, "license": "MIT", "funding": { @@ -536,6 +535,47 @@ "@hyperjump/browser": "^1.1.0" } }, + "node_modules/@hyperjump/json-schema-coverage": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema-coverage/-/json-schema-coverage-1.1.0.tgz", + "integrity": "sha512-E9pwHoalb1enSVMR14iM7x0gIqdG0DzpFVHDfYGOi08DMpbhfj5q59Q5V9X8Z2PlrPn/r74ufvxkbkAOEH5djQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperjump/browser": "^1.3.1", + "@hyperjump/json-schema": "^1.16.0", + "@hyperjump/uri": "^1.3.1", + "content-type": "^1.0.5", + "ignore": "^7.0.5", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.7", + "moo": "^0.5.2", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "tinyglobby": "^0.2.14", + "vfile": "^6.0.3", + "yaml": "^2.8.0", + "yaml-unist-parser": "^2.0.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jdesrosiers" + } + }, + "node_modules/@hyperjump/json-schema-coverage/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@hyperjump/pact": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@hyperjump/pact/-/pact-1.4.0.tgz", @@ -604,9 +644,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2686,9 +2726,9 @@ } }, "node_modules/ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -3741,6 +3781,13 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4941,6 +4988,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -4970,6 +5038,50 @@ "node": ">=10.12.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile/node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", @@ -5383,6 +5495,29 @@ "node": ">= 14.6" } }, + "node_modules/yaml-unist-parser": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/yaml-unist-parser/-/yaml-unist-parser-2.0.5.tgz", + "integrity": "sha512-CirHjIkYcQxbG9wgYmzjJlMaBFuj788zLOgT0A2FAzdsw2dD4vnq4cx+kij/fXImG09ARnlODtS38JM1EottOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yaml-unist-parser/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", diff --git a/package.json b/package.json index 1e9d0cfc5c..e5077b6e5f 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "bash ./scripts/md2html/build.sh", "build-src": "npm run validate-markdown && bash ./scripts/md2html/build.sh src && bash ./scripts/schema-publish.sh src", - "test": "c8 --100 vitest --watch=false && bash scripts/schema-test-coverage.sh", + "test": "c8 --100 vitest run --coverage", "format-markdown": "npx markdownlint-cli2 --config spec.markdownlint.yaml --fix src/oas.md && npx markdownlint-cli2 --fix *.md", "validate-markdown": "npx markdownlint-cli2 --config spec.markdownlint.yaml src/oas.md && npx markdownlint-cli2 *.md" }, @@ -27,7 +27,7 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@hyperjump/json-schema": "^1.16.1", + "@hyperjump/json-schema-coverage": "^1.1.0", "c8": "^10.1.3", "markdownlint-cli2": "^0.18.1", "vitest": "^3.2.4", diff --git a/scripts/schema-test-coverage.mjs b/scripts/schema-test-coverage.mjs deleted file mode 100644 index 5ebaad8d22..0000000000 --- a/scripts/schema-test-coverage.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import { readFileSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import YAML from "yaml"; -import { join } from "node:path"; -import { argv } from "node:process"; -import { registerSchema, validate } from "@hyperjump/json-schema/openapi-3-1"; -import "@hyperjump/json-schema/draft-04"; -import { BASIC, defineVocabulary } from "@hyperjump/json-schema/experimental"; - -/** - * @import { EvaluationPlugin } from "@hyperjump/json-schema/experimental" - * @import { Json } from "@hyperjump/json-pointer" - */ - -import contentTypeParser from "content-type"; -import { addMediaTypePlugin } from "@hyperjump/browser"; -import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; - -addMediaTypePlugin("application/schema+yaml", { - parse: async (response) => { - const contentType = contentTypeParser.parse( - response.headers.get("content-type") ?? "", - ); - const contextDialectId = - contentType.parameters.schema ?? contentType.parameters.profile; - - const foo = YAML.parse(await response.text()); - return buildSchemaDocument(foo, response.url, contextDialectId); - }, - fileMatcher: (path) => path.endsWith(".yaml"), -}); - -/** @implements EvaluationPlugin */ -class TestCoveragePlugin { - constructor() { - /** @type Set */ - this.visitedLocations = new Set(); - } - - beforeSchema(_schemaUri, _instance, context) { - if (this.allLocations) { - return; - } - - /** @type Set */ - this.allLocations = []; - - for (const schemaLocation in context.ast) { - if ( - schemaLocation === "metaData" || - // Do not require coverage of standard JSON Schema - schemaLocation.includes("json-schema.org") || - // Do not require coverage of default $dynamicAnchor - // schemas, as they are not expected to be reached - schemaLocation.endsWith("/schema/WORK-IN-PROGRESS#/$defs/schema") - ) { - continue; - } - - if (Array.isArray(context.ast[schemaLocation])) { - for (const keyword of context.ast[schemaLocation]) { - if (Array.isArray(keyword)) { - this.allLocations.push(keyword[1]); - } - } - } - } - } - - beforeKeyword([, schemaUri]) { - this.visitedLocations.add(schemaUri); - } -} - -/** @type (testDirectory: string) => AsyncGenerator<[string,Json]> */ -const tests = async function* (testDirectory) { - for (const file of await readdir(testDirectory, { - recursive: true, - withFileTypes: true, - })) { - if (!file.isFile() || !file.name.endsWith(".yaml")) { - continue; - } - - const testPath = join(file.parentPath, file.name); - const testJson = await readFile(testPath, "utf8"); - - yield [testPath, YAML.parse(testJson)]; - } -}; - -/** - * @typedef {{ - * allLocations: string[]; - * visitedLocations: Set; - * }} Coverage - */ - -/** @type (schemaUri: string, testDirectory: string) => Promise */ -const runTests = async (schemaUri, testDirectory) => { - const testCoveragePlugin = new TestCoveragePlugin(); - const validateOpenApi = await validate(schemaUri); - - for await (const [name, test] of tests(testDirectory)) { - const result = validateOpenApi(test, { - outputFormat: BASIC, - plugins: [testCoveragePlugin], - }); - - if (!result.valid) { - console.log("Failed:", name, result.errors); - } - } - - return { - allLocations: testCoveragePlugin.allLocations ?? new Set(), - visitedLocations: testCoveragePlugin.visitedLocations - }; -}; - -const parseYamlFromFile = (filePath) => { - const schemaYaml = readFileSync(filePath, "utf8"); - return YAML.parse(schemaYaml, { prettyErrors: true }); -}; - -const meta = parseYamlFromFile("./src/schemas/validation/meta.yaml"); -const oasBaseVocab = Object.keys(meta.$vocabulary)[0]; - -defineVocabulary(oasBaseVocab, { - "discriminator": "https://spec.openapis.org/oas/3.0/keyword/discriminator", - "example": "https://spec.openapis.org/oas/3.0/keyword/example", - "externalDocs": "https://spec.openapis.org/oas/3.0/keyword/externalDocs", - "xml": "https://spec.openapis.org/oas/3.0/keyword/xml" -}); - -registerSchema(meta); -registerSchema(parseYamlFromFile("./src/schemas/validation/dialect.yaml")); -registerSchema(parseYamlFromFile("./src/schemas/validation/schema.yaml")); - -/////////////////////////////////////////////////////////////////////////////// - -const { allLocations, visitedLocations } = await runTests(argv[2], argv[3]); -const notCovered = allLocations.filter( - (location) => !visitedLocations.has(location), -); -if (notCovered.length > 0) { - console.log("NOT Covered:", notCovered.length, "of", allLocations.length); - const maxNotCovered = 20; - const firstNotCovered = notCovered.slice(0, maxNotCovered); - if (notCovered.length > maxNotCovered) firstNotCovered.push("..."); - console.log(firstNotCovered); - process.exitCode = 1; -} - -console.log( - "Covered:", - (allLocations.length - notCovered.length), - "of", - allLocations.length, - "(" + Math.floor(((allLocations.length - notCovered.length) / allLocations.length) * 100) + "%)", -); diff --git a/scripts/schema-test-coverage.sh b/scripts/schema-test-coverage.sh deleted file mode 100755 index 600199b907..0000000000 --- a/scripts/schema-test-coverage.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash - -# Author: @ralfhandl - -# Run this script from the root of the repo - -[[ ! -e src/schemas ]] && exit 0 - -echo -echo "Schema Test Coverage" -echo - -node scripts/schema-test-coverage.mjs src/schemas/validation/schema-base.yaml tests/schema/pass -rc=$? - -[[ "$BASE" == "dev" ]] || exit $rc diff --git a/tests/schema/fail/$self-no-string.yaml b/tests/schema/fail/$self-no-string.yaml new file mode 100644 index 0000000000..01f0ccccab --- /dev/null +++ b/tests/schema/fail/$self-no-string.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +$self: 42 # must be a string, not a number +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/$self-with-fragment.yaml b/tests/schema/fail/$self-with-fragment.yaml new file mode 100644 index 0000000000..fd1598a149 --- /dev/null +++ b/tests/schema/fail/$self-with-fragment.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +$self: no#fragment # must not contain a fragment +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/components-no-object.yaml b/tests/schema/fail/components-no-object.yaml new file mode 100644 index 0000000000..df91625e7c --- /dev/null +++ b/tests/schema/fail/components-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: [] # must be an object diff --git a/tests/schema/fail/components-object-wrong-field-types.yaml b/tests/schema/fail/components-object-wrong-field-types.yaml new file mode 100644 index 0000000000..0376494e2e --- /dev/null +++ b/tests/schema/fail/components-object-wrong-field-types.yaml @@ -0,0 +1,16 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: + schemas: [] + responses: [] + parameters: [] + examples: [] + requestBodies: [] + headers: [] + securitySchemes: [] + links: [] + callbacks: [] + pathItems: [] + mediaTypes: [] diff --git a/tests/schema/fail/invalid_schema_types.yaml b/tests/schema/fail/components-schemas-invalid-types.yaml similarity index 100% rename from tests/schema/fail/invalid_schema_types.yaml rename to tests/schema/fail/components-schemas-invalid-types.yaml diff --git a/tests/schema/fail/encoding-enc-item-exclusion.yaml b/tests/schema/fail/encoding-enc-item-exclusion.yaml index 658f848be9..57101390d3 100644 --- a/tests/schema/fail/encoding-enc-item-exclusion.yaml +++ b/tests/schema/fail/encoding-enc-item-exclusion.yaml @@ -4,9 +4,11 @@ info: version: 1.0.0 components: requestBodies: - content: - multipart/mixed: - prefixEncoding: - - contentType: multipart/mixed - encoding: {} - prefixEncoding: [] + encoding-with-prefixEncoding-not-allowed: + content: + multipart/mixed: + encoding: + foo: + contentType: multipart/mixed + encoding: {} + prefixEncoding: [] diff --git a/tests/schema/fail/encoding-enc-prefix-exclusion.yaml b/tests/schema/fail/encoding-enc-prefix-exclusion.yaml index 8f62070d3b..ea1f8e972b 100644 --- a/tests/schema/fail/encoding-enc-prefix-exclusion.yaml +++ b/tests/schema/fail/encoding-enc-prefix-exclusion.yaml @@ -4,9 +4,11 @@ info: version: 1.0.0 components: requestBodies: - content: - multipart/mixed: - prefixEncoding: - - contentType: multipart/mixed - encoding: {} - itemEncoding: [] + encoding-with-itemEncoding-not-allowed: + content: + multipart/mixed: + encoding: + foo: + contentType: multipart/mixed + encoding: {} + itemEncoding: [] diff --git a/tests/schema/fail/info-contact-wrong-field-types.yaml b/tests/schema/fail/info-contact-wrong-field-types.yaml new file mode 100644 index 0000000000..c5ca668388 --- /dev/null +++ b/tests/schema/fail/info-contact-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 + contact: + name: true + email: true + url: true +paths: {} \ No newline at end of file diff --git a/tests/schema/fail/info-license-no-name.yaml b/tests/schema/fail/info-license-no-name.yaml new file mode 100644 index 0000000000..d641e2ee86 --- /dev/null +++ b/tests/schema/fail/info-license-no-name.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 + license: {} # must have name +paths: {} \ No newline at end of file diff --git a/tests/schema/fail/info-license-wrong-field-types.yaml b/tests/schema/fail/info-license-wrong-field-types.yaml new file mode 100644 index 0000000000..d211c44055 --- /dev/null +++ b/tests/schema/fail/info-license-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 + license: + name: true + identifier: true + url: true +paths: {} \ No newline at end of file diff --git a/tests/schema/fail/info-missing.yaml b/tests/schema/fail/info-missing.yaml new file mode 100644 index 0000000000..d78c64cd7b --- /dev/null +++ b/tests/schema/fail/info-missing.yaml @@ -0,0 +1,3 @@ +openapi: 3.2.0 +# info is required +paths: {} \ No newline at end of file diff --git a/tests/schema/fail/info-no-object.yaml b/tests/schema/fail/info-no-object.yaml new file mode 100644 index 0000000000..75d894e949 --- /dev/null +++ b/tests/schema/fail/info-no-object.yaml @@ -0,0 +1,3 @@ +openapi: 3.2.0 +info: must be an object +paths: {} diff --git a/tests/schema/fail/info-object-no-title-no-version.yaml b/tests/schema/fail/info-object-no-title-no-version.yaml new file mode 100644 index 0000000000..a56bf8a2c5 --- /dev/null +++ b/tests/schema/fail/info-object-no-title-no-version.yaml @@ -0,0 +1,4 @@ +openapi: 3.2.0 +info: + summary: must have title and version +paths: {} diff --git a/tests/schema/fail/info-object-wrong-field-types.yaml b/tests/schema/fail/info-object-wrong-field-types.yaml new file mode 100644 index 0000000000..be8ee0051e --- /dev/null +++ b/tests/schema/fail/info-object-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.2.0 +info: + title: true + summary: true + description: true + termsOfService: true + version: 1 + contact: true + license: true \ No newline at end of file diff --git a/tests/schema/fail/invalid-components.yaml b/tests/schema/fail/invalid-components.yaml new file mode 100644 index 0000000000..e9361c5a9c --- /dev/null +++ b/tests/schema/fail/invalid-components.yaml @@ -0,0 +1,169 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: + pathItems: + ö: true + invalid: + $ref: 42 + summary: true + description: true + servers: true + parameters: true + additionalOperations: none + invalid-additionalOperations: + additionalOperations: + ö: true + invalid-operations: + get: + tags: true + summary: true + description: true + operationId: true + parameters: true + callbacks: true + deprecated: maybe + security: no + servers: none + patch: false + invalid-responses: + get: + tags: [true] + responses: false + patch: + responses: {} + requestBodies: + no-object: true + invalid: + description: true + required: no + parameters: + no-object: true + no-in: + name: id + schema: + type: string + invalid: + name: 42 + in: invalid + description: true + required: no + deprecated: no + content: {} + invalid-content: + name: id + content: + one: true + two: false + invalid-querystring: + name: id + in: querystring + optional-path: + name: id + in: path + schema: + type: string + required: false + invalid-path: + name: id + in: path + schema: + type: string + style: invalid + allowReserved: yes + invalid-header: + name: id + in: header + schema: + type: string + style: 42 + invalid-query: + name: id + in: query + schema: + type: string + style: invalid + allowEmptyValue: yes + invalid-cookie: + name: id + in: cookie + schema: + type: string + style: invalid + invalid-examples: + name: id + in: query + schema: + type: string + examples: true + explode: 42 + callbacks: + no-object: true + invalid: + foo: true + links: + no-object: true + invalid: + description: true + operationId: true + operationRef: true + parameters: true + requestBody: true + responses: true + server: true + servers: true + deprecated: no + headers: + no-object: true + invalid: + description: true + required: yes + deprecated: no + content: {} + invalid-content: + content: + one: true + two: false + invalid-style: + schema: + type: string + style: true + explode: no + allowReserved: yes + examples: + no-object: true + invalid-reference: + $ref: 42 + summary: false + description: true + invalid: + summary: true + description: true + externalValue: true + responses: + no-object: true + invalid: + summary: true + description: true + headers: true + links: true + content: true + invalid-encoding: + content: + 'application/json': + encoding: true + invalid-encoding-object: + content: + 'application/json': + description: true + encoding: + foo: true + bar: + contentType: true + headers: true + style: true + explode: yes + allowReserved: no + encoding: true + prefixEncoding: true diff --git a/tests/schema/fail/invalid-schema-object.yaml b/tests/schema/fail/invalid-schema-object.yaml new file mode 100644 index 0000000000..1b2d9042e7 --- /dev/null +++ b/tests/schema/fail/invalid-schema-object.yaml @@ -0,0 +1,26 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: + schemas: + externalDocs-no-object: + externalDocs: true + externalDocs-incomplete: + externalDocs: + description: true + externalDocs-invalid-url: + externalDocs: + url: true + discriminator-no-object: + discriminator: true + discriminator-incomplete: + discriminator: + mapping: true + discriminator-invalid-propertyName: + discriminator: + propertyName: true + discriminator-invalid-mapping: + discriminator: + mapping: + key: true \ No newline at end of file diff --git a/tests/schema/fail/invalid-security-scheme-objects.yaml b/tests/schema/fail/invalid-security-scheme-objects.yaml new file mode 100644 index 0000000000..510490ddc9 --- /dev/null +++ b/tests/schema/fail/invalid-security-scheme-objects.yaml @@ -0,0 +1,88 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +security: + - true + - basic: true + - apiKey: [true] +components: + securitySchemes: + no-object: true + no-type: + typo: noauth3 + invalid-type: + type: invalid + invalid-description: + type: http + description: 42 + deprecated: yes + apiKey-invalid-name: + type: apiKey + name: 42 + apiKey-invalid-in: + type: apiKey + name: invalid-in + in: garbage + http-no-scheme: + type: http + http-invalid-scheme: + type: http + scheme: 42 + barer-invalid-bearerFormat: + type: http + scheme: bearer + bearerFormat: 42 + oauth2-no-flows: + type: oauth2 + oauth2-invalid-oauth2MetadataUrl: + type: oauth2 + oauth2MetadataUrl: 42 + openIdConnect-no-openIdConnectUrl: + type: openIdConnect + openIdConnect-invalid-openIdConnectUrl: + type: openIdConnect + openIdConnectUrl: true + oauth-invalid-flows: + type: oauth2 + flows: false + oauth-invalid-flow-no-objects: + type: oauth2 + flows: + implicit: false + password: false + clientCredentials: false + authorizationCode: false + deviceAuthorization: false + oauth-invalid-flow-objects: + type: oauth2 + flows: + implicit: + authorizationUrl: 42 + refreshUrl: 42 + password: + tokenUrl: 42 + refreshUrl: 42 + clientCredentials: + tokenUrl: 42 + refreshUrl: 42 + authorizationCode: + authorizationUrl: 42 + tokenUrl: 42 + refreshUrl: 42 + deviceAuthorization: + deviceAuthorizationUrl: 42 + tokenUrl: 42 + refreshUrl: 42 + oauth-invalid-scopes: + type: oauth2 + flows: + implicit: + authorizationUrl: 42 + refreshUrl: 42 + scopes: 42 + password: + tokenUrl: 42 + refreshUrl: 42 + scopes: + invalid: 42 diff --git a/tests/schema/fail/jsonSchemaDialect-no-string.yaml b/tests/schema/fail/jsonSchemaDialect-no-string.yaml new file mode 100644 index 0000000000..e12ae69aef --- /dev/null +++ b/tests/schema/fail/jsonSchemaDialect-no-string.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +jsonSchemaDialect: 42 # must be a string, not a number +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/no-root-object.yaml b/tests/schema/fail/no-root-object.yaml new file mode 100644 index 0000000000..f5a6df9e09 --- /dev/null +++ b/tests/schema/fail/no-root-object.yaml @@ -0,0 +1 @@ +not an object diff --git a/tests/schema/fail/openapi-no-string.yaml b/tests/schema/fail/openapi-no-string.yaml new file mode 100644 index 0000000000..321b3df5e9 --- /dev/null +++ b/tests/schema/fail/openapi-no-string.yaml @@ -0,0 +1,5 @@ +openapi: 4.2 # a number, not a string +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/openapi-wrong-pattern.yaml b/tests/schema/fail/openapi-wrong-pattern.yaml new file mode 100644 index 0000000000..c167225b66 --- /dev/null +++ b/tests/schema/fail/openapi-wrong-pattern.yaml @@ -0,0 +1,5 @@ +openapi: hello # wrong pattern, should be a string like "3.2.0" +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/path-item-object-conflicting-additional-operation.yaml b/tests/schema/fail/path-item-object-conflicting-additional-operation.yaml index f068406b68..ea6abd23da 100644 --- a/tests/schema/fail/path-item-object-conflicting-additional-operation.yaml +++ b/tests/schema/fail/path-item-object-conflicting-additional-operation.yaml @@ -23,6 +23,7 @@ paths: text/html: schema: $ref: '#/components/schemas/ErrorModel' + unexpected: true parameters: - name: id in: path @@ -61,4 +62,4 @@ paths: content: text/html: schema: - $ref: '#/components/schemas/ErrorModel' \ No newline at end of file + $ref: '#/components/schemas/ErrorModel' diff --git a/tests/schema/fail/paths-no-object.yaml b/tests/schema/fail/paths-no-object.yaml new file mode 100644 index 0000000000..548dba3ab7 --- /dev/null +++ b/tests/schema/fail/paths-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +paths: [] # must be an object diff --git a/tests/schema/fail/security-no-array.yaml b/tests/schema/fail/security-no-array.yaml new file mode 100644 index 0000000000..3087a5630a --- /dev/null +++ b/tests/schema/fail/security-no-array.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +security: {} # must be an array +paths: {} diff --git a/tests/schema/fail/server_enum_empty.yaml b/tests/schema/fail/server-variable-enum-empty.yaml similarity index 100% rename from tests/schema/fail/server_enum_empty.yaml rename to tests/schema/fail/server-variable-enum-empty.yaml diff --git a/tests/schema/fail/servers-invalid-items.yaml b/tests/schema/fail/servers-invalid-items.yaml new file mode 100644 index 0000000000..20854d6d52 --- /dev/null +++ b/tests/schema/fail/servers-invalid-items.yaml @@ -0,0 +1,19 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +servers: + - url: true # not a string + description: true # not a string + name: true # not a string + variables: [] # not an object + - description: no url + variables: + no-object: true + invalid-enum: + enum: true + description: true + invalid-enum-value: + enum: [42] + invalid-default: + default: true diff --git a/tests/schema/fail/servers.yaml b/tests/schema/fail/servers-no-array.yaml similarity index 100% rename from tests/schema/fail/servers.yaml rename to tests/schema/fail/servers-no-array.yaml diff --git a/tests/schema/fail/tags-array-invalid-items.yaml b/tests/schema/fail/tags-array-invalid-items.yaml new file mode 100644 index 0000000000..0aaffde417 --- /dev/null +++ b/tests/schema/fail/tags-array-invalid-items.yaml @@ -0,0 +1,18 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +tags: + - true # not an object + - summary: true # no name + - name: true # not a string + description: true # not a string + externalDocs: true # not a string + parent: true # not a string + kind: true # not a string + - name: foo + externalDocs: + url: true # not a string + - name: bar + externalDocs: + description: true # not a string diff --git a/tests/schema/fail/tags-no-array.yaml b/tests/schema/fail/tags-no-array.yaml new file mode 100644 index 0000000000..dbad37aefc --- /dev/null +++ b/tests/schema/fail/tags-no-array.yaml @@ -0,0 +1,6 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +tags: {} # must be an array +paths: {} diff --git a/tests/schema/fail/webhooks-no-object.yaml b/tests/schema/fail/webhooks-no-object.yaml new file mode 100644 index 0000000000..a838049f69 --- /dev/null +++ b/tests/schema/fail/webhooks-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +webhooks: [] # must be an object diff --git a/tests/schema/fail/xml-invalid-object.yaml b/tests/schema/fail/xml-invalid-object.yaml new file mode 100644 index 0000000000..68aa4b67f5 --- /dev/null +++ b/tests/schema/fail/xml-invalid-object.yaml @@ -0,0 +1,15 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: + schemas: + Attr: + type: string + xml: + nodeType: true + name: true + namespace: true + prefix: true + attribute: 42 + wrapped: 42 diff --git a/tests/schema/fail/xml-no-object.yaml b/tests/schema/fail/xml-no-object.yaml new file mode 100644 index 0000000000..eb18af7fb7 --- /dev/null +++ b/tests/schema/fail/xml-no-object.yaml @@ -0,0 +1,9 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +components: + schemas: + Attr: + type: string + xml: true \ No newline at end of file diff --git a/tests/schema/oas-schema.mjs b/tests/schema/oas-schema.mjs new file mode 100644 index 0000000000..e0537549dc --- /dev/null +++ b/tests/schema/oas-schema.mjs @@ -0,0 +1,25 @@ +import { registerSchema } from "@hyperjump/json-schema/draft-2020-12"; +import { defineVocabulary } from "@hyperjump/json-schema/experimental"; +import { readFile } from "node:fs/promises"; +import YAML from "yaml"; + +const parseYamlFromFile = async (filePath) => { + const schemaYaml = await readFile(filePath, "utf8"); + return YAML.parse(schemaYaml, { prettyErrors: true }); +}; + +export default async () => { + const dialect = await parseYamlFromFile("./src/schemas/validation/dialect.yaml"); + const meta = await parseYamlFromFile("./src/schemas/validation/meta.yaml"); + const oasBaseVocab = Object.keys(meta.$vocabulary)[0]; + + defineVocabulary(oasBaseVocab, { + "discriminator": "https://spec.openapis.org/oas/3.0/keyword/discriminator", + "example": "https://spec.openapis.org/oas/3.0/keyword/example", + "externalDocs": "https://spec.openapis.org/oas/3.0/keyword/externalDocs", + "xml": "https://spec.openapis.org/oas/3.0/keyword/xml" + }); + + registerSchema(meta); + registerSchema(dialect); +}; diff --git a/tests/schema/pass/deprecated-example-in-schema-object.yaml b/tests/schema/pass/deprecated-example-in-schema-object.yaml new file mode 100644 index 0000000000..c7ea571028 --- /dev/null +++ b/tests/schema/pass/deprecated-example-in-schema-object.yaml @@ -0,0 +1,20 @@ +openapi: 3.2.0 +info: + title: API + version: 1.0.0 +paths: + /user: + parameters: + - in: querystring + name: json + content: + application/json: + schema: + # Allow an arbitrary JSON object to keep + # the example simple + type: object + # DEPRECATED: don't use example keyword inside Schema Object + example: { + "numbers": [1, 2], + "flag": null + } \ No newline at end of file diff --git a/tests/schema/pass/link-object-examples.yaml b/tests/schema/pass/link-object-examples.yaml index 12a1194bf5..9d471f0a03 100644 --- a/tests/schema/pass/link-object-examples.yaml +++ b/tests/schema/pass/link-object-examples.yaml @@ -45,6 +45,10 @@ paths: operationRef: https://na2.gigantic-server.com/#/paths/~12.0~1repositories~1%7Busername%7D/get parameters: username: $response.body#/username + withBody: + operationId: queryUserWithBody + requestBody: + userId: $request.path.id # the path item of the linked operation /users/{userid}/address: parameters: diff --git a/tests/schema/pass/mega.yaml b/tests/schema/pass/mega.yaml index 3e57fb9144..f472d4388e 100644 --- a/tests/schema/pass/mega.yaml +++ b/tests/schema/pass/mega.yaml @@ -6,6 +6,7 @@ info: license: name: Apache 2.0 identifier: Apache-2.0 +x-tensions: can appear in many places paths: /: get: diff --git a/tests/schema/schema.test.mjs b/tests/schema/schema.test.mjs index 4ba5924816..92bd7a75af 100644 --- a/tests/schema/schema.test.mjs +++ b/tests/schema/schema.test.mjs @@ -1,57 +1,71 @@ import { readdirSync, readFileSync } from "node:fs"; import YAML from "yaml"; -import { registerSchema, validate, setMetaSchemaOutputFormat } from "@hyperjump/json-schema/openapi-3-1"; -import { BASIC, defineVocabulary } from "@hyperjump/json-schema/experimental"; import { describe, test, expect } from "vitest"; - -import contentTypeParser from "content-type"; -import { addMediaTypePlugin } from "@hyperjump/browser"; -import { buildSchemaDocument } from "@hyperjump/json-schema/experimental"; - -addMediaTypePlugin("application/schema+yaml", { - parse: async (response) => { - const contentType = contentTypeParser.parse(response.headers.get("content-type") ?? ""); - const contextDialectId = contentType.parameters.schema ?? contentType.parameters.profile; - - const foo = YAML.parse(await response.text()); - return buildSchemaDocument(foo, response.url, contextDialectId); - }, - fileMatcher: (path) => path.endsWith(".yaml") - }); +import { registerSchema } from "@hyperjump/json-schema-coverage/vitest"; +import registerOasSchema from "./oas-schema.mjs"; const parseYamlFromFile = (filePath) => { const schemaYaml = readFileSync(filePath, "utf8"); return YAML.parse(schemaYaml, { prettyErrors: true }); }; -setMetaSchemaOutputFormat(BASIC); - -const meta = parseYamlFromFile("./src/schemas/validation/meta.yaml"); -const oasBaseVocab = Object.keys(meta.$vocabulary)[0]; +await registerOasSchema(); +await registerSchema("./src/schemas/validation/schema.yaml"); +const fixtures = './tests/schema'; -defineVocabulary(oasBaseVocab, { - "discriminator": "https://spec.openapis.org/oas/3.0/keyword/discriminator", - "example": "https://spec.openapis.org/oas/3.0/keyword/example", - "externalDocs": "https://spec.openapis.org/oas/3.0/keyword/externalDocs", - "xml": "https://spec.openapis.org/oas/3.0/keyword/xml" -}); +describe("v3.2", () => { + test("schema.yaml schema test", async () => { + // Hardcode this simple document instead of putting it in pass/fail directories because + // documents in those folders get run against schema-base.yaml instead of schema.yaml. + const oad = { + // Also need to include required properties + openapi: "3.2.0", + info: { + title: "API", + version: "1.0.0" + }, + components: { + schemas: { + foo: {} + } + } + }; + await expect(oad).to.matchJsonSchema("./src/schemas/validation/schema.yaml"); // <-- "schema.yaml" instead of "schema-base.yaml" + }); -registerSchema(meta); -registerSchema(parseYamlFromFile("./src/schemas/validation/dialect.yaml")); -registerSchema(parseYamlFromFile("./src/schemas/validation/schema.yaml")); + test("schema.yaml invalid Schema Object type", async () => { + // Hardcode this simple document instead of putting it in pass/fail directories because + // documents in those folders get run against schema-base.yaml instead of schema.yaml. + const oad = { + // Also need to include required properties + openapi: "3.2.0", + info: { + title: "API", + version: "1.0.0" + }, + components: { + schemas: { + foo: 42 + } + } + }; + await expect(oad).to.not.matchJsonSchema("./src/schemas/validation/schema.yaml"); // <-- "schema.yaml" instead of "schema-base.yaml" + }); -const validateOpenApi = await validate("./src/schemas/validation/schema-base.yaml"); -const fixtures = './tests/schema'; + test("unreachable branch in Reference Object", async () => { + // The Reference Object schema is only conditionally reached if the instance is an object, + // so the `type: object` line will never fail unless "directly" tested with a non-object instance. + const invalidReferenceObject = 42; + await expect(invalidReferenceObject).to.not.matchJsonSchema("./src/schemas/validation/schema.yaml#/$defs/reference"); // <-- "schema.yaml" instead of "schema-base.yaml" + }); -describe("v3.1", () => { describe("Pass", () => { readdirSync(`${fixtures}/pass`, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.yaml$/.test(entry.name)) .forEach((entry) => { - test(entry.name, () => { + test(entry.name, async () => { const instance = parseYamlFromFile(`${fixtures}/pass/${entry.name}`); - const output = validateOpenApi(instance, BASIC); - expect(output).to.deep.equal({ valid: true }); + await expect(instance).to.matchJsonSchema("./src/schemas/validation/schema-base.yaml"); }); }); }); @@ -60,10 +74,9 @@ describe("v3.1", () => { readdirSync(`${fixtures}/fail`, { withFileTypes: true }) .filter((entry) => entry.isFile() && /\.yaml$/.test(entry.name)) .forEach((entry) => { - test(entry.name, () => { + test(entry.name, async () => { const instance = parseYamlFromFile(`${fixtures}/fail/${entry.name}`); - const output = validateOpenApi(instance, BASIC); - expect(output.valid).to.equal(false); + await expect(instance).to.not.matchJsonSchema("./src/schemas/validation/schema-base.yaml"); }); }); }); diff --git a/vitest.config.mjs b/vitest.config.mjs index 4268028a0d..f5c7665b70 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -1,8 +1,17 @@ import { defineConfig } from 'vitest/config' +import { jsonSchemaCoveragePlugin } from "@hyperjump/json-schema-coverage/vitest" export default defineConfig({ + plugins: [jsonSchemaCoveragePlugin()], test: { + globalSetup: ["tests/schema/oas-schema.mjs"], + coverage: { + include: ["src/schemas/validation/**/*.yaml"], + thresholds: process.env.BASE !== "dev" ? { + 100: true + } : {} + }, forceRerunTriggers: ['**/scripts/**', '**/tests/**'], testTimeout: 10000, // 10 seconds }, -}) \ No newline at end of file +})