diff --git a/README.md b/README.md index 45d53badd..cd990c5cd 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Stoplight has a set of Spectral rulesets that were created to help users get sta - [OWASP Top 10](https://apistylebook.stoplight.io/docs/owasp-top-10) - Set of rules to enforce [OWASP security guidelines](https://owasp.org/www-project-api-security/). - [URL Style Guidelines](https://apistylebook.stoplight.io/docs/url-guidelines) - Set of rules to help developers make better and consistent endpoints. +- [Documentation](https://github.com/stoplightio/spectral-documentation) - Scan an OpenAPI description to make sure you're leveraging enough of its features to help documentation tools like Stoplight Elements, ReDoc, and Swagger UI build the best quality API Reference Documentation possible. There are also rulesets created by many companies to improve their APIs. You can use these as is to lint your OpenAPI descriptions, or use these as a reference to learn more about what rules you would want in your own ruleset: @@ -99,7 +100,10 @@ There are also rulesets created by many companies to improve their APIs. You can - [Tranascom](https://github.com/transcom/mymove/blob/master/swagger-def/.spectral.yml) - Don't even think about using anything other than `application/json`. - [Zalando](https://apistylebook.stoplight.io/docs/zalando-restful-api-guidelines) - Based on [Zalando's RESTFUL API Guidelines](https://github.com/zalando/restful-api-guidelines), covers a wide-range of API topics such as versioning standards, property naming standards, the default format for request/response properties, and more. -Here are [more real-world examples](https://github.com/stoplightio/spectral-rulesets) of Spectral in action. +Check out some additional style guides here: + +- [Spectral Rulesets by Stoplight](https://github.com/stoplightio/spectral-rulesets) +- [API Stylebook by Stoplight](https://apistylebook.stoplight.io) ## ⚙️ Integrations diff --git a/docs/guides/2-cli.md b/docs/guides/2-cli.md index e4707dd3e..120a0f499 100644 --- a/docs/guides/2-cli.md +++ b/docs/guides/2-cli.md @@ -60,6 +60,8 @@ Here you can build a [custom ruleset](../getting-started/3-rulesets.md), or exte - [OpenAPI ruleset](../reference/openapi-rules.md) - [AsyncAPI ruleset](../reference/asyncapi-rules.md) +> If you use rules created or updated in a hosted [Stoplight API project](https://docs.stoplight.io/docs/platform/branches/pam-716-updated-landing-page/c433d678d027a-create-rules) with the Spectral CLI, you must publish the project from Stoplight before rule updates are used for linting. + ## Error Results Spectral has a few different error severities: `error`, `warn`, `info`, and `hint`, and they're in order from highest to lowest. By default, all results are shown regardless of severity, but since v5.0, only the presence of errors causes a failure status code of 1. Seeing results and getting a failure code for it are now two different things. diff --git a/package.json b/package.json index a2bee2699..300531ede 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,8 @@ "test.karma": "karma start", "prepare": "husky install", "prerelease": "patch-package", - "release": "yarn prerelease && yarn workspaces foreach run release" + "release": "yarn prerelease && yarn workspaces foreach run release", + "jest": "jest" }, "workspaces": { "packages": [ diff --git a/packages/cli/package.json b/packages/cli/package.json index 1af38ee84..d4b11f105 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -65,7 +65,7 @@ "nock": "^13.1.3", "node-html-parser": "^4.1.5", "pkg": "^5.8.0", - "xml2js": "^0.4.23" + "xml2js": "^0.5.0" }, "pkg": { "scripts": [ diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 3065a2438..254e08254 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,3 +1,15 @@ +# [@stoplight/spectral-core-v1.18.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-core-v1.17.0...@stoplight/spectral-core-v1.18.0) (2023-04-25) + + +### Bug Fixes + +* **core:** more accurate ruleset error paths ([66b3ca7](https://github.com/stoplightio/spectral/commit/66b3ca704136d5d8a34211e72e2d8a2c522261e4)) + + +### Features + +* **core:** relax formats validation ([#2151](https://github.com/stoplightio/spectral/issues/2151)) ([de16b4c](https://github.com/stoplightio/spectral/commit/de16b4cbd56cd9836609ab79487a6e3e06df964d)) + # [@stoplight/spectral-core-v1.17.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-core-v1.16.1...@stoplight/spectral-core-v1.17.0) (2023-03-23) diff --git a/packages/core/package.json b/packages/core/package.json index 72b5ffd92..89c4d8d4c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-core", - "version": "1.17.0", + "version": "1.18.0", "sideEffects": false, "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", diff --git a/packages/core/src/ruleset/function.ts b/packages/core/src/ruleset/function.ts index b187fc13b..8360ba90c 100644 --- a/packages/core/src/ruleset/function.ts +++ b/packages/core/src/ruleset/function.ts @@ -22,10 +22,27 @@ export class RulesetFunctionValidationError extends RulesetValidationError { super( 'invalid-function-options', RulesetFunctionValidationError.printMessage(fn, error), - error.instancePath.slice(1).split('/'), + RulesetFunctionValidationError.getPath(error), ); } + private static getPath(error: ErrorObject): string[] { + const path: string[] = [ + 'functionOptions', + ...(error.instancePath === '' ? [] : error.instancePath.slice(1).split('/')), + ]; + + switch (error.keyword) { + case 'additionalProperties': { + const additionalProperty = (error as AdditionalPropertiesError).params.additionalProperty; + path.push(additionalProperty); + break; + } + } + + return path; + } + private static printMessage(fn: string, error: ErrorObject): string { switch (error.keyword) { case 'type': { @@ -157,7 +174,7 @@ export function createRulesetFunction( throw new RulesetValidationError( 'invalid-function-options', `"${fn.name || ''}" function does not accept any options`, - [], + ['functionOptions'], ); } else if ( 'errors' in validateOptions && @@ -171,7 +188,7 @@ export function createRulesetFunction( throw new RulesetValidationError( 'invalid-function-options', `"functionOptions" of "${fn.name || ''}" function must be valid`, - [], + ['functionOptions'], ); } }; diff --git a/packages/core/src/ruleset/meta/json-extensions.json b/packages/core/src/ruleset/meta/json-extensions.json index b57698d83..f3c6186ec 100644 --- a/packages/core/src/ruleset/meta/json-extensions.json +++ b/packages/core/src/ruleset/meta/json-extensions.json @@ -36,22 +36,7 @@ }, "Format": { "$anchor": "format", - "enum": [ - "oas2", - "oas3", - "oas3.0", - "oas3.1", - "asyncapi2", - "json-schema", - "json-schema-loose", - "json-schema-draft4", - "json-schema-draft6", - "json-schema-draft7", - "json-schema-draft-2019-09", - "json-schema-2019-09", - "json-schema-draft-2020-12", - "json-schema-2020-12" - ], + "type": "string", "errorMessage": "must be a valid format" }, "Functions": { diff --git a/packages/core/src/ruleset/ruleset.ts b/packages/core/src/ruleset/ruleset.ts index 560957485..8cc01a72e 100644 --- a/packages/core/src/ruleset/ruleset.ts +++ b/packages/core/src/ruleset/ruleset.ts @@ -63,10 +63,10 @@ export class Ruleset { if (isPlainObject(maybeDefinition) && 'extends' in maybeDefinition) { const { extends: _, ...def } = maybeDefinition; // we don't want to validate extends - this is going to happen later on (line 29) - assertValidRuleset({ extends: [], ...def }); + assertValidRuleset({ extends: [], ...def }, 'js'); definition = maybeDefinition as RulesetDefinition; } else { - assertValidRuleset(maybeDefinition); + assertValidRuleset(maybeDefinition, 'js'); definition = maybeDefinition; } diff --git a/packages/core/src/ruleset/validation/__tests__/validation.test.ts b/packages/core/src/ruleset/validation/__tests__/validation.test.ts index 001427667..59a88f911 100644 --- a/packages/core/src/ruleset/validation/__tests__/validation.test.ts +++ b/packages/core/src/ruleset/validation/__tests__/validation.test.ts @@ -773,7 +773,12 @@ describe('JS Ruleset Validation', () => { }), ).toThrowAggregateError( new AggregateError([ - new RulesetValidationError('undefined-function', 'Function is not defined', ['rules', 'rule', 'then']), + new RulesetValidationError('undefined-function', 'Function is not defined', [ + 'rules', + 'rule', + 'then', + 'function', + ]), ]), ); }); @@ -941,14 +946,14 @@ describe('JSON Ruleset Validation', () => { it.each<[unknown, RulesetValidationError[]]>([ [ - [2, 'a'], + [2, null], [ new RulesetValidationError('invalid-format', 'must be a valid format', ['formats', '0']), new RulesetValidationError('invalid-format', 'must be a valid format', ['formats', '1']), ], ], [2, [new RulesetValidationError('invalid-ruleset-definition', 'must be an array of formats', ['formats'])]], - [[''], [new RulesetValidationError('invalid-format', 'must be a valid format', ['formats', '0'])]], + [[null], [new RulesetValidationError('invalid-format', 'must be a valid format', ['formats', '0'])]], ])('recognizes invalid ruleset %p formats syntax', (formats, errors) => { expect( assertValidRuleset.bind( @@ -985,7 +990,7 @@ describe('JSON Ruleset Validation', () => { it.each<[unknown, RulesetValidationError[]]>([ [ - [2, 'a'], + [2, null], [ new RulesetValidationError('invalid-format', 'must be a valid format', ['rules', 'rule', 'formats', '0']), new RulesetValidationError('invalid-format', 'must be a valid format', ['rules', 'rule', 'formats', '1']), diff --git a/packages/core/src/ruleset/validation/errors.ts b/packages/core/src/ruleset/validation/errors.ts index af8660783..3c92aa7d0 100644 --- a/packages/core/src/ruleset/validation/errors.ts +++ b/packages/core/src/ruleset/validation/errors.ts @@ -18,7 +18,7 @@ export type RulesetValidationErrorCode = | 'undefined-alias'; interface IRulesetValidationSingleError extends Pick { - code: RulesetValidationErrorCode; + readonly code: RulesetValidationErrorCode; } export class RulesetValidationError extends Error implements IRulesetValidationSingleError { diff --git a/packages/core/src/ruleset/validation/validators/function.ts b/packages/core/src/ruleset/validation/validators/function.ts index 834425227..bf6249eba 100644 --- a/packages/core/src/ruleset/validation/validators/function.ts +++ b/packages/core/src/ruleset/validation/validators/function.ts @@ -24,7 +24,7 @@ export function validateFunction( validator(opts); } catch (ex) { if (ex instanceof ReferenceError) { - return new RulesetValidationError('undefined-function', ex.message, toParsedPath(path)); + return new RulesetValidationError('undefined-function', ex.message, [...toParsedPath(path), 'function']); } return wrapError(ex, path); diff --git a/packages/functions/src/__tests__/__helpers__/tester.ts b/packages/functions/src/__tests__/__helpers__/tester.ts index fe1355623..bc77112c5 100644 --- a/packages/functions/src/__tests__/__helpers__/tester.ts +++ b/packages/functions/src/__tests__/__helpers__/tester.ts @@ -6,15 +6,8 @@ import { IRuleResult, RulesetFunction, RulesetFunctionWithValidator, - RulesetValidationError, } from '@stoplight/spectral-core'; -import { isError } from 'lodash'; - -function isAggregateError(maybeAggregateError: unknown): maybeAggregateError is Error & { errors: unknown[] } { - return isError(maybeAggregateError) && maybeAggregateError.constructor.name === 'AggregateError'; -} - export default async function ( // eslint-disable-next-line @typescript-eslint/no-explicit-any fn: RulesetFunction | RulesetFunctionWithValidator, @@ -23,31 +16,19 @@ export default async function ( rule?: Partial> & { then?: Partial }, ): Promise[]> { const s = new Spectral(); - try { - s.setRuleset({ - rules: { - 'my-rule': { - given: '$', - ...rule, - then: { - ...(rule?.then as Ruleset['rules']['then']), - function: fn, - functionOptions: opts, - }, + s.setRuleset({ + rules: { + 'my-rule': { + given: '$', + ...rule, + then: { + ...(rule?.then as Ruleset['rules']['then']), + function: fn, + functionOptions: opts, }, }, - }); - } catch (ex) { - if (isAggregateError(ex)) { - for (const e of ex.errors) { - if (e instanceof RulesetValidationError) { - e.path.length = 0; - } - } - } - - throw ex; - } + }, + }); const results = await s.run(input instanceof Document ? input : JSON.stringify(input)); return results diff --git a/packages/functions/src/__tests__/alphabetical.test.ts b/packages/functions/src/__tests__/alphabetical.test.ts index fe6c20073..d8ba91d86 100644 --- a/packages/functions/src/__tests__/alphabetical.test.ts +++ b/packages/functions/src/__tests__/alphabetical.test.ts @@ -132,17 +132,33 @@ describe('Core Functions / Alphabetical', () => { expect(await runAlphabetical([], opts)).toEqual([]); }); - it.each<[unknown, string]>([ - [{ foo: true }, '"alphabetical" function does not support "foo" option'], + it.each<[unknown, RulesetValidationError]>([ + [ + { foo: true }, + new RulesetValidationError( + 'invalid-function-options', + '"alphabetical" function does not support "foo" option', + ['rules', 'my-rule', 'then', 'functionOptions', 'foo'], + ), + ], [ 2, - '"alphabetical" function has invalid options specified. Example valid options: null (no options), { "keyedBy": "my-key" }', + new RulesetValidationError( + 'invalid-function-options', + '"alphabetical" function has invalid options specified. Example valid options: null (no options), { "keyedBy": "my-key" }', + ['rules', 'my-rule', 'then', 'functionOptions'], + ), + ], + [ + { keyedBy: 2 }, + new RulesetValidationError( + 'invalid-function-options', + '"alphabetical" function and its "keyedBy" option accepts only the following types: string', + ['rules', 'my-rule', 'then', 'functionOptions', 'keyedBy'], + ), ], - [{ keyedBy: 2 }, '"alphabetical" function and its "keyedBy" option accepts only the following types: string'], ])('given invalid %p options, should throw', async (opts, error) => { - await expect(runAlphabetical([], opts)).rejects.toThrowAggregateError( - new AggregateError([new RulesetValidationError('invalid-function-options', error, [])]), - ); + await expect(runAlphabetical([], opts)).rejects.toThrowAggregateError(new AggregateError([error])); }); }); }); diff --git a/packages/functions/src/__tests__/casing.test.ts b/packages/functions/src/__tests__/casing.test.ts index b78605265..b0c2e03a5 100644 --- a/packages/functions/src/__tests__/casing.test.ts +++ b/packages/functions/src/__tests__/casing.test.ts @@ -381,13 +381,21 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function and its "type" option accept the following values: flat, camel, pascal, kebab, cobol, snake, macro', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'type'], ), ], ], [ { type: 'macro', foo: true }, - [new RulesetValidationError('invalid-function-options', '"casing" function does not support "foo" option', [])], + [ + new RulesetValidationError('invalid-function-options', '"casing" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], ], [ { @@ -399,7 +407,7 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function is missing "separator.char" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'separator'], ), ], ], @@ -413,7 +421,7 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function is missing "separator.char" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'separator'], ), ], ], @@ -423,7 +431,7 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function does not support "separator.foo" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'separator', 'foo'], ), ], ], @@ -438,7 +446,7 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'separator', 'char'], ), ], ], @@ -453,7 +461,7 @@ describe('Core Functions / Casing', () => { new RulesetValidationError( 'invalid-function-options', '"casing" function and its "separator.char" option accepts only char, i.e. "I" or "/"', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'separator', 'char'], ), ], ], diff --git a/packages/functions/src/__tests__/defined.test.ts b/packages/functions/src/__tests__/defined.test.ts index 05d3e54b9..56ebc51a5 100644 --- a/packages/functions/src/__tests__/defined.test.ts +++ b/packages/functions/src/__tests__/defined.test.ts @@ -25,7 +25,12 @@ describe('Core Functions / Defined', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { await expect(runDefined([], opts)).rejects.toThrowAggregateError( new AggregateError([ - new RulesetValidationError('invalid-function-options', '"defined" function does not accept any options', []), + new RulesetValidationError('invalid-function-options', '"defined" function does not accept any options', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + ]), ]), ); }); diff --git a/packages/functions/src/__tests__/enumeration.test.ts b/packages/functions/src/__tests__/enumeration.test.ts index ab92c140c..019cd6610 100644 --- a/packages/functions/src/__tests__/enumeration.test.ts +++ b/packages/functions/src/__tests__/enumeration.test.ts @@ -44,7 +44,7 @@ describe('Core Functions / Enumeration', () => { new RulesetValidationError( 'invalid-function-options', '"enumeration" function does not support "foo" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'foo'], ), ], ], @@ -56,7 +56,7 @@ describe('Core Functions / Enumeration', () => { new RulesetValidationError( 'invalid-function-options', '"enumeration" and its "values" option support only arrays of primitive values, i.e. ["Berlin", "London", "Paris"]', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'values'], ), ], ], @@ -66,7 +66,7 @@ describe('Core Functions / Enumeration', () => { new RulesetValidationError( 'invalid-function-options', '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -76,7 +76,7 @@ describe('Core Functions / Enumeration', () => { new RulesetValidationError( 'invalid-function-options', '"enumeration" function has invalid options specified. Example valid options: { "values": ["Berlin", "London", "Paris"] }, { "values": [2, 3, 5, 8, 13, 21] }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], diff --git a/packages/functions/src/__tests__/falsy.test.ts b/packages/functions/src/__tests__/falsy.test.ts index 7799b442b..a63c9878a 100644 --- a/packages/functions/src/__tests__/falsy.test.ts +++ b/packages/functions/src/__tests__/falsy.test.ts @@ -25,7 +25,12 @@ describe('Core Functions / Falsy', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { await expect(runFalsy([], opts)).rejects.toThrowAggregateError( new AggregateError([ - new RulesetValidationError('invalid-function-options', '"falsy" function does not accept any options', []), + new RulesetValidationError('invalid-function-options', '"falsy" function does not accept any options', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + ]), ]), ); }); diff --git a/packages/functions/src/__tests__/length.test.ts b/packages/functions/src/__tests__/length.test.ts index 9e3fff231..4ab9cb38f 100644 --- a/packages/functions/src/__tests__/length.test.ts +++ b/packages/functions/src/__tests__/length.test.ts @@ -61,7 +61,7 @@ describe('Core Functions / Length', () => { new RulesetValidationError( 'invalid-function-options', '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -71,7 +71,7 @@ describe('Core Functions / Length', () => { new RulesetValidationError( 'invalid-function-options', '"length" function has invalid options specified. Example valid options: { "min": 2 }, { "max": 5 }, { "min": 0, "max": 10 }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -80,15 +80,23 @@ describe('Core Functions / Length', () => { min: 2, foo: true, }, - [new RulesetValidationError('invalid-function-options', '"length" function does not support "foo" option', [])], + [ + new RulesetValidationError('invalid-function-options', '"length" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], ], [ { min: '2' }, [ new RulesetValidationError( 'invalid-function-options', - '"length" function and its "min" option accepts only the following types: number', - [], + `"length" function and its "min" option accepts only the following types: number`, + ['rules', 'my-rule', 'then', 'functionOptions', 'min'], ), ], ], @@ -99,7 +107,7 @@ describe('Core Functions / Length', () => { new RulesetValidationError( 'invalid-function-options', `"length" function and its "max" option accepts only the following types: number`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'max'], ), ], ], @@ -109,12 +117,12 @@ describe('Core Functions / Length', () => { new RulesetValidationError( 'invalid-function-options', `"length" function and its "min" option accepts only the following types: number`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'min'], ), new RulesetValidationError( 'invalid-function-options', `"length" function and its "max" option accepts only the following types: number`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'max'], ), ], ], diff --git a/packages/functions/src/__tests__/pattern.test.ts b/packages/functions/src/__tests__/pattern.test.ts index 74b98e084..9cc030cc7 100644 --- a/packages/functions/src/__tests__/pattern.test.ts +++ b/packages/functions/src/__tests__/pattern.test.ts @@ -59,7 +59,7 @@ describe('Core Functions / Pattern', () => { new RulesetValidationError( 'invalid-function-options', '"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -69,18 +69,20 @@ describe('Core Functions / Pattern', () => { new RulesetValidationError( 'invalid-function-options', `"pattern" function has invalid options specified. Example valid options: { "match": "^Stoplight" }, { "notMatch": "Swagger" }, { "match": "Stoplight", "notMatch": "Swagger" }`, - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], [ { foo: true }, [ - new RulesetValidationError( - 'invalid-function-options', - '"pattern" function does not support "foo" option', - [], - ), + new RulesetValidationError('invalid-function-options', '"pattern" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), ], ], [ @@ -89,7 +91,7 @@ describe('Core Functions / Pattern', () => { new RulesetValidationError( 'invalid-function-options', '"pattern" function and its "match" option must be string or RegExp instance', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'match'], ), ], ], @@ -99,7 +101,7 @@ describe('Core Functions / Pattern', () => { new RulesetValidationError( 'invalid-function-options', '"pattern" function and its "notMatch" option must be string or RegExp instance', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'notMatch'], ), ], ], @@ -109,12 +111,12 @@ describe('Core Functions / Pattern', () => { new RulesetValidationError( 'invalid-function-options', `"pattern" function and its "match" option must be string or RegExp instance`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'match'], ), new RulesetValidationError( 'invalid-function-options', `"pattern" function and its "notMatch" option must be string or RegExp instance`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'notMatch'], ), ], ], diff --git a/packages/functions/src/__tests__/schema.test.ts b/packages/functions/src/__tests__/schema.test.ts index ee7b53950..ec45b5fd0 100644 --- a/packages/functions/src/__tests__/schema.test.ts +++ b/packages/functions/src/__tests__/schema.test.ts @@ -485,13 +485,21 @@ describe('Core Functions / Schema', () => { new RulesetValidationError( 'invalid-function-options', '"schema" function has invalid options specified. Example valid options: { "schema": { /* any JSON Schema can be defined here */ } , { "schema": { "type": "object" }, "dialect": "auto" }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], [ { schema: { type: 'object' }, foo: true }, - [new RulesetValidationError('invalid-function-options', '"schema" function does not support "foo" option', [])], + [ + new RulesetValidationError('invalid-function-options', '"schema" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], ], [ { schema: { type: 'object' }, oasVersion: 1 }, @@ -499,7 +507,7 @@ describe('Core Functions / Schema', () => { new RulesetValidationError( 'invalid-function-options', '"schema" function does not support "oasVersion" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'oasVersion'], ), ], ], @@ -509,7 +517,7 @@ describe('Core Functions / Schema', () => { new RulesetValidationError( 'invalid-function-options', '"schema" function and its "dialect" option accepts only the following values: "auto", "draft4", "draft6", "draft7", "draft2019-09", "draft2020-12"', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'dialect'], ), ], ], @@ -519,7 +527,7 @@ describe('Core Functions / Schema', () => { new RulesetValidationError( 'invalid-function-options', '"schema" function and its "allErrors" option accepts only the following types: boolean', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'allErrors'], ), ], ], @@ -529,12 +537,12 @@ describe('Core Functions / Schema', () => { new RulesetValidationError( 'invalid-function-options', `"schema" function and its "schema" option accepts only the following types: object`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'schema'], ), new RulesetValidationError( 'invalid-function-options', `"schema" function and its "allErrors" option accepts only the following types: boolean`, - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'allErrors'], ), ], ], diff --git a/packages/functions/src/__tests__/truthy.test.ts b/packages/functions/src/__tests__/truthy.test.ts index b135382a2..32da4fb4a 100644 --- a/packages/functions/src/__tests__/truthy.test.ts +++ b/packages/functions/src/__tests__/truthy.test.ts @@ -25,7 +25,12 @@ describe('Core Functions / Truthy', () => { it.each([{}, 2])('given invalid %p options, should throw', async opts => { await expect(runTruthy([], opts)).rejects.toThrowAggregateError( new AggregateError([ - new RulesetValidationError('invalid-function-options', '"truthy" function does not accept any options', []), + new RulesetValidationError('invalid-function-options', '"truthy" function does not accept any options', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + ]), ]), ); }); diff --git a/packages/functions/src/__tests__/unreferencedReusableObject.test.ts b/packages/functions/src/__tests__/unreferencedReusableObject.test.ts index 7fdcbe80b..3f1deb95c 100644 --- a/packages/functions/src/__tests__/unreferencedReusableObject.test.ts +++ b/packages/functions/src/__tests__/unreferencedReusableObject.test.ts @@ -34,7 +34,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -44,7 +44,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" function has invalid options specified. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -54,7 +54,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" function is missing "reusableObjectsLocation" option. Example valid options: { "reusableObjectsLocation": "#/components/schemas" }, { "reusableObjectsLocation": "#/$defs" }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -67,7 +67,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" function does not support "foo" option', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'foo'], ), ], ], @@ -79,7 +79,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'reusableObjectsLocation'], ), ], ], @@ -91,7 +91,7 @@ describe('Core Functions / UnreferencedReusableObject', () => { new RulesetValidationError( 'invalid-function-options', '"unreferencedReusableObject" and its "reusableObjectsLocation" option support only valid JSON Pointer fragments, i.e. "#", "#/foo", "#/paths/~1user"', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'reusableObjectsLocation'], ), ], ], diff --git a/packages/functions/src/__tests__/xor.test.ts b/packages/functions/src/__tests__/xor.test.ts index 2bf7deed7..6ea29e233 100644 --- a/packages/functions/src/__tests__/xor.test.ts +++ b/packages/functions/src/__tests__/xor.test.ts @@ -73,7 +73,7 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], @@ -83,13 +83,21 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" function has invalid options specified. Example valid options: { "properties": ["id", "name"] }, { "properties": ["country", "street"] }', - [], + ['rules', 'my-rule', 'then', 'functionOptions'], ), ], ], [ { properties: ['foo', 'bar'], foo: true }, - [new RulesetValidationError('invalid-function-options', '"xor" function does not support "foo" option', [])], + [ + new RulesetValidationError('invalid-function-options', '"xor" function does not support "foo" option', [ + 'rules', + 'my-rule', + 'then', + 'functionOptions', + 'foo', + ]), + ], ], [ { properties: ['foo', 'bar', 'baz'] }, @@ -97,7 +105,7 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], @@ -107,7 +115,7 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], @@ -117,7 +125,7 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], @@ -127,7 +135,7 @@ describe('Core Functions / Xor', () => { new RulesetValidationError( 'invalid-function-options', '"xor" and its "properties" option support 2-item tuples, i.e. ["id", "name"]', - [], + ['rules', 'my-rule', 'then', 'functionOptions', 'properties'], ), ], ], diff --git a/packages/functions/src/schema/index.ts b/packages/functions/src/schema/index.ts index a7262517d..955c799b9 100644 --- a/packages/functions/src/schema/index.ts +++ b/packages/functions/src/schema/index.ts @@ -69,7 +69,10 @@ export default createRulesetFunction( // let's ignore any $ref errors if schema fn is provided with already resolved content, // if our resolver fails to resolve them, // ajv is unlikely to do it either, since it won't have access to the whole document, but a small portion of it - if (!rule.resolved || !(ex instanceof MissingRefError)) { + // We specifically check that "rule" is truthy below because "rule" might be undefined/null if this + // code is called from testcases. + const ignoreError = rule?.resolved && ex instanceof MissingRefError; + if (!ignoreError) { results.push({ message: ex.message, path, diff --git a/packages/ruleset-migrator/CHANGELOG.md b/packages/ruleset-migrator/CHANGELOG.md index f7ab63f06..bea2e694f 100644 --- a/packages/ruleset-migrator/CHANGELOG.md +++ b/packages/ruleset-migrator/CHANGELOG.md @@ -1,3 +1,10 @@ +# [@stoplight/spectral-ruleset-migrator-v1.9.3](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-migrator-v1.9.2...@stoplight/spectral-ruleset-migrator-v1.9.3) (2023-04-21) + + +### Bug Fixes + +* **ruleset-migrator:** transform functions under overrides ([#2459](https://github.com/stoplightio/spectral/issues/2459)) ([45e817f](https://github.com/stoplightio/spectral/commit/45e817ffb9b682779c8e20153405879d9205454d)) + # [@stoplight/spectral-ruleset-migrator-v1.9.2](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-ruleset-migrator-v1.9.1...@stoplight/spectral-ruleset-migrator-v1.9.2) (2023-02-22) diff --git a/packages/ruleset-migrator/package.json b/packages/ruleset-migrator/package.json index 6121002dd..3e9e22c0b 100644 --- a/packages/ruleset-migrator/package.json +++ b/packages/ruleset-migrator/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-ruleset-migrator", - "version": "1.9.2", + "version": "1.9.3", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.cjs b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.cjs new file mode 100644 index 000000000..f72b51d08 --- /dev/null +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.cjs @@ -0,0 +1,24 @@ +const { truthy } = require('@stoplight/spectral-functions'); +const { oas } = require('@stoplight/spectral-rulesets'); +module.exports = { + extends: [oas], + aliases: { + OperationObject: ['#PathItem[get,put,post,delete,options,head,patch,trace]'], + PathItem: ['$.paths[*]'], + }, + overrides: [ + { + files: ['*'], + rules: { + 'operation-description': { + given: '#OperationObject', + then: { + field: 'summary', + function: truthy, + }, + severity: 'warn', + }, + }, + }, + ], +}; diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.mjs b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.mjs new file mode 100644 index 000000000..dfc6f6d57 --- /dev/null +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/output.mjs @@ -0,0 +1,24 @@ +import { truthy } from '@stoplight/spectral-functions'; +import { oas } from '@stoplight/spectral-rulesets'; +export default { + extends: [oas], + aliases: { + OperationObject: ['#PathItem[get,put,post,delete,options,head,patch,trace]'], + PathItem: ['$.paths[*]'], + }, + overrides: [ + { + files: ['*'], + rules: { + 'operation-description': { + given: '#OperationObject', + then: { + field: 'summary', + function: truthy, + }, + severity: 'warn', + }, + }, + }, + ], +}; diff --git a/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/ruleset.yaml b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/ruleset.yaml new file mode 100644 index 000000000..7a7e9d7cf --- /dev/null +++ b/packages/ruleset-migrator/src/__tests__/__fixtures__/overrides-variant-2/ruleset.yaml @@ -0,0 +1,16 @@ +extends: + - 'spectral:oas' +aliases: + OperationObject: + - '#PathItem[get,put,post,delete,options,head,patch,trace]' + PathItem: + - $.paths[*] +overrides: + - files: ['*'] + rules: + operation-description: + given: '#OperationObject' + then: + field: 'summary' + function: 'truthy' + severity: warn diff --git a/packages/ruleset-migrator/src/transformers/rules.ts b/packages/ruleset-migrator/src/transformers/rules.ts index 74bc05c21..33693d550 100644 --- a/packages/ruleset-migrator/src/transformers/rules.ts +++ b/packages/ruleset-migrator/src/transformers/rules.ts @@ -85,7 +85,7 @@ const transformer: Transformer = function (hooks) { ]); hooks.add([ - /^\/rules\/[^/]+\/then\/(?:[0-9]+\/)?function$/, + /^(?:\/overrides\/\d+)?\/rules\/[^/]+\/then\/(?:\d+\/)?function$/, (value, ctx): namedTypes.Identifier | namedTypes.UnaryExpression => { assertString(value); diff --git a/packages/rulesets/CHANGELOG.md b/packages/rulesets/CHANGELOG.md index d1757129e..30e256336 100644 --- a/packages/rulesets/CHANGELOG.md +++ b/packages/rulesets/CHANGELOG.md @@ -1,3 +1,17 @@ +# [@stoplight/spectral-rulesets-v1.16.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-v1.15.1...@stoplight/spectral-rulesets-v1.16.0) (2023-04-25) + + +### Features + +* **rulesets:** add traits array path to headers rule ([#2460](https://github.com/stoplightio/spectral/issues/2460)) ([9ceabca](https://github.com/stoplightio/spectral/commit/9ceabca80969885c240349d6ebba15c09a4f8697)) + +# [@stoplight/spectral-rulesets-v1.15.1](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-v1.15.0...@stoplight/spectral-rulesets-v1.15.1) (2023-04-25) + + +### Bug Fixes + +* **rulesets:** avoid false errors from ajv ([#2408](https://github.com/stoplightio/spectral/issues/2408)) ([92dab78](https://github.com/stoplightio/spectral/commit/92dab78d0c07e6919c0485cadbe5aa2391a53e8b)) + # [@stoplight/spectral-rulesets-v1.15.0](https://github.com/stoplightio/spectral/compare/@stoplight/spectral-rulesets-v1.14.1...@stoplight/spectral-rulesets-v1.15.0) (2023-02-03) diff --git a/packages/rulesets/package.json b/packages/rulesets/package.json index ab7a5a787..fd2bf92aa 100644 --- a/packages/rulesets/package.json +++ b/packages/rulesets/package.json @@ -1,6 +1,6 @@ { "name": "@stoplight/spectral-rulesets", - "version": "1.15.0", + "version": "1.16.0", "homepage": "https://github.com/stoplightio/spectral", "bugs": "https://github.com/stoplightio/spectral/issues", "author": "Stoplight ", diff --git a/packages/rulesets/src/asyncapi/__tests__/asyncapi-headers-schema-type-object.test.ts b/packages/rulesets/src/asyncapi/__tests__/asyncapi-headers-schema-type-object.test.ts index 36e99de4e..c0b971b06 100644 --- a/packages/rulesets/src/asyncapi/__tests__/asyncapi-headers-schema-type-object.test.ts +++ b/packages/rulesets/src/asyncapi/__tests__/asyncapi-headers-schema-type-object.test.ts @@ -26,6 +26,18 @@ const document = { message: cloneDeep(headersBearer), }, }, + 'users/{userId}/loggedIn': { + publish: { + message: { + traits: [cloneDeep(headersBearer)], + }, + }, + subscribe: { + message: { + traits: [cloneDeep(headersBearer)], + }, + }, + }, }, components: { messageTraits: { @@ -131,5 +143,34 @@ testRule('asyncapi-headers-schema-type-object', [ }, ], }, + + { + name: `channels.{channel}.${property}.message.traits.[*].headers lacks "type" property`, + document: produce(document, draft => { + draft.channels['users/{userId}/loggedIn'][property].message.traits[0].headers = { const: 'Hello World!' }; + }), + errors: [ + { + message: 'Headers schema type must be "object" ("headers" property must have required property "type").', + path: ['channels', 'users/{userId}/loggedIn', property, 'message', 'traits', '0', 'headers'], + severity: DiagnosticSeverity.Error, + }, + ], + }, + + { + name: `channels.{channel}.${property}.message.traits.[*].headers is not of type "object"`, + document: produce(document, draft => { + draft.channels['users/{userId}/loggedIn'][property].message.traits[0].headers = { type: 'integer' }; + }), + errors: [ + { + message: + 'Headers schema type must be "object" ("type" property must be equal to one of the allowed values: "object". Did you mean "object"?).', + path: ['channels', 'users/{userId}/loggedIn', property, 'message', 'traits', '0', 'headers', 'type'], + severity: DiagnosticSeverity.Error, + }, + ], + }, ]), ]); diff --git a/packages/rulesets/src/asyncapi/index.ts b/packages/rulesets/src/asyncapi/index.ts index 94221b93f..eb20fa1fb 100644 --- a/packages/rulesets/src/asyncapi/index.ts +++ b/packages/rulesets/src/asyncapi/index.ts @@ -90,6 +90,7 @@ export default { '$.components.messageTraits.*.headers', '$.components.messages.*.headers', '$.channels.*.[publish,subscribe].message.headers', + '$.channels.*.[publish,subscribe].message.traits[*].headers', ], then: { function: schema, diff --git a/packages/rulesets/src/oas/functions/__tests__/oasExample.test.ts b/packages/rulesets/src/oas/functions/__tests__/oasExample.test.ts new file mode 100644 index 000000000..899e5b517 --- /dev/null +++ b/packages/rulesets/src/oas/functions/__tests__/oasExample.test.ts @@ -0,0 +1,451 @@ +import { oas3, oas3_0 } from '@stoplight/spectral-formats'; +import { DeepPartial } from '@stoplight/types'; +import oasExample, { Options as ExampleOptions } from '../oasExample'; +import { RulesetFunctionContext } from '@stoplight/spectral-core/src'; + +const schemaOpts: ExampleOptions = { + schemaField: '$', + oasVersion: 3, + type: 'schema', +}; +const mediaOpts: ExampleOptions = { + schemaField: 'schema', + oasVersion: 3, + type: 'media', +}; +const docFormats = { + formats: new Set([oas3, oas3_0]), +}; + +/** + * Runs the oasExample() custom rule function to perform a single test. + * @param target the object (media type or schema) containing an example/default value + * @param ruleOptions the options to be passed to oasExample() + * @param context the spectral context object to pass to oasExample() + * @returns an array of errors, or [] if no errors occurred + */ +function runRule(testData: Record, ruleOptions: ExampleOptions) { + const context: DeepPartial = { + path: [], + documentInventory: {}, + document: docFormats, + }; + + return oasExample(testData, ruleOptions, context as RulesetFunctionContext); +} + +describe('oasExample', () => { + describe('should return no errors', () => { + describe('example/default value in schema', () => { + test('valid "example" object', () => { + const schema = { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo'], + example: { + foo: 38, + bar: 'foo', + }, + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(0); + }); + test('valid "default" string', () => { + const schema = { + type: 'string', + pattern: 'xyz-.*', + minLength: 4, + maxLength: 6, + default: 'xyz-99', + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(0); + }); + test('valid "example" integer', () => { + const schema = { + type: 'integer', + example: 74, + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(0); + }); + test('scenario: "resolves to more than one schema"', () => { + // This test data is from https://github.com/stoplightio/spectral/issues/2081 and + // demonstrates a scenario in which ajv returns the dreaded + // "reference <...> resolves to more than one schema" false error. + // Without the fix to the oasExample() function, this test will fail. + // The reason that it fails is due to the way in which ajv handles unknown + // properties found in the schema (e.g. "example" - it's not actually part of JSONSchema), + // and the way it gives special treatment to the "id" property. Ajv gets confused by + // the fact that there are multiple example objects that each contain a property named "id" + // with the value 'bf23bc970b78d27691e8' (repeating example values is probably not an uncommon + // use-case for openapi authors if you think about it). + // So, without the fix to oasExample(), the test below will fail with this result: + // [ + // { + // "message": "reference \"bf23bc970b78d27691e8\" resolves to more than one schema", + // "path": ["example"] + // } + // ] + // However, if you rename the "id" properties to something else, the rule returns []. + // Likewise, if you change the value of "id" in one of the examples (so they are no longer equal) + // the rule returns []. + // And of course, with the fix to oasExample() in place, the rule will also return []. + const schema = { + type: 'object', + required: ['items'], + allOf: [ + { + type: 'object', + properties: { + items: { + type: 'array', + items: { + type: 'object', + required: ['id', 'url'], + properties: { + id: { + type: 'string', + }, + url: { + type: 'string', + format: 'uri', + }, + }, + example: { + id: 'bf23bc970b78d27691e8', + url: 'https://api.example.com/banking/accounts/bf23bc970b78d27691e8', + }, + }, + }, + }, + }, + ], + example: { + items: [ + { + id: 'bf23bc970b78d27691e8', + url: 'https://api.example.com/banking/accounts/bf23bc970b78d27691e8', + }, + { + id: '8d27691e8bf23bc970b7', + url: 'https://api.example.com/banking/accounts/8d27691e8bf23bc970b7', + }, + ], + }, + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(0); + }); + }); + describe('example/examples value in mediatype', () => { + test('valid "example" object', () => { + const mediaType = { + schema: { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo'], + }, + example: { + foo: 38, + bar: 'foo', + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(0); + }); + test('valid "examples" object', () => { + const mediaType = { + schema: { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo'], + }, + examples: { + first: { + value: { + foo: 38, + bar: 'foo', + }, + }, + second: { + value: { + foo: 26, + bar: 'baz', + }, + }, + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(0); + }); + test('valid "example" string', () => { + const mediaType = { + schema: { + type: 'string', + pattern: 'xyz-.*', + minLength: 4, + maxLength: 8, + }, + example: 'xyz-9999', + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(0); + }); + test('valid "examples" string', () => { + const mediaType = { + schema: { + type: 'string', + pattern: 'id-.*', + minLength: 4, + maxLength: 8, + }, + examples: { + first: { + value: 'id-1', + }, + second: { + value: 'id-99999', + }, + third: { + value: 'id-38', + }, + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(0); + }); + test('scenario: "resolves to more than one schema"', () => { + // This test data was adapted from https://github.com/stoplightio/spectral/issues/2140. + const mediaType = { + schema: { + properties: { + bars: { + description: 'Array of bars!', + type: 'array', + items: { + oneOf: [ + { + type: 'object', + description: 'a real bar!', + required: ['id'], + properties: { + id: { + description: 'The ID for this real bar', + type: 'string', + }, + }, + example: { + id: '6d353a0f-aeb1-4ae1-832e-1110d10981bb', + }, + }, + { + description: 'not a real bar!', + not: { + type: 'object', + description: 'a real bar!', + required: ['id'], + properties: { + id: { + description: 'The ID for this real bar', + type: 'string', + }, + }, + example: { + id: '6d353a0f-aeb1-4ae1-832e-1110d10981bb', + }, + }, + }, + ], + }, + }, + }, + }, + example: { + bars: [{ id: '6d353a0f-aeb1-4ae1-832e-1110d10981bb' }], + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(0); + }); + }); + }); + describe('should return errors', () => { + describe('example/default value in schema', () => { + test('invalid "example" object', () => { + const schema = { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo', 'bar'], + example: { + foo: 38, + bar: 26, + }, + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(1); + + expect(results[0].path.join('.')).toBe('example.bar'); + expect(results[0].message).toBe(`"bar" property type must be string`); + }); + test('invalid "default" string', () => { + const schema = { + type: 'string', + pattern: 'xyz-.*', + minLength: 4, + maxLength: 8, + default: 'xyz-99999', + }; + + const results = runRule(schema, schemaOpts); + expect(results).toHaveLength(1); + expect(results[0].message).toBe(`"default" property must not have more than 8 characters`); + expect(results[0].path.join('.')).toBe('default'); + }); + }); + describe('example/examples value in mediatype', () => { + test('invalid "example" object', () => { + const mediaType = { + schema: { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo', 'bar'], + }, + example: { + foo: 38, + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(1); + expect(results[0].message).toBe(`"example" property must have required property "bar"`); + expect(results[0].path.join('.')).toBe('example'); + }); + test('invalid "examples" object', () => { + const mediaType = { + schema: { + type: 'object', + properties: { + foo: { + type: 'number', + }, + bar: { + type: 'string', + }, + }, + required: ['foo', 'bar'], + }, + examples: { + first: { + value: { + foo: 38, + }, + }, + second: { + value: { + foo: 'bar', + bar: 'foo', + }, + }, + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(2); + + expect(results[0].message).toBe(`"value" property must have required property "bar"`); + expect(results[0].path.join('.')).toBe('examples.first.value'); + + expect(results[1].message).toBe(`"foo" property type must be number`); + expect(results[1].path.join('.')).toBe('examples.second.value.foo'); + }); + test('invalid "example" string', () => { + const mediaType = { + schema: { + type: 'string', + pattern: 'xyz-.*', + minLength: 4, + maxLength: 8, + }, + example: 'xyz-99999', + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(1); + expect(results[0].message).toBe(`"example" property must not have more than 8 characters`); + expect(results[0].path.join('.')).toBe('example'); + }); + test('invalid "examples" string', () => { + const mediaType = { + schema: { + type: 'string', + pattern: 'xyz-.*', + minLength: 4, + maxLength: 8, + default: 'xyz-99', + }, + examples: { + first: { + value: 'xyz-99999', + }, + second: { + value: 38, + }, + }, + }; + + const results = runRule(mediaType, mediaOpts); + expect(results).toHaveLength(2); + expect(results[0].message).toBe(`"value" property must not have more than 8 characters`); + expect(results[0].path.join('.')).toBe('examples.first.value'); + expect(results[1].message).toBe(`"value" property type must be string`); + expect(results[1].path.join('.')).toBe('examples.second.value'); + }); + }); + }); +}); diff --git a/packages/rulesets/src/oas/functions/oasExample.ts b/packages/rulesets/src/oas/functions/oasExample.ts index 1ad1dcd9b..6af1154f5 100644 --- a/packages/rulesets/src/oas/functions/oasExample.ts +++ b/packages/rulesets/src/oas/functions/oasExample.ts @@ -3,6 +3,7 @@ import type { Dictionary, JsonPath, Optional } from '@stoplight/types'; import oasSchema, { Options as SchemaOptions } from './oasSchema'; import { createRulesetFunction, IFunctionResult } from '@stoplight/spectral-core'; import { oas2 } from '@stoplight/spectral-formats'; +import traverse from 'json-schema-traverse'; export type Options = { oasVersion: 2 | 3; @@ -110,6 +111,21 @@ function* getSchemaValidationItems( } } +/** + * Modifies 'schema' (and all its sub-schemas) to remove all "example" fields. + * In this context, "sub-schemas" refers to all schemas reachable from 'schema' + * (e.g. properties, additionalProperties, allOf/anyOf/oneOf, not, items, etc.) + * @param schema the schema to be "de-examplified" + * @returns 'schema' with example fields removed + */ +function deExamplify(schema: Record): void { + traverse(schema, (fragment => { + if ('example' in fragment) { + delete fragment.example; + } + })); +} + export default createRulesetFunction, Options>( { input: { @@ -149,6 +165,12 @@ export default createRulesetFunction, Options>( delete schemaOpts.schema.required; } + // Make a deep copy of the schema and then remove all the "example" fields from it. + // This is to avoid problems down in "ajv" which does the actual schema validation. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + schemaOpts.schema = JSON.parse(JSON.stringify(schemaOpts.schema)); + deExamplify(schemaOpts.schema); + for (const validationItem of validationItems) { const result = oasSchema(validationItem.value, schemaOpts, { ...context, diff --git a/test-harness/scenarios/overrides/new-rule-legacy-ruleset.scenario b/test-harness/scenarios/overrides/new-rule-legacy-ruleset.scenario new file mode 100644 index 000000000..e4fb03303 --- /dev/null +++ b/test-harness/scenarios/overrides/new-rule-legacy-ruleset.scenario @@ -0,0 +1,62 @@ +====test==== +Respect overrides with rules-only +====asset:spectral.yaml==== +aliases: + OperationObject: + - "#PathItem[get,put,post,delete,options,head,patch,trace]" + PathItem: + - $.paths[*] +rules: + operation-description: + given: '#OperationObject' + then: + field: 'description' + function: 'truthy' + severity: error +overrides: + - files: ["v2/**/*.json"] + rules: + summary-description: + given: '#OperationObject' + then: + field: 'summary' + function: 'truthy' + severity: warn +====asset:v2/document.json==== +{ + "openapi": "3.1.0", + "info": { + "description": "", + "title": "" + }, + "paths": { + "/": { + "get": {} + } + } +} +====asset:legacy/document.json==== +{ + "openapi": "3.1.0", + "info": { + "description": "", + "title": "" + }, + "paths": { + "/": { + "get": {} + } + } +} +====command==== +{bin} lint **/*.json --ruleset {asset:spectral.yaml} --fail-on-unmatched-globs +====stdout==== + +{asset:legacy/document.json} + 9:13 error operation-description "get.description" property must be truthy paths./.get + +{asset:v2/document.json} + 9:13 error operation-description "get.description" property must be truthy paths./.get + 9:13 warning summary-description "get.summary" property must be truthy paths./.get + +✖ 3 problems (2 errors, 1 warning, 0 infos, 0 hints) diff --git a/tsconfig.json b/tsconfig.json index 858e0c868..8f7094cec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "baseUrl": ".", "paths": { "@stoplight/spectral-core": ["packages/core/src/index.ts"], + "@stoplight/spectral-core/ruleset": ["packages/core/src/ruleset/index.ts"], + "@stoplight/spectral-core/ruleset/validation": ["packages/core/src/ruleset/validation/index.ts"], "@stoplight/spectral-formats": ["packages/formats/src/index.ts"], "@stoplight/spectral-functions": ["packages/functions/src/index.ts"], "@stoplight/spectral-parsers": ["packages/parsers/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index 72539fb9e..99849c982 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2526,7 +2526,7 @@ __metadata: strip-ansi: 6.0 text-table: 0.2 tslib: ^2.3.0 - xml2js: ^0.4.23 + xml2js: ^0.5.0 yargs: 17.3.1 bin: spectral: ./dist/index.js @@ -13172,14 +13172,14 @@ __metadata: linkType: hard "vm2@npm:^3.9.8": - version: 3.9.11 - resolution: "vm2@npm:3.9.11" + version: 3.9.17 + resolution: "vm2@npm:3.9.17" dependencies: acorn: ^8.7.0 acorn-walk: ^8.2.0 bin: vm2: bin/vm2 - checksum: aab39e6e4b59146d24abacd79f490e854a6e058a8b23d93d2be5aca7720778e2605d2cc028ccc4a5f50d3d91b0c38be9a6247a80d2da1a6de09425cc437770b4 + checksum: 9a03740a40ab2be5e3348a95fb31512da1a3c85318febb07e5299fa103ff05bcd7b6f458211fa38a1281dc27beccd04ff90355fc1d34fe2ee6ca10d0bb8c6f35 languageName: node linkType: hard @@ -13362,13 +13362,13 @@ __metadata: languageName: node linkType: hard -"xml2js@npm:^0.4.23": - version: 0.4.23 - resolution: "xml2js@npm:0.4.23" +"xml2js@npm:^0.5.0": + version: 0.5.0 + resolution: "xml2js@npm:0.5.0" dependencies: sax: ">=0.6.0" xmlbuilder: ~11.0.0 - checksum: ca0cf2dfbf6deeaae878a891c8fbc0db6fd04398087084edf143cdc83d0509ad0fe199b890f62f39c4415cf60268a27a6aed0d343f0658f8779bd7add690fa98 + checksum: 1aa71d62e5bc2d89138e3929b9ea46459157727759cbc62ef99484b778641c0cd21fb637696c052d901a22f82d092a3e740a16b4ce218e81ac59b933535124ea languageName: node linkType: hard