Repository: stoplightio/spectral
Pull request #576 (2019-09-20 01:26:41)

fix(schema): support draft 6 and 7
Fixes https://github.com/stoplightio/spectral/issues/573

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

Contains 2 unrelated fixes (see commit messages).

Regarding ajv-better-errors and the exception that's thrown - I'll try to provide a fix for the package.

This is where it fails:
https://github.com/atlassian/better-ajv-errors/blob/126ee2e45ee829e69e3b2f3f6d56eb49d5c42135/src/validation-errors/enum.js#L52
https://github.com/janl/node-jsonpointer/blob/master/jsonpointer.js#L60
Basically, jsonpointer does not expect non-object value.


Patch:
diff --git a/src/functions/schema.ts b/src/functions/schema.ts
index 0b2173d3..ed5bbb0e 100644
--- a/src/functions/schema.ts
+++ b/src/functions/schema.ts
@@ -1,6 +1,9 @@
+import { decodePointerFragment } from '@stoplight/json';
 import * as AJV from 'ajv';
 import { ValidateFunction } from 'ajv';
 import * as jsonSpecv4 from 'ajv/lib/refs/json-schema-draft-04.json';
+import * as jsonSpecv6 from 'ajv/lib/refs/json-schema-draft-06.json';
+import * as jsonSpecv7 from 'ajv/lib/refs/json-schema-draft-07.json';
 import { IOutputError } from 'better-ajv-errors';
 import { JSONSchema4, JSONSchema6 } from 'json-schema';
 import { IFunction, IFunctionResult, ISchemaOptions } from '../types';
@@ -31,6 +34,8 @@ const ajv = new AJV({
   logger,
 });
 ajv.addMetaSchema(jsonSpecv4);
+ajv.addMetaSchema(jsonSpecv6);
+ajv.addMetaSchema(jsonSpecv7);
 // @ts-ignore
 ajv._opts.defaultMeta = jsonSpecv4.id;
 // @ts-ignore
@@ -78,7 +83,7 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
 
   const path = paths.target || paths.given;
 
