Repository: stoplightio/spectral
Pull request #622 (2019-10-02 02:55:21)

feat(rule): expose {{value}} and {{path}} for use in messages
Closes https://github.com/stoplightio/spectral/issues/520

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No



P0lip: @philsturgeon or @marbemac could any of you review it? Would like to make it a part of 4.2.

Patch:
diff --git a/src/linter.ts b/src/linter.ts
index 9fe2281e..de25f634 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -3,11 +3,12 @@ import { get, has } from 'lodash';
 
 const { JSONPath } = require('jsonpath-plus');
 
-import { decodePointerFragment } from '@stoplight/json';
+import { decodePointerFragment, pathToPointer } from '@stoplight/json';
 import { Resolved } from './resolved';
 import { message } from './rulesets/message';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { IFunction, IGivenNode, IRuleResult, IRunRule, IThen } from './types';
+import { isObject } from './utils';
 
 // TODO(SO-23): unit test but mock whatShouldBeLinted
 export const lintNode = (
@@ -112,7 +113,17 @@ export const lintNode = (
               : message(rule.message, {
                   error: result.message,
                   property: path.length > 0 ? path[path.length - 1] : '',
+                  path: pathToPointer(path),
                   description: rule.description,
+                  get value() {
+                    // let's make it `value` lazy
+                    const value = resolved.getValueForJsonPath(path);
+                    if (isObject(value)) {
+                      return Array.isArray(value) ? 'Array[]' : 'Object{}';
+                    }
+
+                    return JSON.stringify(value);
+                  },
                 }),
           path,
           severity: getDiagnosticSeverity(rule.severity),
diff --git a/src/resolved.ts b/src/resolved.ts
index 202f5b3e..37027bd4 100644
--- a/src/resolved.ts
+++ b/src/resolved.ts
@@ -102,4 +102,9 @@ export class Resolved {
       range: location !== void 0 ? location.range : getDefaultRange(),
     };
   }
+
+  public getValueForJsonPath(path: JsonPath): unknown {
+    const parsedResult = this.getParsedForJsonPath(path);
+    return parsedResult === null ? void 0 : get(parsedResult.doc.parsed.data, parsedResult.path);
+  }
 }
diff --git a/src/rulesets/message.ts b/src/rulesets/message.ts
index e3c4f5a4..c0f2967c 100644
--- a/src/rulesets/message.ts
+++ b/src/rulesets/message.ts
@@ -1,7 +1,11 @@
+import { Segment } from '@stoplight/types';
+
 export interface IMessageVars {
-  property: string | number;
+  property: Segment;
   error: string;
   description?: string;
+  value: unknown;
+  path: string;
 }
 
 export type MessageInterpolator = (str: string, values: IMessageVars) => string;


Test patch:
diff --git a/src/__tests__/linter.jest.test.ts b/src/__tests__/linter.jest.test.ts
index e4721b83..cbc74449 100644
--- a/src/__tests__/linter.jest.test.ts
+++ b/src/__tests__/linter.jest.test.ts
@@ -1,5 +1,8 @@
 import * as path from '@stoplight/path';
+import { join } from '@stoplight/path';
+import { DiagnosticSeverity } from '@stoplight/types';
 import { isOpenApiv2, isOpenApiv3 } from '../formats';
+import { httpAndFileResolver } from '../resolvers/http-and-file';
 import { Spectral } from '../spectral';
 
 const customFunctionOASRuleset = path.join(__dirname, './__fixtures__/custom-functions-oas-ruleset.json');
@@ -74,4 +77,66 @@ describe('Linter', () => {
       }),
     ]);
   });
+
+  describe('evaluate {{value}} in validation messages', () => {
+    test('should print correct values for referenced files', async () => {
+      spectral = new Spectral({ resolver: httpAndFileResolver });
+
+      spectral.setRules({
+        'empty-is-falsy': {
+          severity: DiagnosticSeverity.Error,
+          recommended: true,
+          description: 'Should be falsy',
+          message: 'Value {{value}} should be falsy',
+          given: '$..empty',
+          then: {
+            function: 'falsy',
+          },
+        },
+      });
+
+      return expect(
+        spectral.run(
+          {
+            empty: {
+              $ref: './__fixtures__/petstore.merge.keys.oas3.json#/info/contact/url',
+            },
+            bar: {
+              empty: {
+                $ref: './__fixtures__/petstore.oas3.json#/servers',
+              },
+            },
+            foo: {
+              empty: {
+                $ref: './__fixtures__/petstore.oas3.json#/info',
+              },
+            },
+          },
+          {
+            resolve: {
+              documentUri: join(__dirname, 'foo.json'),
+            },
+          },
+        ),
+      ).resolves.toEqual(
+        expect.arrayContaining([
+          expect.objectContaining({
+            code: 'empty-is-falsy',
+            message: 'Value "https://example.com" should be falsy',
+            path: ['empty'],
+          }),
+          expect.objectContaining({
+            code: 'empty-is-falsy',
+            message: 'Value Array[] should be falsy',
+            path: ['bar', 'empty'],
+          }),
+          expect.objectContaining({
+            code: 'empty-is-falsy',
+            message: 'Value Object{} should be falsy',
+            path: ['foo', 'empty'],
+          }),
+        ]),
+      );
+    });
+  });
 });
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index 9faf0a1a..36b6c651 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -873,6 +873,122 @@ responses:: !!foo
     });
   });
 
