Repository: stoplightio/spectral (ts)

Issue:
oas3-valid-(content-)schema-example: Problem with nullable
**Describe the bug**

It seems that in some cases the rules oas3-valid-schema-example and oas3-valid-content-schema-example fail although the examples are valid. Seems to be an issue with nullable / null values.

I get in the command line:
```
  59:33  error  oas3-valid-content-schema-example  `1` property type should be string
 167:25  error  oas3-valid-content-schema-example  `1` property type should be string
 167:25  error  oas3-valid-schema-example          `1` property type should be string
```

**To Reproduce**

OpenAPI document openapi.yaml:
```
openapi: 3.0.2
info:
  version: 1.0.0
  title: openEO API
  description: ...
  contact:
    name: openEO Consortium
    url: 'http://www.openeo.org'
    email: openeo@list.tuwien.ac.at
  license:
    name: Apache 2.0
    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
tags:
  - name: EO Data Discovery
    description: ...
servers:
  - url: 'https://localhost/api'
    description: ...
paths:
  /collections:
    get:
      summary: Basic metadata for all datasets
      operationId: list-collections
      description: ...
      tags:
        - EO Data Discovery
      responses:
        '200':
          description: A list of collections and related links.
          content:
            application/json:
              schema:
                title: Collections Response
                type: object
                required:
                  - collections
                properties:
                  collections:
                    type: array
                    items:
                      $ref: '#/components/schemas/collection'
                example:
                  collections:
                    - extent:
                        temporal:
                          interval:
                            - - '2000-02-01T00:00:00Z'
                              - null
components:
  schemas:
    collection:
      title: STAC Collection
      type: object
      required:
        - extent
      properties:
        extent:
          type: object
          title: STAC Collection Extent
          description: ...
          required:
            - spatial
            - temporal
          properties:
            temporal:
              description: The *potential* temporal extent of the features in the collection.
              type: object
              properties:
                interval:
                  description: ...
                  type: array
                  minItems: 1
                  items:
                    description: ...
                    type: array
                    minItems: 2
                    maxItems: 2
                    items:
                      type: string
                      format: date-time
                      nullable: true
                    example:
                      - '2011-11-11T12:22:11Z'
                      - null
```

.spectral.yml
```
extends: "spectral:oas"
```

Run: `spectral lint openapi.yaml`

**Expected behavior**

No warnings/errors

**Environment (remove any that are not applicable):**
 - Library version: 5.0.0
 - OS: Windows 10

Pull request:
fix #894: oas3-valid-(content-)schema-example has problem with nullable
Fixes #894.

**Checklist**

- [ ] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

This PR tries to solve #894. 
~As the documentation for schemaPath states the sentence below, I guess it's fine to just turn on nullable in ajv without thinking about implications for non OpenAPI documents:~
> ~This may never be useful for anything other than the use-case of checking OpenAPI examples are valid~

Patch:
diff --git a/src/functions/schema-path.ts b/src/functions/schema-path.ts
index 364ecf64..785c6897 100644
--- a/src/functions/schema-path.ts
+++ b/src/functions/schema-path.ts
@@ -9,6 +9,8 @@
  */
 import { JSONPath } from 'jsonpath-plus';
 
+import { Optional } from '@stoplight/types';
+
 import { IFunction, IFunctionResult, IRule, RuleFunction } from '../types';
 import { getLintTargets } from '../utils';
 import { schema } from './schema';
@@ -17,6 +19,8 @@ export interface ISchemaPathOptions {
   schemaPath: string;
   // the `path.to.prop` to field, or special `@key` value to target keys for matched `given` object
   field?: string;
+  // The oasVersion, either 2 or 3 for OpenAPI Spec versions, could also be 3.1 or a larger number if there's a need for it, otherwise JSON Schema
+  oasVersion?: Optional<number>;
 }
 
 export type SchemaPathRule = IRule<RuleFunction.SCHEMAPATH, ISchemaPathOptions>;