-  if (!targetVal)
+  if (targetVal === void 0)
     return [
       {
         path,
@@ -93,16 +98,29 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
     // we used the compiled validation now, hence this lookup here (see the logic above for more info)
     const validator = validators.get(schemaObj);
     if (!validator(targetVal) && validator.errors) {
-      results.push(
-        ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IAJVOutputError[]).map(
-          ({ error, path: errorPath }) => {
-            return {
+      try {
+        results.push(
+          ...(betterAjvErrors(schemaObj, targetVal, validator.errors, { format: 'js' }) as IAJVOutputError[]).map(
+            ({ error, path: errorPath }) => ({
               message: cleanAJVErrorMessage(error),
               path: [...path, ...(errorPath ? errorPath.replace(/^\//, '').split('/') : [])],
-            };
-          },
-        ),
-      );
+            }),
+          ),
+        );
+      } catch {
+        results.push(
+          ...validator.errors.map(({ message, dataPath }) => ({
+            message: message ? cleanAJVErrorMessage(message) : '',
+            path: [
+              ...path,
+              ...dataPath
+                .split('/')
+                .slice(1)
+                .map(decodePointerFragment),
+            ],
+          })),
+        );
+      }
     }
   } catch (ex) {
     if (ex instanceof AJV.MissingRefError) {


Test patch:
diff --git a/src/functions/__tests__/schema.test.ts b/src/functions/__tests__/schema.test.ts
index 1bb63a9a..e9aba031 100644
--- a/src/functions/__tests__/schema.test.ts
+++ b/src/functions/__tests__/schema.test.ts
@@ -6,6 +6,60 @@ function runSchema(target: any, schemaObj: object) {
 }
 
 describe('schema', () => {
+  describe('validates falsy values such as', () => {
+    test('empty string', () => {
+      const testSchema: JSONSchema6 = {
+        type: 'number',
+      };
+
+      expect(runSchema('', testSchema)).toEqual([
+        {
+          message: 'type should be number',
+          path: [],
+        },
+      ]);
+    });
+
+    test('zero', () => {
+      const testSchema: JSONSchema6 = {
+        type: 'string',
+      };
+
+      expect(runSchema(0, testSchema)).toEqual([
+        {
+          message: 'type should be string',
+          path: [],
+        },
+      ]);
+    });
+
+    test('false', () => {
+      const testSchema: JSONSchema6 = {
+        type: 'string',
+      };
+
+      expect(runSchema(false, testSchema)).toEqual([
+        {
+          message: 'type should be string',
+          path: [],
+        },
+      ]);
+    });
+
+    test('null', () => {
+      const testSchema: JSONSchema6 = {
+        type: 'string',
+      };
+
+      expect(runSchema(null, testSchema)).toEqual([
+        {
+          message: 'type should be string',
+          path: [],
+        },
+      ]);
+    });
+  });
+
   describe('when schema defines unknown format', () => {
     const testSchema = {
       type: 'string',
@@ -144,7 +198,7 @@ describe('schema', () => {
     });
   });
 
-  describe('handles duplicate JSONSchema4 ids', () => {
+  test('handles duplicate JSONSchema Draft 4 ids', () => {
     const testSchema: JSONSchema4 = {
       id: 'test',
       type: 'string',
@@ -164,7 +218,7 @@ describe('schema', () => {
     expect(runSchema('a', testSchema2)).toEqual([]);
   });
 
-  describe('handles duplicate JSONSchema6 ids', () => {
+  test('handles duplicate JSONSchema Draft 6 and 7 $ids', () => {
     const testSchema: JSONSchema6 = {
       $id: 'test',
       type: 'string',
@@ -183,4 +237,13 @@ describe('schema', () => {
     ]);
     expect(runSchema('a', testSchema2)).toEqual([]);
   });
+
+  test.each([4, 6, 7])('accepts draft %d', draft => {
+    const testSchema: JSONSchema6 = {
+      $schema: `http://json-schema.org/draft-0${draft}/schema#`,
+      type: 'string',
+    };
+
+    expect(runSchema.bind(null, 'd', testSchema)).not.toThrow();
+  });
 });
diff --git a/test-harness/scenarios/external-schemas-ruleset.scenario b/test-harness/scenarios/external-schemas-ruleset.scenario
new file mode 100644
index 00000000..f2dec0c3
--- /dev/null
+++ b/test-harness/scenarios/external-schemas-ruleset.scenario
@@ -0,0 +1,17 @@
+====test====
+Schemas referenced via $refs are resolved and used
+====document====
+openapi: 3.0.2
+info:
+  title: foo
+  description: baz
+====command====
+lint {document} --ruleset ./test-harness/scenarios/rulesets/external-schemas-ruleset.yaml
+====stdout====
+OpenAPI 3.x detected
+
+{document}
+ 3:10  error  info-title        should be equal to one of the allowed values
+ 4:16  error  info-description  should be equal to one of the allowed values
+
+✖ 2 problems (2 errors, 0 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/rulesets/external-schemas-ruleset.yaml b/test-harness/scenarios/rulesets/external-schemas-ruleset.yaml
new file mode 100644
index 00000000..f9324e4b
--- /dev/null
+++ b/test-harness/scenarios/rulesets/external-schemas-ruleset.yaml
@@ -0,0 +1,23 @@
+rules:
+  info-description:
+    message: "{{error}}"
+    severity: error
+    given: $.info.description
+    type: style
+    recommended: true
+    then:
+      function: schema # note, this rule could use pattern function, but for test purposes I made the rule use schema function
+      functionOptions:
+        schema:
+          $ref: ./schemas/foo-description.yaml#
+  info-title:
+    message: "{{error}}"
+    severity: error
+    given: $.info.title
+    type: style
+    recommended: true
+    then:
+      function: schema # note, as above, this rule could use pattern function, but for test purposes I made the rule use schema function
+      functionOptions:
+        schema:
+          $ref: ./schemas/stoplight-title.yaml#
diff --git a/test-harness/scenarios/rulesets/schemas/foo-description.yaml b/test-harness/scenarios/rulesets/schemas/foo-description.yaml
new file mode 100644
index 00000000..f5fe075e
--- /dev/null
+++ b/test-harness/scenarios/rulesets/schemas/foo-description.yaml
@@ -0,0 +1,6 @@
+$schema: http://json-schema.org/draft-07/schema#
+type: string
+enum:
+  - foo
+  - foo-bar
+  - bar-foo
diff --git a/test-harness/scenarios/rulesets/schemas/stoplight-title.yaml b/test-harness/scenarios/rulesets/schemas/stoplight-title.yaml
new file mode 100644
index 00000000..8bdabebb
--- /dev/null
+++ b/test-harness/scenarios/rulesets/schemas/stoplight-title.yaml
@@ -0,0 +1,6 @@
+$schema: http://json-schema.org/draft-06/schema#
+type: string
+enum:
+  - Stoplight
+  - Stoplight.io
+  - StoplightIO