Repository: stoplightio/spectral
Pull request #797 (2019-11-21 00:30:49)

feat(cli): implement --resolver flag
Closes https://github.com/stoplightio/spectral/issues/717

**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No




Patch:
diff --git a/docs/guides/cli.md b/docs/guides/cli.md
index 1769c504..70f876b8 100644
--- a/docs/guides/cli.md
+++ b/docs/guides/cli.md
@@ -26,6 +26,7 @@ Other options include:
   --encoding, -e               text encoding to use                        [string] [default: "utf8"]
   --format, -f                 formatter to use for outputting results  [string] [default: "stylish"]
   --output, -o                 output to a file instead of stdout                            [string]
+  --resolver                   path to custom json-ref-resolver instance                     [string]
   --ruleset, -r                path/URL to a ruleset file                                    [string]
   --skip-rule, -s              ignore certain rules if they are causing trouble              [string]
   --fail-severity, -F          results of this level or above will trigger a failure exit code
@@ -55,3 +56,24 @@ Changing the fail severity will not effect output. To change what results Spectr
 To have requests made from Spectral be proxied through a server, you'd need to specify PROXY environment variable:
 
 `PROXY=<<PROXY_SERVER_ADDRESS>> spectral lint spec.yaml`
+
+## Custom $ref resolving
+
+If you want to customize $ref resolving, you can leverage `--resolver` flag and pass a path to the JS file exporting a custom instance of json-ref-resolver Resolver. 
+
+### Example
+
+Assuming the filename is called `my-resolver.js` and the content looks as follows, the path should look more or less like `--resolver=./my-resolver.js`.
+
+```js
+const { Resolver } = require('@stoplight/json-ref-resolver');
+
+module.exports = new Resolver({
+  resolvers: {
+    // pass any resolver for protocol you need
+  }
+});
+```
+
+
+You can learn more about $ref resolving in the [JS section](./javascript.md#using-custom-resolver).
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index d05c40e1..a44107f6 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -71,6 +71,10 @@ const lintCommand: CommandModule = {
           description: 'output to a file instead of stdout',
           type: 'string',
         },
+        resolver: {
+          description: 'path to custom json-ref-resolver instance',
+          type: 'string',
+        },
         ruleset: {
           alias: 'r',
           description: 'path/URL to a ruleset file',
@@ -124,7 +128,13 @@ const lintCommand: CommandModule = {
       displayOnlyFailures: boolean;
     };
 
-    return lint(documents, { format, output, encoding, ruleset, ...pick(config, ['skipRule', 'verbose', 'quiet']) })
+    return lint(documents, {
+      format,
+      output,
+      encoding,
+      ruleset,
+      ...pick<Partial<ILintConfig>, keyof ILintConfig>(config, ['skipRule', 'verbose', 'quiet', 'resolver']),
+    })
       .then(results => {
         if (displayOnlyFailures) {
           return filterResultsBySeverity(results, failSeverity);
@@ -144,8 +154,8 @@ const lintCommand: CommandModule = {
   },
 };
 
-const fail = (err: Error) => {
-  console.error(err);
+const fail = ({ message }: Error) => {
+  console.error(message);
   process.exitCode = 2;
 };
 
diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts
index c56e34c9..eefa86be 100644
--- a/src/cli/services/linter/linter.ts
+++ b/src/cli/services/linter/linter.ts
@@ -12,12 +12,12 @@ import {
   isOpenApiv3,
 } from '../../../formats';
 import { readParsable } from '../../../fs/reader';
-import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { isRuleEnabled } from '../../../runner';
 import { IRuleResult, Spectral } from '../../../spectral';
 import { FormatLookup, IParsedResult } from '../../../types';
 import { ILintConfig } from '../../../types/config';
 import { deduplicateResults, getRuleset, listFiles, skipRules } from './utils';
+import { getResolver } from './utils/getResolver';
 
 const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
   ['oas2', isOpenApiv2, 'OpenAPI 2.0 (Swagger) detected'],
@@ -31,7 +31,9 @@ const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
 ];
 
 export async function lint(documents: Array<number | string>, flags: ILintConfig) {
-  const spectral = new Spectral({ resolver: httpAndFileResolver });
+  const spectral = new Spectral({
+    resolver: getResolver(flags.resolver),
+  });
 
   const ruleset = await getRuleset(flags.ruleset);
   spectral.setRuleset(ruleset);
@@ -64,7 +66,7 @@ export async function lint(documents: Array<number | string>, flags: ILintConfig
   }
 
   const targetUris = await listFiles(documents);
-  const results: IRuleResult[] = []; // todo: shall we display results as they come in?
+  const results: IRuleResult[] = [];
 
   for (const targetUri of targetUris) {
     if (flags.verbose) {
diff --git a/src/cli/services/linter/utils/getResolver.ts b/src/cli/services/linter/utils/getResolver.ts
new file mode 100644
index 00000000..fd3322a3
--- /dev/null
+++ b/src/cli/services/linter/utils/getResolver.ts
@@ -0,0 +1,19 @@
+import { isAbsolute, join } from '@stoplight/path';
+import { Optional } from '@stoplight/types';
+import { httpAndFileResolver } from '../../../../resolvers/http-and-file';
+
+export const getResolver = (resolver: Optional<string>) => {
+  if (resolver) {
+    try {
+      return require(isAbsolute(resolver) ? resolver : join(process.cwd(), resolver));
+    } catch ({ message }) {
+      throw new Error(formatMessage(message) ?? message);
+    }
+  }
+
+  return httpAndFileResolver;
+};
+
+function formatMessage(message: string): Optional<string> {
+  return message.split(/\r?\n/)?.[0]?.replace(/\\/g, '/');
+}
diff --git a/src/types/config.ts b/src/types/config.ts
index 762f4965..f2f3e9f3 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -13,6 +13,7 @@ export interface ILintConfig {
   encoding: string;
   format: OutputFormat;
   output?: string;
+  resolver?: string;
   ruleset?: string[];
   skipRule?: string[];
   verbose?: boolean;


Test patch:
diff --git a/src/cli/commands/__tests__/lint.test.ts b/src/cli/commands/__tests__/lint.test.ts
index 97e99dc3..748283aa 100644
--- a/src/cli/commands/__tests__/lint.test.ts
+++ b/src/cli/commands/__tests__/lint.test.ts
@@ -174,7 +174,7 @@ describe('lint', () => {
     );
   });
 
-  it('errors upon exception', async () => {
+  it('prints error message upon exception', async () => {
     const error = new Error('Failure');
     (lint as jest.Mock).mockReset();
     (lint as jest.Mock).mockReturnValueOnce({
@@ -189,6 +189,6 @@ describe('lint', () => {
     });
 
     await run(`lint -o foo.json ./__fixtures__/empty-oas2-document.json`);
-    expect(errorSpy).toBeCalledWith(error);
+    expect(errorSpy).toBeCalledWith('Failure');
   });
 });
diff --git a/src/cli/services/__tests__/__fixtures__/foo-document.yaml b/src/cli/services/__tests__/__fixtures__/foo-document.yaml
new file mode 100644
index 00000000..a86cb2f7
--- /dev/null
+++ b/src/cli/services/__tests__/__fixtures__/foo-document.yaml
@@ -0,0 +1,1 @@
+$ref: foo://openapi-3.0-no-contact.yaml
diff --git a/src/cli/services/__tests__/__fixtures__/foo-resolver.js b/src/cli/services/__tests__/__fixtures__/foo-resolver.js
new file mode 100644
index 00000000..31b1ad64
--- /dev/null
+++ b/src/cli/services/__tests__/__fixtures__/foo-resolver.js
@@ -0,0 +1,21 @@
+const fs = require('fs');
+const path = require('path');
+const { Resolver } = require('@stoplight/json-ref-resolver');
+
+module.exports = new Resolver({
+  resolvers: {
+    foo: {
+      resolve(ref) {
+        return new Promise((resolve, reject) => {
+          fs.readFile(path.join(__dirname, ref.hostname()), 'utf8', (err, data) => {
+            if (err) {
+              reject(err);
+            } else {
+              resolve(data);
+            }
+          });
+        });
+      }
+    },
+  },
+});
diff --git a/src/cli/services/__tests__/linter.test.ts b/src/cli/services/__tests__/linter.test.ts
index 4e5b4856..3a28748a 100644
--- a/src/cli/services/__tests__/linter.test.ts
+++ b/src/cli/services/__tests__/linter.test.ts
@@ -20,6 +20,8 @@ const draftRefSpec = resolve(__dirname, './__fixtures__/draft-ref.oas2.json');
 const draftNestedRefSpec = resolve(__dirname, './__fixtures__/draft-nested-ref.oas2.json');
 const validOas3SpecPath = resolve(__dirname, './__fixtures__/openapi-3.0-valid.yaml');
 const invalidOas3SpecPath = resolve(__dirname, '__fixtures__/openapi-3.0-no-contact.yaml');
+const fooResolver = resolve(__dirname, '__fixtures__/foo-resolver.js');
+const fooDocument = resolve(__dirname, '__fixtures__/foo-document.yaml');
 
 function run(command: string) {
   const parser = yargs.command(lintCommand);
@@ -704,4 +706,17 @@ describe('Linter service', () => {
       ]);
     });
   });
+
+  describe('--resolver', () => {
+    it('uses provided resolver for $ref resolving', async () => {
+      expect(await run(`lint --resolver ${fooResolver} ${fooDocument}`)).toEqual([
+        expect.objectContaining({
+          code: 'api-servers',
+        }),
+        expect.objectContaining({
+          code: 'openapi-tags',
+        }),
+      ]);
+    });
+  });
 });
diff --git a/src/cli/services/linter/utils/__tests__/__fixtures__/resolver.js b/src/cli/services/linter/utils/__tests__/__fixtures__/resolver.js
new file mode 100644
index 00000000..5a799cde
--- /dev/null
+++ b/src/cli/services/linter/utils/__tests__/__fixtures__/resolver.js
@@ -0,0 +1,5 @@
+const { Resolver } = require('@stoplight/json-ref-resolver');
+
+module.exports = new Resolver({
+  resolvers: {},
+});
diff --git a/src/cli/services/linter/utils/__tests__/getResolver.spec.ts b/src/cli/services/linter/utils/__tests__/getResolver.spec.ts
new file mode 100644
index 00000000..22d65fe5
--- /dev/null
+++ b/src/cli/services/linter/utils/__tests__/getResolver.spec.ts
@@ -0,0 +1,26 @@
+import { join, relative } from '@stoplight/path';
+import { httpAndFileResolver } from '../../../../../resolvers/http-and-file';
+import { getResolver } from '../getResolver';
+
+const customResolver = require('./__fixtures__/resolver');
+
+describe('getResolver', () => {
+  it('resolves absolute path to the file', () => {
+    expect(getResolver(join(__dirname, './__fixtures__/resolver.js'))).toStrictEqual(customResolver);
+  });
+
+  it('resolves relative path to the file', () => {
+    const relativePath = relative(process.cwd(), join(__dirname, './__fixtures__/resolver.js'));
+    expect(getResolver(relativePath)).toStrictEqual(customResolver);
+  });
+
+  it('throws when module cannot be imported', () => {
+    expect(getResolver.bind(null, join(__dirname, 'test.json'))).toThrow(
+      `Cannot find module '${join(__dirname, 'test.json')}' from 'getResolver.ts'`,
+    );
+  });
+
+  it('given no path, returns default resolver', () => {
+    expect(getResolver(void 0)).toBe(httpAndFileResolver);
+  });
+});
diff --git a/test-harness/index.ts b/test-harness/index.ts
index a614d263..45dcaa29 100644
--- a/test-harness/index.ts
+++ b/test-harness/index.ts
@@ -1,4 +1,5 @@
 import * as path from '@stoplight/path';
+import { normalize } from '@stoplight/path';
 import { Dictionary } from '@stoplight/types';
 import * as fg from 'fast-glob';
 import * as fs from 'fs';
@@ -17,7 +18,7 @@ describe('cli acceptance tests', () => {
     const data = fs.readFileSync(path.join(cwd, file), { encoding: 'utf8' });
     const scenario = parseScenarioFile(data);
     const replacements: Dictionary<string> = {
-      __dirname,
+      __dirname: normalize(__dirname),
       bin: spectralBin,
     };
 
diff --git a/test-harness/scenarios/custom-ref-resolver.scenario b/test-harness/scenarios/custom-ref-resolver.scenario
new file mode 100644
index 00000000..6a673a0f
--- /dev/null
+++ b/test-harness/scenarios/custom-ref-resolver.scenario
@@ -0,0 +1,15 @@
+====test====
+Custom json-ref-resolver instance is used for $ref resolving
+====document====
+$ref: custom://user.yaml
+====asset:ruleset====
+rules:
+  required-user:
+    given: $
+    then:
+      field: user
+      function: truthy
+====command====
+{bin} lint {document} --ruleset {asset:ruleset} --resolver ./resolvers/custom.js
+====stdout====
+No results with a severity of 'warn' or higher found!
diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario
index 1fbb9b02..d0572fe6 100644
--- a/test-harness/scenarios/help-no-document.scenario
+++ b/test-harness/scenarios/help-no-document.scenario
@@ -16,6 +16,7 @@ Options:
   --encoding, -e               text encoding to use  [string] [default: "utf8"]
   --format, -f                 formatter to use for outputting results  [string] [default: "stylish"]
   --output, -o                 output to a file instead of stdout  [string]
+  --resolver                   path to custom json-ref-resolver instance  [string]
   --ruleset, -r                path/URL to a ruleset file  [string]
   --skip-rule, -s              ignore certain rules if they are causing trouble  [string]
   --fail-severity, -F          results of this level or above will trigger a failure exit code  [string] [choices: "error", "warn", "info", "hint"] [default: "warn"]
diff --git a/test-harness/scenarios/invalid-custom-ref-resolver.scenario b/test-harness/scenarios/invalid-custom-ref-resolver.scenario
new file mode 100644
index 00000000..05e48577
--- /dev/null
+++ b/test-harness/scenarios/invalid-custom-ref-resolver.scenario
@@ -0,0 +1,17 @@
+====test====
+Prints meaningful error message when custom json-ref-resolver instance cannot be imported
+====document====
+$ref: custom://user.yaml
+====asset:ruleset====
+rules:
+  required-user:
+    given: $
+    then:
+      field: user
+      function: truthy
+====command====
+{bin} lint {document} --ruleset {asset:ruleset} --resolver ./resolvers/missing-resolver.js
+====status====
+2
+====stderr====
+Cannot find module '{__dirname}/scenarios/resolvers/missing-resolver.js'
diff --git a/test-harness/scenarios/resolvers/custom.js b/test-harness/scenarios/resolvers/custom.js
new file mode 100644
index 00000000..9e25f319
--- /dev/null
+++ b/test-harness/scenarios/resolvers/custom.js
@@ -0,0 +1,11 @@
+const { Resolver } = require('@stoplight/json-ref-resolver');
+
+module.exports = new Resolver({
+  resolvers: {
+    custom: {
+      async resolve() {
+        return `{ "user": "Stoplight" }`;
+      }
+    }
+  }
+});