diff --git a/.changeset/cyan-months-visit.md b/.changeset/cyan-months-visit.md new file mode 100644 index 0000000000..d13459573b --- /dev/null +++ b/.changeset/cyan-months-visit.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +"@redocly/cli": minor +--- + +Added validation for JSON Schema format. diff --git a/__tests__/lint/validate-schema-formats/openapi.yaml b/__tests__/lint/validate-schema-formats/openapi.yaml new file mode 100644 index 0000000000..5a181b737f --- /dev/null +++ b/__tests__/lint/validate-schema-formats/openapi.yaml @@ -0,0 +1,67 @@ +openapi: 3.1.0 +info: + title: Test format validation + version: 1.0.0 +paths: + /test: + get: + responses: + 200: + description: Validate formats using $refs. + content: + application/json: + schema: + type: string + format: date-time + examples: + Correct: + $ref: '#/components/examples/DateTime' + Incorrect: + $ref: '#/components/examples/Date' + +components: + schemas: + Date: + type: string + format: date + examples: + - '2000-01-01' # correct + - 2000 # incorrect type + - incorrect + Email: + description: Email address for ticket purchaser. + type: string + format: email + examples: + - museum-lover@example.com # correct + - wrong.format + Id: + type: string + format: uuid + examples: + - 3be6453c-03eb-4357-ae5a-984a0e574a54 # correct + - incorrect + - 42 # wrong type + Time: + type: string + pattern: '^([01]\d|2[0-3]):?([0-5]\d)$' + description: Time the museum opens on a specific date. Uses 24 hour time format (`HH:mm`). + examples: + - 09:00 # correct + - incorrect + - 09.00 # wrong type + Oneof: + type: string + oneOf: + - format: date + - pattern: ^(month|year)ly$ + examples: + - '2000-01-01' # correct + - monthly # correct + - wrong + + examples: + Date: + value: '2000-01-01' + DateTime: + value: '2000-01-01T12:00:00Z' diff --git a/__tests__/lint/validate-schema-formats/redocly.yaml b/__tests__/lint/validate-schema-formats/redocly.yaml new file mode 100644 index 0000000000..2bee81130c --- /dev/null +++ b/__tests__/lint/validate-schema-formats/redocly.yaml @@ -0,0 +1,9 @@ +apis: + main: + root: openapi.yaml + +rules: + no-invalid-parameter-examples: error + no-invalid-media-type-examples: error + no-invalid-schema-examples: error + no-unresolved-refs: error diff --git a/__tests__/lint/validate-schema-formats/snapshot.txt b/__tests__/lint/validate-schema-formats/snapshot.txt new file mode 100644 index 0000000000..43383f429a --- /dev/null +++ b/__tests__/lint/validate-schema-formats/snapshot.txt @@ -0,0 +1,183 @@ +[1] openapi.yaml:65:14 at #/components/examples/Date/value + +Example value must conform to the schema: must match format "date-time". + +63 | examples: +64 | Date: +65 | value: '2000-01-01' + | ^^^^^^^^^^^^ +66 | DateTime: +67 | value: '2000-01-01T12:00:00Z' + +referenced from openapi.yaml:13:15 at #/paths/~1test/get/responses/200/content/application~1json + +Error was generated by the no-invalid-media-type-examples rule. + + +[2] openapi.yaml:29:11 at #/components/schemas/Date/examples/1 + +Example value must conform to the schema: type must be string. + +27 | examples: +28 | - '2000-01-01' # correct +29 | - 2000 # incorrect type + | ^^^^ +30 | - incorrect +31 | Email: + +referenced from openapi.yaml:25:7 at #/components/schemas/Date + +Error was generated by the no-invalid-schema-examples rule. + + +[3] openapi.yaml:30:11 at #/components/schemas/Date/examples/2 + +Example value must conform to the schema: must match format "date". + +28 | - '2000-01-01' # correct +29 | - 2000 # incorrect type +30 | - incorrect + | ^^^^^^^^^ +31 | Email: +32 | description: Email address for ticket purchaser. + +referenced from openapi.yaml:25:7 at #/components/schemas/Date + +Error was generated by the no-invalid-schema-examples rule. + + +[4] openapi.yaml:37:11 at #/components/schemas/Email/examples/1 + +Example value must conform to the schema: must match format "email". + +35 | examples: +36 | - museum-lover@example.com # correct +37 | - wrong.format + | ^^^^^^^^^^^^ +38 | Id: +39 | type: string + +referenced from openapi.yaml:32:7 at #/components/schemas/Email + +Error was generated by the no-invalid-schema-examples rule. + + +[5] openapi.yaml:43:11 at #/components/schemas/Id/examples/1 + +Example value must conform to the schema: must match format "uuid". + +41 | examples: +42 | - 3be6453c-03eb-4357-ae5a-984a0e574a54 # correct +43 | - incorrect + | ^^^^^^^^^ +44 | - 42 # wrong type +45 | Time: + +referenced from openapi.yaml:39:7 at #/components/schemas/Id + +Error was generated by the no-invalid-schema-examples rule. + + +[6] openapi.yaml:44:11 at #/components/schemas/Id/examples/2 + +Example value must conform to the schema: type must be string. + +42 | - 3be6453c-03eb-4357-ae5a-984a0e574a54 # correct +43 | - incorrect +44 | - 42 # wrong type + | ^^ +45 | Time: +46 | type: string + +referenced from openapi.yaml:39:7 at #/components/schemas/Id + +Error was generated by the no-invalid-schema-examples rule. + + +[7] openapi.yaml:51:11 at #/components/schemas/Time/examples/1 + +Example value must conform to the schema: must match pattern "^([01]\d|2[0-3]):?([0-5]\d)$". + +49 | examples: +50 | - 09:00 # correct +51 | - incorrect + | ^^^^^^^^^ +52 | - 09.00 # wrong type +53 | Oneof: + +referenced from openapi.yaml:46:7 at #/components/schemas/Time + +Error was generated by the no-invalid-schema-examples rule. + + +[8] openapi.yaml:52:11 at #/components/schemas/Time/examples/2 + +Example value must conform to the schema: type must be string. + +50 | - 09:00 # correct +51 | - incorrect +52 | - 09.00 # wrong type + | ^^^^^ +53 | Oneof: +54 | type: string + +referenced from openapi.yaml:46:7 at #/components/schemas/Time + +Error was generated by the no-invalid-schema-examples rule. + + +[9] openapi.yaml:61:11 at #/components/schemas/Oneof/examples/2 + +Example value must conform to the schema: must match format "date". + +59 | - '2000-01-01' # correct +60 | - monthly # correct +61 | - wrong + | ^^^^^ +62 | +63 | examples: + +referenced from openapi.yaml:54:7 at #/components/schemas/Oneof + +Error was generated by the no-invalid-schema-examples rule. + + +[10] openapi.yaml:61:11 at #/components/schemas/Oneof/examples/2 + +Example value must conform to the schema: must match pattern "^(month|year)ly$". + +59 | - '2000-01-01' # correct +60 | - monthly # correct +61 | - wrong + | ^^^^^ +62 | +63 | examples: + +referenced from openapi.yaml:54:7 at #/components/schemas/Oneof + +Error was generated by the no-invalid-schema-examples rule. + + +[11] openapi.yaml:61:11 at #/components/schemas/Oneof/examples/2 + +Example value must conform to the schema: must match exactly one schema in oneOf. + +59 | - '2000-01-01' # correct +60 | - monthly # correct +61 | - wrong + | ^^^^^ +62 | +63 | examples: + +referenced from openapi.yaml:54:7 at #/components/schemas/Oneof + +Error was generated by the no-invalid-schema-examples rule. + + + +validating openapi.yaml... +openapi.yaml: validated in ms + +❌ Validation failed with 11 errors. +run `redocly lint --generate-ignore-file` to add all problems to the ignore file. + diff --git a/package-lock.json b/package-lock.json index 8c628fc601..dfb5cd4167 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4894,6 +4894,39 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/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-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -6989,6 +7022,22 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "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/fast-xml-parser": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.2.tgz", @@ -13428,6 +13477,7 @@ "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.26.3", + "ajv-formats": "^2.1.1", "colorette": "^1.2.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", @@ -15998,6 +16048,7 @@ "@types/js-yaml": "^4.0.3", "@types/minimatch": "^5.1.2", "@types/pluralize": "^0.0.29", + "ajv-formats": "^2.1.1", "colorette": "^1.2.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", @@ -17106,6 +17157,27 @@ } } }, + "ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "requires": { + "ajv": "^8.0.0" + }, + "dependencies": { + "ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + } + } + }, "ajv-keywords": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", @@ -18615,6 +18687,11 @@ "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, + "fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" + }, "fast-xml-parser": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.2.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index d9e526d7e9..f7dde28b6e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -59,6 +59,7 @@ "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.26.3", + "ajv-formats": "^2.1.1", "colorette": "^1.2.0", "js-levenshtein": "^1.1.6", "js-yaml": "^4.1.0", diff --git a/packages/core/src/rules/ajv.ts b/packages/core/src/rules/ajv.ts index 31a0bef121..45dc793539 100644 --- a/packages/core/src/rules/ajv.ts +++ b/packages/core/src/rules/ajv.ts @@ -1,3 +1,4 @@ +import addFormats from 'ajv-formats'; import Ajv from '@redocly/ajv/dist/2020.js'; import { escapePointer } from '../ref-utils.js'; @@ -22,7 +23,7 @@ function getAjv(resolve: ResolveFn, allowAdditionalProperties: boolean) { validateSchema: false, discriminator: true, allowUnionTypes: true, - validateFormats: false, // TODO: fix it + validateFormats: true, defaultUnevaluatedProperties: allowAdditionalProperties, loadSchemaSync(base: string, $ref: string, $id: string) { const resolvedRef = resolve({ $ref }, base.split('#')[0]); @@ -31,6 +32,7 @@ function getAjv(resolve: ResolveFn, allowAdditionalProperties: boolean) { }, logger: false, }); + addFormats(ajvInstance as any); // TODO: fix type mismatch } return ajvInstance; } diff --git a/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts b/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts index 9cd9647458..3068384970 100644 --- a/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts +++ b/packages/core/src/rules/oas3/__tests__/no-invalid-media-type-examples.test.ts @@ -609,7 +609,7 @@ describe('no-invalid-media-type-examples', () => { }); expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`); - }, 30_000); + }); it('should report invalid value when externalValue is also set', async () => { const document = parseYamlToDocument( @@ -683,7 +683,7 @@ describe('no-invalid-media-type-examples', () => { }, ] `); - }, 30_000); + }); it('should first report on unresolved ref rather than fail on validation', async () => { const document = parseYamlToDocument(