diff --git a/.changeset/tough-clouds-complain.md b/.changeset/tough-clouds-complain.md new file mode 100644 index 0000000000..f83604774f --- /dev/null +++ b/.changeset/tough-clouds-complain.md @@ -0,0 +1,6 @@ +--- +"@redocly/openapi-core": minor +"@redocly/cli": minor +--- + +Added the ability to override default problem messages for built-in rules. diff --git a/__tests__/lint/default-message-override/openapi.yaml b/__tests__/lint/default-message-override/openapi.yaml new file mode 100644 index 0000000000..71014bf49d --- /dev/null +++ b/__tests__/lint/default-message-override/openapi.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: Custom messages test +paths: + /test: + get: + responses: + 200: + content: + application/json: + schema: + type: object diff --git a/__tests__/lint/default-message-override/redocly.yaml b/__tests__/lint/default-message-override/redocly.yaml new file mode 100644 index 0000000000..e4948d0652 --- /dev/null +++ b/__tests__/lint/default-message-override/redocly.yaml @@ -0,0 +1,28 @@ +apis: + built-in-rule-message-override: + root: ./openapi.yaml + rules: + info-contact: + message: 'API LEVEL MESSAGE' # should override teh root-level message + severity: warn + operation-operationId: + severity: warn + message: 'API LEVEL WITH ORIGINAL MSG: {{message}}' # should enhance the original message + split-documentation: + root: split/openapi.yaml + +rules: + info-contact: + message: ROOT LEVEL MESSAGE # should be replaced with api-level message + severity: error + struct: + message: 'ROOT LEVEL WITH ORIGINAL MSG: {{message}}' # should enhance the original message + severity: error + rule/operationId: + subject: + type: Operation + message: 'Original problem: {{problems}}' # should not interfere with assertion messages + severity: error + assertions: + required: + - operationId diff --git a/__tests__/lint/default-message-override/snapshot.js b/__tests__/lint/default-message-override/snapshot.js new file mode 100644 index 0000000000..f09410bc0b --- /dev/null +++ b/__tests__/lint/default-message-override/snapshot.js @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`E2E lint default-message-override 1`] = ` + +validating openapi.yaml... +[1] openapi.yaml:7:5 at #/paths/~1test/get + +Original problem: operationId is required + +5 | paths: +6 | /test: +7 | get: + | ^^^ +8 | responses: +9 | 200: + +Error was generated by the rule/operationId rule. + + +[2] openapi.yaml:9:9 at #/paths/~1test/get/responses/200 + +ROOT LEVEL WITH ORIGINAL MSG: The field \`description\` must be present on this level. + + 7 | get: + 8 | responses: + 9 | 200: + | ^^^ +10 | content: +11 | application/json: + +Error was generated by the struct rule. + + +[3] openapi.yaml:2:1 at #/info/contact + +API LEVEL MESSAGE + +1 | openapi: 3.1.0 +2 | info: + | ^^^^ +3 | version: 1.0.0 +4 | title: Custom messages test + +Warning was generated by the info-contact rule. + + +[4] openapi.yaml:7:5 at #/paths/~1test/get/operationId + +API LEVEL WITH ORIGINAL MSG: Operation object should contain \`operationId\` field. + +5 | paths: +6 | /test: +7 | get: + | ^^^ +8 | responses: +9 | 200: + +Warning was generated by the operation-operationId rule. + + +openapi.yaml: validated in ms + +validating split/openapi.yaml... +[1] split/info.yaml:1:1 at #/contact + +ROOT LEVEL MESSAGE + +1 | version: 1.0.0 + | ^^^^^^^^^^^^^^ +2 | title: Custom messages test + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ +3 | + +Error was generated by the info-contact rule. + + +[2] split/paths/test.yaml:1:1 at #/get + +Original problem: operationId is required + +1 | get: + | ^^^ +2 | responses: +3 | '200': + +Error was generated by the rule/operationId rule. + + +[3] split/paths/test.yaml:3:5 at #/get/responses/200 + +ROOT LEVEL WITH ORIGINAL MSG: The field \`description\` must be present on this level. + +1 | get: +2 | responses: +3 | '200': + | ^^^^^ +4 | content: +5 | application/json: + +Error was generated by the struct rule. + + +split/openapi.yaml: validated in ms + +❌ Validation failed with 5 errors and 2 warnings. +run \`redocly lint --generate-ignore-file\` to add all problems to the ignore file. + + +`; diff --git a/__tests__/lint/default-message-override/split/info.yaml b/__tests__/lint/default-message-override/split/info.yaml new file mode 100644 index 0000000000..08b1259af5 --- /dev/null +++ b/__tests__/lint/default-message-override/split/info.yaml @@ -0,0 +1,2 @@ +version: 1.0.0 +title: Custom messages test diff --git a/__tests__/lint/default-message-override/split/openapi.yaml b/__tests__/lint/default-message-override/split/openapi.yaml new file mode 100644 index 0000000000..d385178d34 --- /dev/null +++ b/__tests__/lint/default-message-override/split/openapi.yaml @@ -0,0 +1,6 @@ +openapi: 3.1.0 +info: + $ref: ./info.yaml +paths: + /test: + $ref: paths/test.yaml diff --git a/__tests__/lint/default-message-override/split/paths/test.yaml b/__tests__/lint/default-message-override/split/paths/test.yaml new file mode 100644 index 0000000000..5ea80079c5 --- /dev/null +++ b/__tests__/lint/default-message-override/split/paths/test.yaml @@ -0,0 +1,7 @@ +get: + responses: + '200': + content: + application/json: + schema: + type: object diff --git a/docs/configuration/reference/rules.md b/docs/configuration/reference/rules.md index ca024fe411..91b02eba5d 100644 --- a/docs/configuration/reference/rules.md +++ b/docs/configuration/reference/rules.md @@ -38,6 +38,14 @@ The `rules` block can be used at the root of a configuration file, or inside an --- +- message +- string +- Optional custom message for this rule. + Example: `My Error Description. {{message}}`. + The {{message}} placeholder renders with the default error message for the rule. Include the {{message}} placeholder if you want to provide the user with your custom message as well as the default error message for the rule. + +--- + - {additional properties} - any - Some rules allow additional configuration, check the details of each rule to find out the values that can be supplied here. For example the [`boolean-parameter-prefixes` rule](../../rules/oas/boolean-parameter-prefixes.md) supports an additional option of `prefixes` that accepts an array of strings. diff --git a/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts index 8f2f17ed08..c14b281ee9 100644 --- a/packages/core/src/__tests__/walk.test.ts +++ b/packages/core/src/__tests__/walk.test.ts @@ -12,6 +12,7 @@ import { import { BaseResolver, Document } from '../resolve'; import { listOf } from '../types'; import { Oas3RuleSet } from '../oas-types'; +import { createConfig } from '../config'; describe('walk order', () => { it('should run visitors', async () => { @@ -1338,6 +1339,56 @@ describe('context.report', () => { ] `); }); + + it('should report errors with custom messages', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + info: + license: {} + paths: {} + `, + 'foobar.yaml' + ); + + const config = await createConfig(` + rules: + info-contact: + message: "MY ERR DESCRIPTION: {{message}}" + severity: "error" + `); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: config.styleguide, + }); + + expect(results).toMatchInlineSnapshot(` + [ + { + "location": [ + { + "pointer": "#/info/contact", + "reportOnKey": true, + "source": Source { + "absoluteRef": "foobar.yaml", + "body": "openapi: 3.0.0 + info: + license: {} + paths: {}", + "mimeType": undefined, + }, + }, + ], + "message": "MY ERR DESCRIPTION: Info object should contain \`contact\` field.", + "ruleId": "info-contact", + "severity": "error", + "suggest": [], + }, + ] + `); + }); }); describe('context.resolve', () => { diff --git a/packages/core/src/config/rules.ts b/packages/core/src/config/rules.ts index 1ae0b249c5..02b722d47a 100644 --- a/packages/core/src/config/rules.ts +++ b/packages/core/src/config/rules.ts @@ -39,19 +39,21 @@ export function initRules( return undefined; } const severity: ProblemSeverity = ruleSettings.severity; - + const message = ruleSettings.message; const visitors = rule(ruleSettings); if (Array.isArray(visitors)) { return visitors.map((visitor: any) => ({ severity, ruleId, + message, visitor: visitor, })); } return { severity, + message, ruleId, visitor: visitors, // note: actually it is only one visitor object }; diff --git a/packages/core/src/config/types.ts b/packages/core/src/config/types.ts index 40141c2148..465fb1f6a4 100644 --- a/packages/core/src/config/types.ts +++ b/packages/core/src/config/types.ts @@ -26,15 +26,11 @@ import type { JSONSchema } from 'json-schema-to-ts'; export type RuleSeverity = ProblemSeverity | 'off'; -export type RuleSettings = { severity: RuleSeverity }; +export type RuleSettings = { severity: RuleSeverity; message?: string }; export type PreprocessorSeverity = RuleSeverity | 'on'; -export type RuleConfig = - | RuleSeverity - | ({ - severity?: ProblemSeverity; - } & Record); +export type RuleConfig = RuleSeverity | (Partial & Record); export type PreprocessorConfig = | PreprocessorSeverity diff --git a/packages/core/src/visitors.ts b/packages/core/src/visitors.ts index 68767df49c..61b7282ec2 100644 --- a/packages/core/src/visitors.ts +++ b/packages/core/src/visitors.ts @@ -97,6 +97,7 @@ type VisitFunctionOrObject = VisitFunction | VisitObject; export type VisitorNode = { ruleId: string; severity: ProblemSeverity; + message?: string; context: VisitorLevelContext | VisitorSkippedLevelContext; depth: number; visit: VisitFunction; @@ -106,6 +107,7 @@ export type VisitorNode = { type VisitorRefNode = { ruleId: string; severity: ProblemSeverity; + message?: string; context: VisitorLevelContext; depth: number; visit: VisitRefFunction; @@ -365,6 +367,7 @@ export type OasDecorator = Oas3Decorator; export type RuleInstanceConfig = { ruleId: string; severity: ProblemSeverity; + message?: string; }; export function normalizeVisitors( @@ -390,8 +393,8 @@ export function normalizeVisitors( leave: [], }; - for (const { ruleId, severity, visitor } of visitorsConfig) { - normalizeVisitorLevel({ ruleId, severity }, visitor, null); + for (const { ruleId, severity, message, visitor } of visitorsConfig) { + normalizeVisitorLevel({ ruleId, severity, message }, visitor, null); } for (const v of Object.keys(normalizedVisitors)) { diff --git a/packages/core/src/walk.ts b/packages/core/src/walk.ts index 3847633cd4..5b2b9a8f6e 100644 --- a/packages/core/src/walk.ts +++ b/packages/core/src/walk.ts @@ -165,9 +165,9 @@ export function walkDocument(opts: { if (isRef(node)) { const refEnterVisitors = normalizedVisitors.ref.enter; - for (const { visit: visitor, ruleId, severity, context } of refEnterVisitors) { + for (const { visit: visitor, ruleId, severity, message, context } of refEnterVisitors) { enteredContexts.add(context); - const report = reportFn.bind(undefined, ruleId, severity); + const report = reportFn.bind(undefined, ruleId, severity, message); visitor( node, { @@ -203,7 +203,7 @@ export function walkDocument(opts: { const activatedContexts: Array = []; - for (const { context, visit, skip, ruleId, severity } of currentEnterVisitors) { + for (const { context, visit, skip, ruleId, severity, message } of currentEnterVisitors) { if (ignoredNodes.has(`${currentLocation.absolutePointer}${currentLocation.pointer}`)) break; if (context.isSkippedLevel) { @@ -258,7 +258,7 @@ export function walkDocument(opts: { if (!activatedOn.skipped) { visitedBySome = true; enteredContexts.add(context); - visitWithContext(visit, resolvedNode, node, context, ruleId, severity); + visitWithContext(visit, resolvedNode, node, context, ruleId, severity, message); } } } @@ -360,9 +360,9 @@ export function walkDocument(opts: { } } - for (const { context, visit, ruleId, severity } of currentLeaveVisitors) { + for (const { context, visit, ruleId, severity, message } of currentLeaveVisitors) { if (!context.isSkippedLevel && enteredContexts.has(context)) { - visitWithContext(visit, resolvedNode, node, context, ruleId, severity); + visitWithContext(visit, resolvedNode, node, context, ruleId, severity, message); } } } @@ -371,9 +371,9 @@ export function walkDocument(opts: { if (isRef(node)) { const refLeaveVisitors = normalizedVisitors.ref.leave; - for (const { visit: visitor, ruleId, severity, context } of refLeaveVisitors) { + for (const { visit: visitor, ruleId, severity, context, message } of refLeaveVisitors) { if (enteredContexts.has(context)) { - const report = reportFn.bind(undefined, ruleId, severity); + const report = reportFn.bind(undefined, ruleId, severity, message); visitor( node, { @@ -402,9 +402,10 @@ export function walkDocument(opts: { node: unknown, context: VisitorLevelContext, ruleId: string, - severity: ProblemSeverity + severity: ProblemSeverity, + customMessage: string | undefined ) { - const report = reportFn.bind(undefined, ruleId, severity); + const report = reportFn.bind(undefined, ruleId, severity, customMessage); visit( resolvedNode, { @@ -428,7 +429,12 @@ export function walkDocument(opts: { ); } - function reportFn(ruleId: string, severity: ProblemSeverity, opts: Problem) { + function reportFn( + ruleId: string, + severity: ProblemSeverity, + customMessage: string, + opts: Problem + ) { const normalizedLocation = opts.location ? Array.isArray(opts.location) ? opts.location @@ -445,6 +451,9 @@ export function walkDocument(opts: { ruleId: opts.ruleId || ruleId, severity: ruleSeverity, ...opts, + message: customMessage + ? customMessage.replace('{{message}}', opts.message) + : opts.message, suggest: opts.suggest || [], location, });