Repository: stoplightio/spectral
Pull request #1582 (2021-04-22 13:40:13)

feat(cli): register JSON Schema Draft 2020-12 format
Related to https://github.com/stoplightio/spectral/issues/1556 and https://github.com/stoplightio/platform-internal/issues/5792

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



Patch:
diff --git a/docs/getting-started/3-rulesets.md b/docs/getting-started/3-rulesets.md
index 91f963f7..4bc19d3a 100644
--- a/docs/getting-started/3-rulesets.md
+++ b/docs/getting-started/3-rulesets.md
@@ -61,6 +61,7 @@ Formats are an optional way to specify which API description formats a rule, or
 - `json-schema-draft6` (`$schema` says this is JSON Schema Draft 06)
 - `json-schema-draft7` (`$schema` says this is JSON Schema Draft 07)
 - `json-schema-2019-09` (`$schema` says this is JSON Schema 2019-09)
+- `json-schema-2020-12` (`$schema` says this is JSON Schema 2020-12)
 
 Specifying the format is optional, so you can completely ignore this if all the rules you are writing apply to any document you lint, or if you have specific rulesets for different formats. If you'd like to use one ruleset for multiple formats, the formats key is here to help.
 
diff --git a/src/formats/consts.ts b/src/formats/consts.ts
index bc7c62bf..e4b50ef7 100644
--- a/src/formats/consts.ts
+++ b/src/formats/consts.ts
@@ -3,11 +3,12 @@ import { isAsyncApiv2 } from './asyncapi';
 import {
   isJSONSchema,
   isJSONSchemaDraft2019_09,
+  isJSONSchemaDraft2020_12,
   isJSONSchemaDraft4,
   isJSONSchemaDraft6,
   isJSONSchemaDraft7,
   isJSONSchemaLoose,
-} from './json-schema';
+} from './jsonSchema';
 import { isOpenApiv2, isOpenApiv3 } from './openapi';
 
 export const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
@@ -20,6 +21,7 @@ export const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
   ['json-schema-draft6', isJSONSchemaDraft6, 'JSON Schema Draft 6'],
   ['json-schema-draft7', isJSONSchemaDraft7, 'JSON Schema Draft 7'],
   ['json-schema-2019-09', isJSONSchemaDraft2019_09, 'JSON Schema Draft 2019-09'],
+  ['json-schema-2020-12', isJSONSchemaDraft2020_12, 'JSON Schema Draft 2020-12'],
 ];
 
 export const KNOWN_RULESETS = ['spectral:oas', 'spectral:asyncapi'];
diff --git a/src/formats/index.ts b/src/formats/index.ts
index 709a3454..617d35cf 100644
--- a/src/formats/index.ts
+++ b/src/formats/index.ts
@@ -1,4 +1,4 @@
 export * from './openapi';
 export * from './asyncapi';
-export * from './json-schema';
+export * from './jsonSchema';
 export * from './consts';