@@ -33,7 +37,10 @@ export const schemaPath: IFunction<ISchemaPathOptions> = (targetVal, opts, paths
   for (const relevantItem of relevantItems) {
     const result = schema(
       relevantItem.value,
-      { schema: schemaObject },
+      {
+        schema: schemaObject,
+        oasVersion: opts.oasVersion,
+      },
       {
         given: paths.given,
         target: [...(paths.target || paths.given), ...relevantItem.path],
diff --git a/src/functions/schema.ts b/src/functions/schema.ts
index 7eab63a7..03d1486a 100644
--- a/src/functions/schema.ts
+++ b/src/functions/schema.ts
@@ -4,7 +4,6 @@ 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 { escapeRegExp } from 'lodash';
 import { IFunction, IFunctionResult, IRule, JSONSchema, RuleFunction } from '../types';
@@ -13,6 +12,8 @@ const betterAjvErrors = require('better-ajv-errors/lib/modern');
 
 export interface ISchemaOptions {
   schema: object;
+  // The oasVersion, either 2 or 3 for OpenAPI Spec versions, could also be 3.1 or a larger number if there's a need for it, otherwise JSON Schema
+  oasVersion?: Optional<number>;
 }
 
 export type SchemaRule = IRule<RuleFunction.SCHEMA, ISchemaOptions>;
@@ -33,26 +34,41 @@ const logger = {
   error: console.error,
 };
 
-const ajv = new AJV({
-  meta: false,
-  schemaId: 'auto',
-  jsonPointers: true,
-  unknownFormats: 'ignore',
-  logger,
-});
-ajv.addMetaSchema(jsonSpecv4);
-ajv.addMetaSchema(jsonSpecv6);
-ajv.addMetaSchema(jsonSpecv7);
-// @ts-ignore
-ajv._opts.defaultMeta = jsonSpecv4.id;
-// @ts-ignore
-ajv._refs['http://json-schema.org/schema'] = 'http://json-schema.org/draft-04/schema';
-
-ajv.addFormat('int32', { type: 'number', validate: oasFormatValidator.int32 });
-ajv.addFormat('int64', { type: 'number', validate: oasFormatValidator.int64 });
-ajv.addFormat('float', { type: 'number', validate: oasFormatValidator.float });
-ajv.addFormat('double', { type: 'number', validate: oasFormatValidator.double });
-ajv.addFormat('byte', { type: 'string', validate: oasFormatValidator.byte });
+const ajvInstances = {};
+
+function getAjv(oasVersion?: Optional<number>): AJV.Ajv {
+  const type: string = oasVersion && oasVersion >= 2 ? 'oas' + oasVersion : 'jsonschema';
+  if (typeof ajvInstances[type] !== 'undefined') {
+    return ajvInstances[type];
+  }
+
+  const ajvOpts: AJV.Options = {
+    meta: true, // Add default meta schemas (draft 7 at the moment)
+    schemaId: 'auto',
+    jsonPointers: true,
+    unknownFormats: 'ignore',
+    nullable: oasVersion === 3, // Support nullable for OAS3
+    logger,
+  };
+  const ajv = new AJV(ajvOpts);
+  // We need v4 for OpenAPI and it doesn't hurt to have v6 as well.
+  ajv.addMetaSchema(jsonSpecv4);
+  ajv.addMetaSchema(jsonSpecv6);
+
+  // @ts-ignore
+  ajv._opts.defaultMeta = jsonSpecv4.id;
+  // @ts-ignore
+  ajv._refs['http://json-schema.org/schema'] = 'http://json-schema.org/draft-04/schema';
+
+  ajv.addFormat('int32', { type: 'number', validate: oasFormatValidator.int32 });
+  ajv.addFormat('int64', { type: 'number', validate: oasFormatValidator.int64 });
+  ajv.addFormat('float', { type: 'number', validate: oasFormatValidator.float });
+  ajv.addFormat('double', { type: 'number', validate: oasFormatValidator.double });
+  ajv.addFormat('byte', { type: 'string', validate: oasFormatValidator.byte });
+
+  ajvInstances[type] = ajv;
+  return ajv;
+}
 
 function getSchemaId(schemaObj: JSONSchema): void | string {
   if ('$id' in schemaObj) {
@@ -65,7 +81,8 @@ function getSchemaId(schemaObj: JSONSchema): void | string {
 }
 
 const validators = new (class extends WeakMap<JSONSchema, ValidateFunction> {
-  public get(schemaObj: JSONSchema) {
+  public get(schemaObj: JSONSchema, oasVersion?: Optional<number>) {
+    const ajv = getAjv(oasVersion);
     const schemaId = getSchemaId(schemaObj);
     let validator = schemaId !== void 0 ? ajv.getSchema(schemaId) : void 0;
     if (validator !== void 0) {
@@ -128,7 +145,7 @@ export const schema: IFunction<ISchemaOptions> = (targetVal, opts, paths) => {
 
   try {
     // we used the compiled validation now, hence this lookup here (see the logic above for more info)
-    const validator = validators.get(schemaObj);
+    const validator = validators.get(schemaObj, opts.oasVersion);
     if (!validator(targetVal) && validator.errors) {
       try {
         results.push(
diff --git a/src/rulesets/oas/index.json b/src/rulesets/oas/index.json
index be7aafbd..3dd384ed 100644
--- a/src/rulesets/oas/index.json
+++ b/src/rulesets/oas/index.json
@@ -531,7 +531,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 2
         }
       }
     },
@@ -547,7 +548,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 2
         }
       }
     },
@@ -708,7 +710,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$.schema"
+          "schemaPath": "$.schema",
+          "oasVersion": 3
         }
       }
     },
@@ -724,7 +727,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$.schema"
+          "schemaPath": "$.schema",
+          "oasVersion": 3
         }
       }
     },
@@ -740,7 +744,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$.schema"
+          "schemaPath": "$.schema",
+          "oasVersion": 3
         }
       }
     },
@@ -756,7 +761,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 3
         }
       }
     },
@@ -772,7 +778,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 3
         }
       }
     },
@@ -788,7 +795,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 3
         }
       }
     },
@@ -804,7 +812,8 @@
         "function": "schemaPath",
         "functionOptions": {
           "field": "example",
-          "schemaPath": "$"
+          "schemaPath": "$",
+          "oasVersion": 3
         }
       }
     },


Test patch:
diff --git a/src/functions/__tests__/schema.test.ts b/src/functions/__tests__/schema.test.ts
index 54b128c1..68ba2e05 100644
--- a/src/functions/__tests__/schema.test.ts
+++ b/src/functions/__tests__/schema.test.ts
@@ -1,8 +1,9 @@
+import { Optional } from '@stoplight/types';
 import { JSONSchema4, JSONSchema6 } from 'json-schema';
 import { schema } from '../schema';
 
-function runSchema(target: any, schemaObj: object) {
-  return schema(target, { schema: schemaObj }, { given: [] }, { given: null, original: null } as any);
+function runSchema(target: any, schemaObj: object, oasVersion?: Optional<number>) {
+  return schema(target, { schema: schemaObj, oasVersion }, { given: [] }, { given: null, original: null } as any);
 }
 
 describe('schema', () => {