diff --git a/package-lock.json b/package-lock.json index 29a52a28c0..6a9c4f1af3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "yargs": "^18.0.0" }, "devDependencies": { - "@hyperjump/json-schema": "^1.16.0", + "@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": { @@ -514,9 +513,9 @@ } }, "node_modules/@hyperjump/json-schema": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.16.0.tgz", - "integrity": "sha512-7tAcnxrsfmu8JFH2oFzk+AEvp74VQh7sb2DfDl3HSxFE880tJIsKlnC0nBiIfLeeIyg4LsjgjL2PDS63foWULQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@hyperjump/json-schema/-/json-schema-1.16.1.tgz", + "integrity": "sha512-GCGQCOJMwAUTcCn7eDFOx5G6uOPFLG2O3tv+vMrHJUHwqeFo4GVO03BcsmX/Xy7dfTP4VgucXyoNjrtyoqb5wA==", "dev": true, "license": "MIT", "dependencies": { @@ -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 1c41d07916..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.0", + "@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/components-no-object.yaml b/tests/schema/fail/components-no-object.yaml new file mode 100644 index 0000000000..2ad434d7cb --- /dev/null +++ b/tests/schema/fail/components-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.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..d50a9807d9 --- /dev/null +++ b/tests/schema/fail/components-object-wrong-field-types.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: # must all be objects + 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 85% rename from tests/schema/fail/invalid_schema_types.yaml rename to tests/schema/fail/components-schemas-invalid-types.yaml index d295b1f0ed..fac3b48f75 100644 --- a/tests/schema/fail/invalid_schema_types.yaml +++ b/tests/schema/fail/components-schemas-invalid-types.yaml @@ -6,7 +6,7 @@ info: title: API version: 1.0.0 components: - schemas: + schemas: # must all be objects invalid_null: null invalid_number: 0 invalid_array: [] 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..a92d502ca0 --- /dev/null +++ b/tests/schema/fail/info-contact-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 + contact: + name: true # must be a string + email: true # must be a string + url: true # must be a string +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..18aff41855 --- /dev/null +++ b/tests/schema/fail/info-license-no-name.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 + license: {} # must have `name` field` +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..5e96e82a18 --- /dev/null +++ b/tests/schema/fail/info-license-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 + license: + name: true # must be a string + identifier: true # must be a string + url: true # must be a string +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..91c312d5a8 --- /dev/null +++ b/tests/schema/fail/info-missing.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +# `info` field 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..e05c26ec37 --- /dev/null +++ b/tests/schema/fail/info-no-object.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.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..bdfa33ff0d --- /dev/null +++ b/tests/schema/fail/info-object-no-title-no-version.yaml @@ -0,0 +1,4 @@ +openapi: 3.1.0 +info: + summary: must have `title` and `version` fields +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..0917a4c34d --- /dev/null +++ b/tests/schema/fail/info-object-wrong-field-types.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: true # must be a string + summary: true # must be a string + description: true # must be a string + termsOfService: true # must be a string + version: 1 # must be a string + contact: true # must be a string + license: true # must be a string \ 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..edc4afe3eb --- /dev/null +++ b/tests/schema/fail/invalid-components.yaml @@ -0,0 +1,163 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: + pathItems: + รถ: true # wrong map key pattern + invalid: + $ref: 42 # must be a string + summary: true # must be a string + description: true # must be a string + servers: true # must be an array + parameters: true # must be an array + invalid-operations: + get: + tags: true # must be an array + summary: true # must be a string + description: true # must be a string + operationId: true # must be a string + parameters: true # must be an array + callbacks: true # must be an object + deprecated: maybe # must be a boolean + security: no # must be an array + servers: none # must be an array + patch: false # must be an object + invalid-responses: + get: + tags: [true] # array items must be strings + responses: false # must be an object + patch: + responses: {} # must have at least one field + post: + responses: + invalid: true # must be an object + requestBodies: + no-object: true # must be an object + invalid: + description: true # must be a string + required: no # must be a boolean + parameters: + no-object: true # must be an object + no-in: # object must have `in` field + name: id + schema: + type: string + invalid: + name: 42 # must be a string + in: invalid # must be one of `query`, `header`, `path`, `cookie` + description: true # must be a string + required: no # must be a boolean + deprecated: no # must be a boolean + content: {} # must have at least one field + invalid-content: + name: id + content: + one: true # must be an object + two: false # must be an object + invalid-querystring: # must have `content` field + name: id + in: querystring + optional-path: + name: id + in: path + schema: + type: string + required: false # must be true for path parameters + invalid-path: + name: id + in: path + schema: + type: string + style: invalid # must be one of the allowed enum values + allowReserved: yes # must be a boolean + invalid-header: + name: id + in: header + schema: + type: string + style: 42 # must be the string "simple" + invalid-query: + name: id + in: query + schema: + type: string + style: invalid # must be one of the allowed enum values + allowEmptyValue: yes # must be a boolean + allowReserved: no # must be a boolean + invalid-cookie: + name: id + in: cookie + schema: + type: string + style: invalid # must be one of the allowed enum values + invalid-examples: + name: id + in: query + schema: + type: string + examples: true # must be an object + explode: 42 # must be a boolean + callbacks: + no-object: true # must be an object + invalid: + foo: true # must be an object + links: + no-object: true # must be an object + invalid: + description: true # must be a string + operationId: true # must be a string + operationRef: true # must be a string + parameters: true # must be an object + responses: true # must be an object + server: true # must be an object + headers: + no-object: true # must be an object + invalid: + description: true # must be a string + required: yes # must be a boolean + deprecated: no # must be a boolean + content: {} # must have at least one field + invalid-content: + content: + one: true # must be an object + two: false # must be an object + invalid-style: + schema: + type: string + style: true # must be a string + explode: no # must be a boolean + allowReserved: yes # must be a boolean + examples: + no-object: true # must be an object + invalid-reference: + $ref: 42 # must be a string + summary: false # must be a string + description: true # must be a string + invalid: + summary: true # must be a string + description: true # must be a string + externalValue: true # must be a string + responses: + no-object: true # must be an object + invalid: + summary: true # must be a string + description: true # must be a string + headers: true # must be an object + links: true # must be an object + content: true # must be an object + invalid-encoding: + content: + 'application/json': + encoding: true # must be an object + invalid-encoding-object: + content: + 'application/json': + encoding: + foo: true # must be an object + bar: + contentType: true # must be a string + headers: true # must be an object + style: true # must be a string + explode: yes # must be a boolean + allowReserved: no # must be a boolean diff --git a/tests/schema/fail/invalid-schema-object.yaml b/tests/schema/fail/invalid-schema-object.yaml new file mode 100644 index 0000000000..34161f5d00 --- /dev/null +++ b/tests/schema/fail/invalid-schema-object.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: + schemas: + externalDocs-no-object: + externalDocs: true # must be an object + externalDocs-incomplete: + externalDocs: # must have `url` field + description: true # must be a string + externalDocs-invalid-url: + externalDocs: + url: true # must be a string + discriminator-no-object: + discriminator: true # must be an object + discriminator-incomplete: + discriminator: # must have `propertyName` field + mapping: true # must be an object + discriminator-invalid-field-values: + discriminator: + propertyName: true # must be a string + discriminator-invalid-mapping: + discriminator: + propertyName: foo + mapping: + key: true # must be a string \ 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..34dd443dc1 --- /dev/null +++ b/tests/schema/fail/invalid-security-scheme-objects.yaml @@ -0,0 +1,88 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +security: + - true # array items must be objects + - basic: true # field value must be an array + - apiKey: [true] # array items must be strings +components: + securitySchemes: + no-object: true + no-type: # must have `type` field + typo: noauth3 + invalid-type: + type: invalid # must be one of the allowed enum values + invalid-description: + type: http + description: 42 # must be a string + deprecated: yes # must be a boolean + apiKey-invalid-name: + type: apiKey + name: 42 # must be a string + apiKey-invalid-in: + type: apiKey + name: invalid-in + in: garbage # must be one of `query`, `header`, `cookie` + http-no-scheme: + type: http + http-invalid-scheme: + type: http + scheme: 42 # must be a string + barer-invalid-bearerFormat: + type: http + scheme: bearer + bearerFormat: 42 # must be a string + oauth2-no-flows: + type: oauth2 # must have `flows` field + oauth2-invalid-oauth2MetadataUrl: + type: oauth2 + oauth2MetadataUrl: 42 # must be a string + openIdConnect-no-openIdConnectUrl: + type: openIdConnect # must have `openIdConnectUrl` field + openIdConnect-invalid-openIdConnectUrl: + type: openIdConnect + openIdConnectUrl: true # must be a string + oauth-invalid-flows: + type: oauth2 + flows: false # must be an object + oauth-invalid-flow-no-objects: + type: oauth2 + flows: + implicit: false # must be an object + password: false # must be an object + clientCredentials: false # must be an object + authorizationCode: false # must be an object + deviceAuthorization: false # must be an object + oauth-invalid-flow-objects: + type: oauth2 + flows: + implicit: + authorizationUrl: 42 # must be a string + refreshUrl: 42 # must be a string + password: + tokenUrl: 42 # must be a string + refreshUrl: 42 # must be a string + clientCredentials: + tokenUrl: 42 # must be a string + refreshUrl: 42 # must be a string + authorizationCode: + authorizationUrl: 42 # must be a string + tokenUrl: 42 # must be a string + refreshUrl: 42 # must be a string + deviceAuthorization: + deviceAuthorizationUrl: 42 # must be a string + tokenUrl: 42 # must be a string + refreshUrl: 42 # must be a string + oauth-invalid-scopes: + type: oauth2 + flows: + implicit: # must have `tokenUrl` field + authorizationUrl: a + refreshUrl: b + scopes: 42 # must be an object + password: # must have `authorizationUrl` field + tokenUrl: a + refreshUrl: b + scopes: + invalid: 42 # map values must be strings diff --git a/tests/schema/fail/jsonSchemaDialect-no-string.yaml b/tests/schema/fail/jsonSchemaDialect-no-string.yaml new file mode 100644 index 0000000000..271aa3aff7 --- /dev/null +++ b/tests/schema/fail/jsonSchemaDialect-no-string.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.0 +jsonSchemaDialect: 42 # must be a string, not a number +info: + title: API + version: 1.0.0 +paths: {} diff --git a/tests/schema/fail/link-object-no-body.yaml b/tests/schema/fail/link-object-no-body.yaml deleted file mode 100644 index 2c327694f5..0000000000 --- a/tests/schema/fail/link-object-no-body.yaml +++ /dev/null @@ -1,11 +0,0 @@ -openapi: 3.1.0 -info: - title: API - version: 1.0.0 -components: - links: - Link-Object-with-body-property: - operationId: getThing - description: The "server" property was misspelled as "body" in a previous schema iteration, now fixed - body: - url: https://things.example.com 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/no_containers.yaml b/tests/schema/fail/no_containers.yaml index c158bcb2b6..5c5d89df2b 100644 --- a/tests/schema/fail/no_containers.yaml +++ b/tests/schema/fail/no_containers.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 -# this example should fail as there are no paths, components or webhooks containers (at least one of which must be present) +# this example should fail as there are no `paths`, `components` or `webhooks` fields (at least one of which must be present) info: title: API 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/paths-no-object.yaml b/tests/schema/fail/paths-no-object.yaml new file mode 100644 index 0000000000..145a9a75ef --- /dev/null +++ b/tests/schema/fail/paths-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +paths: [] # must be an object diff --git a/tests/schema/fail/paths-no-path-item-object.yaml b/tests/schema/fail/paths-no-path-item-object.yaml new file mode 100644 index 0000000000..5909c74ed7 --- /dev/null +++ b/tests/schema/fail/paths-no-path-item-object.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +paths: + /: true # 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..c2b1fc61f6 --- /dev/null +++ b/tests/schema/fail/security-no-array.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.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 78% rename from tests/schema/fail/server_enum_empty.yaml rename to tests/schema/fail/server-variable-enum-empty.yaml index cd6d30eb3e..be81a11e83 100644 --- a/tests/schema/fail/server_enum_empty.yaml +++ b/tests/schema/fail/server-variable-enum-empty.yaml @@ -9,6 +9,6 @@ servers: - url: https://example.com/{var} variables: var: - enum: [] + enum: [] # must not be empty and must contain the default value default: a components: {} diff --git a/tests/schema/fail/servers-invalid-items.yaml b/tests/schema/fail/servers-invalid-items.yaml new file mode 100644 index 0000000000..d1db2a5b01 --- /dev/null +++ b/tests/schema/fail/servers-invalid-items.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +servers: + - url: true # must be a string + description: true # must be a string + name: true # must be a string + variables: [] # must be an object + - description: no url # object must have `url` field + variables: + no-object: true # must be an object + invalid-enum: + enum: true # must be an array + description: true # must be a string + invalid-enum-value: + enum: [42] # array items must be strings + invalid-default: + default: true # must be a string diff --git a/tests/schema/fail/servers.yaml b/tests/schema/fail/servers-no-array.yaml similarity index 85% rename from tests/schema/fail/servers.yaml rename to tests/schema/fail/servers-no-array.yaml index 1470fe1ec8..da13b19810 100644 --- a/tests/schema/fail/servers.yaml +++ b/tests/schema/fail/servers-no-array.yaml @@ -6,6 +6,6 @@ info: title: API version: 1.0.0 paths: {} -servers: +servers: # must be an array url: /v1 description: Run locally. 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..179fa02666 --- /dev/null +++ b/tests/schema/fail/tags-array-invalid-items.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +tags: + - true # must be an object + - description: no name # object must have `name` field + - name: true # must be a string + description: true # must be a string + externalDocs: true # must be an object + - name: foo + externalDocs: + url: true # must be a string + - name: bar + externalDocs: # object must have `url` field + description: true # must be 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..4e7192e66e --- /dev/null +++ b/tests/schema/fail/tags-no-array.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +tags: {} # must be an array +paths: {} diff --git a/tests/schema/fail/unknown_container.yaml b/tests/schema/fail/unknown_container.yaml index 7f31e86053..843facf23e 100644 --- a/tests/schema/fail/unknown_container.yaml +++ b/tests/schema/fail/unknown_container.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 -# this example should fail as overlays is not a valid top-level object/keyword +# this example should fail as `overlays` is not a valid top-level field info: title: API diff --git a/tests/schema/fail/webhooks-no-object.yaml b/tests/schema/fail/webhooks-no-object.yaml new file mode 100644 index 0000000000..40af17d2a0 --- /dev/null +++ b/tests/schema/fail/webhooks-no-object.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +webhooks: [] # must be an object diff --git a/tests/schema/fail/xml-no-object.yaml b/tests/schema/fail/xml-no-object.yaml new file mode 100644 index 0000000000..1172362256 --- /dev/null +++ b/tests/schema/fail/xml-no-object.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: + schemas: + Attr: + type: string + xml: true # must be an object \ No newline at end of file diff --git a/tests/schema/fail/xml-object-wrong-field-types.yaml b/tests/schema/fail/xml-object-wrong-field-types.yaml new file mode 100644 index 0000000000..a7936d5d9b --- /dev/null +++ b/tests/schema/fail/xml-object-wrong-field-types.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: + schemas: + Attr: + type: string + xml: + nodeType: true # must be a string + name: true # must be a string + namespace: true # must be a string + prefix: true # must be a string + attribute: 42 # must be a boolean + wrapped: 42 # must be a boolean 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..bb56b870e0 --- /dev/null +++ b/tests/schema/pass/deprecated-example-in-schema-object.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +paths: + /user: + parameters: + - in: query + name: example + 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 92142a94a6..b7d8e737ad 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 dafae3991f..50d2d9c006 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..57b1d93fe8 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.1", () => { + 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.1.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.1.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 +})