+  describe('evaluate {{value}} in validation messages', () => {
+    test('should print primitive values', () => {
+      spectral = new Spectral();
+      spectral.setRules({
+        'header-parameter-names-kebab-case': {
+          severity: DiagnosticSeverity.Error,
+          recommended: true,
+          description: 'A parameter in the header should be written in kebab-case',
+          message: '{{value}} is not kebab-cased: {{error}}',
+          given: "$..parameters[?(@.in === 'header')]",
+          then: {
+            field: 'name',
+            function: 'pattern',
+            functionOptions: {
+              match: '^[a-z0-9]+((-[a-z0-9]+)+)?$',
+            },
+          },
+        },
+      });
+
+      return expect(
+        spectral.run({
+          parameters: [
+            {
+              in: 'header',
+              name: 'fooA',
+            },
+          ],
+          foo: {
+            parameters: [
+              {
+                in: 'header',
+                name: 'd 1',
+              },
+            ],
+          },
+        }),
+      ).resolves.toEqual([
+        expect.objectContaining({
+          code: 'header-parameter-names-kebab-case',
+          message: '"fooA" is not kebab-cased: must match the pattern \'^[a-z0-9]+((-[a-z0-9]+)+)?$\'',
+          path: ['parameters', '0', 'name'],
+        }),
+
+        expect.objectContaining({
+          code: 'header-parameter-names-kebab-case',
+          message: '"d 1" is not kebab-cased: must match the pattern \'^[a-z0-9]+((-[a-z0-9]+)+)?$\'',
+          path: ['foo', 'parameters', '0', 'name'],
+        }),
+      ]);
+    });
+
+    test('should not attempt to print complex values', () => {
+      spectral = new Spectral();
+      spectral.setRules({
+        'empty-is-falsy': {
+          severity: DiagnosticSeverity.Error,
+          recommended: true,
+          description: 'Should be falsy',
+          message: 'Value {{value}} should be falsy',
+          given: '$..empty',
+          then: {
+            function: 'falsy',
+          },
+        },
+      });
+
+      return expect(
+        spectral.run({
+          empty: {
+            a: 'b',
+          },
+          bar: {
+            empty: [13, { empty: 123 }],
+          },
+        }),
+      ).resolves.toEqual([
+        expect.objectContaining({
+          code: 'empty-is-falsy',
+          message: 'Value Object{} should be falsy',
+          path: ['empty'],
+        }),
+        expect.objectContaining({
+          code: 'empty-is-falsy',
+          message: 'Value Array[] should be falsy',
+          path: ['bar', 'empty'],
+        }),
+        expect.objectContaining({
+          code: 'empty-is-falsy',
+          message: 'Value 123 should be falsy',
+          path: ['bar', 'empty', '1', 'empty'], // fixme: 1 should be a number
+        }),
+      ]);
+    });
+  });
+
+  test('should evaluate {{path}} in validation messages', async () => {
+    await spectral.loadRuleset('spectral:oas3');
+    spectral.setRules({
+      'oas3-schema': {
+        ...spectral.rules['oas3-schema'],
+        message: 'Schema error at {{path}}',
+      },
+    });
+
+    return expect(spectral.run(invalidSchema)).resolves.toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          code: 'oas3-schema',
+          message: 'Schema error at #/paths/~1pets/get/responses/200',
+          path: ['paths', '/pets', 'get', 'responses', '200'],
+        }),
+      ]),
+    );
+  });
+
   test('should report ref siblings', async () => {
     await spectral.loadRuleset('spectral:oas');
 
diff --git a/src/rulesets/oas/__tests__/message.spec.ts b/src/rulesets/oas/__tests__/message.spec.ts
index 352f7e75..c235ea77 100644
--- a/src/rulesets/oas/__tests__/message.spec.ts
+++ b/src/rulesets/oas/__tests__/message.spec.ts
@@ -7,6 +7,8 @@ describe('message util', () => {
       message(template, {
         property: 'description',
         error: 'expected property to be truthy',
+        path: '',
+        value: void 0,
       }),
     ).toEqual('oops... "description" is missing;error: expected property to be truthy');
   });
@@ -17,6 +19,8 @@ describe('message util', () => {
       message(template, {
         property: 'baz',
         error: 'foo',
+        path: '',
+        value: void 0,
       }),
     ).toEqual('foofoobazfoofoo');
   });
@@ -27,6 +31,8 @@ describe('message util', () => {
       message(template, {
         property: 'description',
         error: 'expected property to be truthy',
+        path: '',
+        value: void 0,
       }),
     ).toEqual('missing :(');
   });