diff --git a/src/formats/json-schema.ts b/src/formats/json-schema.ts
deleted file mode 100644
index 06ccd810..00000000
--- a/src/formats/json-schema.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { isObject } from 'lodash';
-import { JSONSchema } from '../types';
-
-const KNOWN_JSON_SCHEMA_TYPES = ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'];
-const KNOWN_JSON_SCHEMA_COMBINERS = ['allOf', 'oneOf', 'anyOf'];
-const $SCHEMA_DRAFT4_REGEX = /^https?:\/\/json-schema.org\/draft-04\/(?:hyper-)?schema#?$/;
-const $SCHEMA_DRAFT6_REGEX = /^https?:\/\/json-schema.org\/draft-06\/(?:hyper-)?schema#?$/;
-const $SCHEMA_DRAFT7_REGEX = /^https?:\/\/json-schema.org\/draft-07\/(?:hyper-)?schema#?$/;
-const $SCHEMA_DRAFT_2019_09_REGEX = /^https?:\/\/json-schema.org\/draft\/2019-09\/(?:hyper-)?schema#?$/;
-
-const hasValidJSONSchemaType = (document: object | Partial<{ type: unknown }>): boolean =>
-  'type' in document && typeof document.type === 'string' && KNOWN_JSON_SCHEMA_TYPES.includes(document.type);
-
-const hasValidJSONSchemaCombiner = (document: object): boolean =>
-  KNOWN_JSON_SCHEMA_COMBINERS.some(combiner => combiner in document && isObject(document[combiner]));
-
-export const isJSONSchema = (document: unknown): document is JSONSchema & { $schema: string } =>
-  isObject(document) &&
-  '$schema' in document &&
-  String((document as Partial<Pick<JSONSchema, '$schema'>>).$schema).includes('//json-schema.org/');
-
-export const isJSONSchemaLoose = (document: unknown): boolean =>
-  isObject(document) &&
-  (isJSONSchema(document) ||
-    hasValidJSONSchemaType(document) ||
-    hasValidJSONSchemaCombiner(document) ||
-    'not' in document);
-
-export const isJSONSchemaDraft4 = (document: unknown): boolean =>
-  isJSONSchema(document) && $SCHEMA_DRAFT4_REGEX.test(document.$schema);
-
-export const isJSONSchemaDraft6 = (document: unknown): boolean =>
-  isJSONSchema(document) && $SCHEMA_DRAFT6_REGEX.test(document.$schema);
-
-export const isJSONSchemaDraft7 = (document: unknown): boolean =>
-  isJSONSchema(document) && $SCHEMA_DRAFT7_REGEX.test(document.$schema);
-
-export const isJSONSchemaDraft2019_09 = (document: unknown): boolean =>
-  isJSONSchema(document) && $SCHEMA_DRAFT_2019_09_REGEX.test(document.$schema);
diff --git a/src/formats/jsonSchema.ts b/src/formats/jsonSchema.ts
new file mode 100644
index 00000000..fcd22efe
--- /dev/null
+++ b/src/formats/jsonSchema.ts
@@ -0,0 +1,57 @@
+import { isObject } from 'lodash';
+import { JSONSchema } from '../types';
+
+const KNOWN_JSON_SCHEMA_TYPES = ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'];
+const KNOWN_JSON_SCHEMA_COMPOUND_KEYWORDS = ['allOf', 'oneOf', 'anyOf', 'not', 'if'];
+
+const SCHEMA_DRAFT_REGEX = /^https?:\/\/json-schema.org\/(?:draft-0([467])|draft\/(20(?:19-09|20-12)))\/(?:hyper-)?schema#?$/;
+
+const hasValidJSONSchemaType = (document: Partial<{ type?: unknown }>): boolean => {
+  if (!('type' in document)) return false;
+  if (typeof document.type === 'string') {
+    return KNOWN_JSON_SCHEMA_TYPES.includes(document.type);
+  }
+
+  return Array.isArray(document.type) && document.type.every(type => KNOWN_JSON_SCHEMA_TYPES.includes(type));
+};
+
+const hasValidJSONSchemaCompoundKeyword = (document: object): boolean =>
+  KNOWN_JSON_SCHEMA_COMPOUND_KEYWORDS.some(combiner => combiner in document && isObject(document[combiner]));
+
+function hasSchemaVersion(document: unknown): document is JSONSchema & { $schema: string } {
+  return (
+    isObject(document) &&
+    '$schema' in document &&
+    typeof (document as Partial<{ $schema: unknown }>).$schema === 'string'
+  );
+}
+
+export const isJSONSchema = (document: unknown): document is JSONSchema & { $schema: string } =>
+  hasSchemaVersion(document) && document.$schema.includes('//json-schema.org/');
+
+export const isJSONSchemaLoose = (document: unknown): boolean =>
+  isObject(document) &&
+  (isJSONSchema(document) || hasValidJSONSchemaType(document) || hasValidJSONSchemaCompoundKeyword(document));
+
+export const isJSONSchemaDraft4 = createJSONSchemaDraftMatcher('draft4');
+export const isJSONSchemaDraft6 = createJSONSchemaDraftMatcher('draft6');
+export const isJSONSchemaDraft7 = createJSONSchemaDraftMatcher('draft7');
+export const isJSONSchemaDraft2019_09 = createJSONSchemaDraftMatcher('draft2019-09');
+export const isJSONSchemaDraft2020_12 = createJSONSchemaDraftMatcher('draft2020-12');
+
+function createJSONSchemaDraftMatcher(draft: string) {
+  return (document: unknown): boolean => isJSONSchema(document) && extractDraftVersion(document.$schema) === draft;
+}
+
+export function extractDraftVersion($schema: string): string | null {
+  const match = SCHEMA_DRAFT_REGEX.exec($schema);
+  return match !== null ? `draft${match[1] ?? match[2]}` : null;
+}
+
+export function detectDialect(document: unknown): string | null {
+  if (!isJSONSchema(document)) {
+    return null;
+  }
+
+  return extractDraftVersion(document.$schema);
+}


