Repository: Redocly/redocly-cli
Pull request #1834 (2024-12-19 17:15:54)

feat: support custom message for built-in rules
## What/Why/How?

Added the ability to override default problem messages for built-in rules.

Configuration example (the original problem will replace `{{message}}`):

```yaml
struct:
  severity: error
  message: CODE-123. Invalid structure. {{message}}
```

## Reference

The PR is based on this one: https://github.com/Redocly/redocly-cli/pull/1821

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code changed? - Tested with redoc/reference-docs/workflows (internal)
- [x] All new/updated code is covered with tests
- [ ] New package installed? - Tested in different environments (browser/node)

## Security

- [x] Security impact of change has been considered
- [x] Code follows company security practices and guidelines


github-actions[bot]: <!-- jest coverage report action for options with hash 3339ae53a38c44382c0fb4491bdb282d -->

## Coverage report 



| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | Category   | Percentage | Covered / Total |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :--------- | :--------- | :-------------: |
|                                                                           🟡                                                                           | Statements | 78.62%     |    5045/6417    |
|                                                                           🟡                                                                           | Branches   | 67.27%     |    2055/3055    |
|                                                                           🟡                                                                           | Functions  | 73.11%     |     832/1138    |
|                                                                           🟡                                                                           | Lines      | 78.91%     |    4759/6031    |


## Test suite run success
833 tests passing in 120 suites.

<p align="right">Report generated by <a href="https://github.com/ArtiomTr/jest-coverage-report-action">🧪jest coverage report action</a> from 3c620f4e819a519537bf760e593bbbdc489a6f8a</p>


Patch:
diff --git a/.changeset/tough-clouds-complain.md b/.changeset/tough-clouds-complain.md
new file mode 100644
index 00000000..f8360477
--- /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/docs/configuration/reference/rules.md b/docs/configuration/reference/rules.md
index ca024fe4..91b02eba 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/config/rules.ts b/packages/core/src/config/rules.ts
index 1ae0b249..02b722d4 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 40141c21..465fb1f6 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<string, any>);
+export type RuleConfig = RuleSeverity | (Partial<RuleSettings> & Record<string, any>);
 
 export type PreprocessorConfig =
   | PreprocessorSeverity
diff --git a/packages/core/src/visitors.ts b/packages/core/src/visitors.ts
index 68767df4..61b7282e 100644
--- a/packages/core/src/visitors.ts
+++ b/packages/core/src/visitors.ts
@@ -97,6 +97,7 @@ type VisitFunctionOrObject<T> = VisitFunction<T> | VisitObject<T>;
 export type VisitorNode<T> = {
   ruleId: string;
   severity: ProblemSeverity;
+  message?: string;
   context: VisitorLevelContext | VisitorSkippedLevelContext;
   depth: number;
   visit: VisitFunction<T>;
@@ -106,6 +107,7 @@ export type VisitorNode<T> = {
 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<T extends BaseVisitor>(
@@ -390,8 +393,8 @@ export function normalizeVisitors<T extends BaseVisitor>(
     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 3847633c..5b2b9a8f 100644
--- a/packages/core/src/walk.ts
+++ b/packages/core/src/walk.ts
@@ -165,9 +165,9 @@ export function walkDocument<T extends BaseVisitor>(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<T extends BaseVisitor>(opts: {
 
       const activatedContexts: Array<VisitorSkippedLevelContext | VisitorLevelContext> = [];
 
-      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<T extends BaseVisitor>(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<T extends BaseVisitor>(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<T extends BaseVisitor>(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<T extends BaseVisitor>(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<T extends BaseVisitor>(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<T extends BaseVisitor>(opts: {
           ruleId: opts.ruleId || ruleId,
           severity: ruleSeverity,
           ...opts,
+          message: customMessage
+            ? customMessage.replace('{{message}}', opts.message)
+            : opts.message,
           suggest: opts.suggest || [],
           location,
         });


Test patch:
diff --git a/__tests__/lint/default-message-override/openapi.yaml b/__tests__/lint/default-message-override/openapi.yaml
new file mode 100644
index 00000000..71014bf4
--- /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 00000000..e4948d06
--- /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 00000000..f09410bc
--- /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 <test>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 <test>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 00000000..08b1259a
--- /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 00000000..d385178d
--- /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 00000000..5ea80079
--- /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/packages/core/src/__tests__/walk.test.ts b/packages/core/src/__tests__/walk.test.ts
index 8f2f17ed..c14b281e 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', () => {