diff --git a/.changeset/angry-taxis-share.md b/.changeset/angry-taxis-share.md new file mode 100644 index 0000000000..d13459573b --- /dev/null +++ b/.changeset/angry-taxis-share.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.js b/__tests__/lint/validate-schema-formats/snapshot.js new file mode 100644 index 0000000000..69f782a0fb --- /dev/null +++ b/__tests__/lint/validate-schema-formats/snapshot.js @@ -0,0 +1,188 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E lint validate-schema-formats 1`] = ` + +validating openapi.yaml... +[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. + + +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 2f85404098..f95480714d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4032,6 +4032,39 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "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", @@ -6269,6 +6302,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/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -12637,6 +12686,7 @@ "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", + "ajv-formats": "^3.0.1", "colorette": "^1.2.0", "https-proxy-agent": "^7.0.4", "js-levenshtein": "^1.1.6", @@ -15104,6 +15154,7 @@ "@types/node": "^20.11.5", "@types/node-fetch": "^2.5.7", "@types/pluralize": "^0.0.29", + "ajv-formats": "^3.0.1", "colorette": "^1.2.0", "https-proxy-agent": "^7.0.4", "js-levenshtein": "^1.1.6", @@ -15819,6 +15870,27 @@ } } }, + "ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "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", @@ -17479,6 +17551,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==" + }, "fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", diff --git a/packages/core/package.json b/packages/core/package.json index f3cc99f103..a75728753a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -37,6 +37,7 @@ "dependencies": { "@redocly/ajv": "^8.11.2", "@redocly/config": "^0.20.1", + "ajv-formats": "^3.0.1", "colorette": "^1.2.0", "https-proxy-agent": "^7.0.4", "js-levenshtein": "^1.1.6", diff --git a/packages/core/src/rules/ajv.ts b/packages/core/src/rules/ajv.ts index 0e26faa81c..97e894fbda 100644 --- a/packages/core/src/rules/ajv.ts +++ b/packages/core/src/rules/ajv.ts @@ -1,4 +1,5 @@ import Ajv from '@redocly/ajv/dist/2020'; +import addFormats from 'ajv-formats'; import { escapePointer } from '../ref-utils'; import type { Location } from '../ref-utils'; @@ -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); // FIXME: type mismatch } return ajvInstance; } diff --git a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts index 951dde7ac8..b9dc4f42e9 100644 --- a/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts +++ b/packages/core/src/rules/oas3/no-invalid-media-type-examples.ts @@ -37,7 +37,7 @@ export const ValidContentExamples: Oas3Rule = (opts) => { location = isMultiple ? resolved.location.child('value') : resolved.location; example = resolved.node; } - if (isMultiple && typeof example.value === 'undefined') { + if (isMultiple && typeof example?.value === 'undefined') { return; } validateExample(