Test patch:
diff --git a/src/formats/__tests__/json-schema.test.ts b/src/formats/__tests__/jsonSchema.test.ts
similarity index 87%
rename from src/formats/__tests__/json-schema.test.ts
rename to src/formats/__tests__/jsonSchema.test.ts
index 04a5bdaf..b5358078 100644
--- a/src/formats/__tests__/json-schema.test.ts
+++ b/src/formats/__tests__/jsonSchema.test.ts
@@ -1,11 +1,12 @@
 import {
   isJSONSchema,
   isJSONSchemaDraft2019_09,
+  isJSONSchemaDraft2020_12,
   isJSONSchemaDraft4,
   isJSONSchemaDraft6,
   isJSONSchemaDraft7,
   isJSONSchemaLoose,
-} from '../json-schema';
+} from '../jsonSchema';
 
 describe('JSON Schema format', () => {
   describe('JSON Schema strict', () => {
@@ -196,18 +197,21 @@ describe('JSON Schema format', () => {
     });
   });
 
-  describe('JSON Schema Draft 2019-09', () => {
+  describe.each<[string, (document: unknown) => boolean]>([
+    ['2019-09', isJSONSchemaDraft2019_09],
+    ['2020-12', isJSONSchemaDraft2020_12],
+  ])('JSON Schema Draft %s', (draft, fn) => {
     it.each([
-      'http://json-schema.org/draft/2019-09/schema#',
-      'https://json-schema.org/draft/2019-09/schema#',
-      'http://json-schema.org/draft/2019-09/schema',
-      'https://json-schema.org/draft/2019-09/schema',
-      'http://json-schema.org/draft/2019-09/hyper-schema#',
-      'https://json-schema.org/draft/2019-09/hyper-schema#',
-      'http://json-schema.org/draft/2019-09/hyper-schema',
-      'https://json-schema.org/draft/2019-09/hyper-schema',
+      `http://json-schema.org/draft/${draft}/schema#`,
+      `https://json-schema.org/draft/${draft}/schema#`,
+      `http://json-schema.org/draft/${draft}/schema`,
+      `https://json-schema.org/draft/${draft}/schema`,
+      `http://json-schema.org/draft/${draft}/hyper-schema#`,
+      `https://json-schema.org/draft/${draft}/hyper-schema#`,
+      `http://json-schema.org/draft/${draft}/hyper-schema`,
+      `https://json-schema.org/draft/${draft}/hyper-schema`,
     ])('recognizes %s schema correctly', $schema => {
-      expect(isJSONSchemaDraft2019_09({ $schema })).toBe(true);
+      expect(fn({ $schema })).toBe(true);
     });
 
     it.each([
@@ -217,18 +221,18 @@ describe('JSON Schema format', () => {
       'http://json-schema.org/draft-06/schema#',
       'http://json-schema.org/draft-06/hyper-schema#',
     ])('does not recognize %s schema', $schema => {
-      expect(isJSONSchemaDraft2019_09({ $schema })).toBe(false);
+      expect(fn({ $schema })).toBe(false);
     });
 
     it('does not recognize invalid document', () => {
-      expect(isJSONSchemaDraft2019_09({ $schema: '2.0' })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({ $schema: 'json-schema' })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({ $schema: 2 })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({ swagger: null })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({ allOf: [] })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({ type: 'string' })).toBe(false);
-      expect(isJSONSchemaDraft2019_09({})).toBe(false);
-      expect(isJSONSchemaDraft2019_09(null)).toBe(false);
+      expect(fn({ $schema: '2.0' })).toBe(false);
+      expect(fn({ $schema: 'json-schema' })).toBe(false);
+      expect(fn({ $schema: 2 })).toBe(false);
+      expect(fn({ swagger: null })).toBe(false);
+      expect(fn({ allOf: [] })).toBe(false);
+      expect(fn({ type: 'string' })).toBe(false);
+      expect(fn({})).toBe(false);
+      expect(fn(null)).toBe(false);
     });
   });
 });
diff --git a/test-harness/scenarios/rules-matching-multiple-places.scenario b/test-harness/scenarios/rules-matching-multiple-places.scenario
index d6526fa2..8d61b637 100644
--- a/test-harness/scenarios/rules-matching-multiple-places.scenario
+++ b/test-harness/scenarios/rules-matching-multiple-places.scenario
@@ -33,9 +33,9 @@ rules:
 1
 ====stdout====
 {document}
-  1:1   warning  unrecognized-format       The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09]
-  6:15    error  valid-user-properties     must match the pattern '/^string$/'                                                                                                                                                                          schemas.user.properties.name.type
- 10:15    error  valid-user-properties     must match the pattern '/^string$/'                                                                                                                                                                          schemas.user.properties.occupation.type
- 11:11    error  require-user-and-address  `address` property is not truthy                                                                                                                                                                             schemas.address
+  1:1   warning  unrecognized-format       The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09, json-schema-2020-12]
+  6:15    error  valid-user-properties     must match the pattern '/^string$/'                                                                                                                                                                                               schemas.user.properties.name.type
+ 10:15    error  valid-user-properties     must match the pattern '/^string$/'                                                                                                                                                                                               schemas.user.properties.occupation.type
+ 11:11    error  require-user-and-address  `address` property is not truthy                                                                                                                                                                                                  schemas.address
 
 ✖ 4 problems (3 errors, 1 warning, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/unrecognized-format.scenario b/test-harness/scenarios/unrecognized-format.scenario
index 1ac19f4b..554ca858 100644
--- a/test-harness/scenarios/unrecognized-format.scenario
+++ b/test-harness/scenarios/unrecognized-format.scenario
@@ -7,6 +7,6 @@ info: {}
 {bin} lint {document}
 ====stdout====
 {document}
- 1:1  warning  unrecognized-format  The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09]
+ 1:1  warning  unrecognized-format  The provided document does not match any of the registered formats [oas2, oas3, asyncapi2, json-schema, json-schema-loose, json-schema-draft4, json-schema-draft6, json-schema-draft7, json-schema-2019-09, json-schema-2020-12]
 
 ✖ 1 problem (0 errors, 1 warning, 0 infos, 0 hints)