Repository: Redocly/redocly-cli
Pull request #816 (2022-08-19 12:30:37)

Feat: add rfc7807 rule
## What/Why/How?

Fix: #392 According the issue added new rules for check  rfc7807. https://datatracker.ietf.org/doc/html/rfc7807 

## Reference

## Testing

## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows
- [x] All new/updated code is covered with tests

## 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 

❌ An unexpected error occurred. For more details, [check console](https://github.com/Redocly/redocly-cli/actions/runs/2889431431) 
```
Error: The process '/usr/local/bin/npm' failed with exit code 1
```

| <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 | 68.49%     |    2897/4230    |
|                                                                           🟡                                                                           | Branches   | 61.71%     |    1589/2575    |
|                                                                           🔴                                                                           | Functions  | 59.59%     |     463/777     |
|                                                                           🟡                                                                           | Lines      | 68.3%      |    2683/3928    |

<details><summary>Show new covered files 🐣</summary>
<br/>

| <div title="Status of coverage:&#10; 🟢 - ok&#10; 🟡 - slightly more than threshold&#10; 🔴 - under the threshold">St.<sup>:grey_question:</sup></div> | File                                                                                                                             | Statements | Branches | Functions | Lines  |
| :----------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------- | :--------- | :------- | :-------- | :----- |
|                                                                           🔴                                                                           | <div title="core/src/rules/oas3/operation-4xx-problem-details-rfc7807.ts">`...` / operation-4xx-problem-details-rfc7807.ts</div> | 18.75%     | 0%       | 0%        | 14.29% |
|                                                                           🔴                                                                           | <div title="core/src/rules/oas2/operation-4xx-problem-details-rfc7807.ts">`...` / operation-4xx-problem-details-rfc7807.ts</div> | 42.86%     | 100%     | 0%        | 33.33% |

</details>


## Test suite run failed
Failed tests: 0/463. Failed suites: 0/77.

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

tatomyr: Please add this to all rules set: `core/src/config/all.ts`.
iosypov: > Please add this to all rules set: `core/src/config/all.ts`.

@tatomyr , done!
adamaltman: We also need:

- add file `docs/rules/operation-4xx-problem-details-rfc7807.md` following similar page template of `operation-4xx`
- add to docs/rules.md index file with link to details

I can help with that part if you need.

Patch:
diff --git a/docs/rules.md b/docs/rules.md
index 28fd115f..cbc1813f 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -31,6 +31,7 @@ The *Special rules* group contains rules that may apply to multiple objects or t
 
 - [operation-2xx-response](./rules/operation-2xx-response.md)
 - [operation-4xx-response](./rules/operation-4xx-response.md)
+- [operation-4xx-problem-details-rfc7807](./rules/operation-4xx-problem-details-rfc7807.md)
 - [operation-description](./rules/operation-description.md)
 - [operation-operationId](./rules/operation-operationId.md)
 - [operation-operationId-unique](./rules/operation-operationId-unique.md)
diff --git a/docs/rules/operation-4xx-problem-details-rfc7807.md b/docs/rules/operation-4xx-problem-details-rfc7807.md
new file mode 100644
index 00000000..fc65d404
--- /dev/null
+++ b/docs/rules/operation-4xx-problem-details-rfc7807.md
@@ -0,0 +1,84 @@
+# operation-4xx-problem-details-rfc7807
+
+Ensures that every operation with (400-499) HTTP response has content-type `application/problem+json` and fields `title` and `type`.
+
+| OAS | Compatibility |
+| --- | ------------- |
+| 2.0 | ❌            |
+| 3.0 | ✅            |
+| 3.1 | ✅            |
+
+## API design principles
+
+Problem Details for HTTP APIs are a way to carry machine-
+readable details of errors in a HTTP response to avoid the need to
+define new error response formats for HTTP APIs.
+
+Every operation with (400-499) HTTP response should have content-type `application/problem+json` and fields `title` and `type` according to the [specification](https://datatracker.ietf.org/doc/html/rfc7807).
+
+## Configuration
+
+| Option   | Type   | Description                                                                              |
+| -------- | ------ | ---------------------------------------------------------------------------------------- |
+| severity | string | Possible values: `off`, `warn`, `error`. Default `off` (in `recommended` configuration). |
+
+An example configuration:
+
+```yaml
+styleguide:
+  rules:
+    operation-4xx-problem-details-rfc7807: error
+```
+
+## Examples
+
+Given this configuration:
+
+```yaml
+styleguide:
+  rules:
+    operation-4xx-problem-details-rfc7807: error
+```
+
+Example of **incorrect** operation response:
+
+```yaml
+post:
+  responses:
+    '400':
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              type:
+                type: string
+              title:
+                type: string
+```
+
+Example of **correct** operation response:
+
+```yaml
+post:
+  responses:
+    '400':
+      content:
+        application/problem+json:
+          schema:
+            type: object
+            properties:
+              type:
+                type: string
+              title:
+                type: string
+```
+
+## Related rules
+
+- [operation-4xx-response](./operation-4xx-response.md)
+
+## Resources
+
+- [Rule source](https://github.com/Redocly/redocly-cli/blob/master/packages/core/src/rules/common/operation-4xx-response.ts)
+- [Responses map docs](https://redocly.com/docs/openapi-visual-reference/responses/)
diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts
index 1aa23b8e..6ec97401 100644
--- a/packages/core/src/config/all.ts
+++ b/packages/core/src/config/all.ts
@@ -52,6 +52,7 @@ export default {
     'no-unused-components': 'error',
     'no-undefined-server-variable': 'error',
     'no-server-variables-empty-enum': 'error',
+    'operation-4xx-problem-details-rfc7807': 'error',
   },
   oas3_1Rules: {
     'no-server-example.com': 'error',
@@ -61,5 +62,6 @@ export default {
     'no-unused-components': 'error',
     'no-undefined-server-variable': 'error',
     'no-server-variables-empty-enum': 'error',
+    'operation-4xx-problem-details-rfc7807': 'error',
   },
 } as PluginStyleguideConfig;
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index f3619288..ffe95ca6 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -48,6 +48,7 @@ import { NoInvalidParameterExamples } from '../common/no-invalid-parameter-examp
 import { ResponseContainsHeader } from '../common/response-contains-header';
 import { ResponseContainsProperty } from './response-contains-property';
 import { ScalarPropertyMissingExample } from '../common/scalar-property-missing-example';
+import { Operation4xxProblemDetailsRfc7807 } from './operation-4xx-problem-details-rfc7807';
 
 export const rules = {
   spec: OasSpec,
@@ -57,6 +58,7 @@ export const rules = {
   'info-license-url': InfoLicenseUrl,
   'operation-2xx-response': Operation2xxResponse,
   'operation-4xx-response': Operation4xxResponse,
+  'operation-4xx-problem-details-rfc7807': Operation4xxProblemDetailsRfc7807,
   assertions: Assertions,
   'operation-operationId-unique': OperationIdUnique,
   'operation-parameters-unique': OperationParametersUnique,
diff --git a/packages/core/src/rules/oas3/operation-4xx-problem-details-rfc7807.ts b/packages/core/src/rules/oas3/operation-4xx-problem-details-rfc7807.ts
new file mode 100644
index 00000000..bf8aa568
--- /dev/null
+++ b/packages/core/src/rules/oas3/operation-4xx-problem-details-rfc7807.ts
@@ -0,0 +1,36 @@
+import { Oas3MediaType, Oas3Response, Oas3Schema } from 'core/src/typings/openapi';
+import { Oas3Rule } from '../../visitors';
+import { UserContext } from '../../walk';
+import { validateDefinedAndNonEmpty } from '../utils';
+
+/**
+ * Validation according rfc7807 - https://datatracker.ietf.org/doc/html/rfc7807
+ */
+export const Operation4xxProblemDetailsRfc7807: Oas3Rule = () => {
+  return {
+    Response: {
+      skip(_response: Oas3Response, key: string | number) {
+        return !/4[Xx0-9]{2}/.test(`${key}`);
+      },
+      enter(response: Oas3Response, { report, location }: UserContext) {
+        if (!response.content || !response.content['application/problem+json'])
+          report({
+            message: 'Response `4xx` must have content-type `application/problem+json`.',
+            location: location.key(),
+          });
+      },
+      MediaType: {
+        skip(_response: Oas3MediaType, key: string | number) {
+          return key !== 'application/problem+json';
+        },
+        enter(media: Oas3MediaType, ctx: UserContext) {
+          validateDefinedAndNonEmpty('schema', media, ctx);
+        },
+        SchemaProperties(schema: Oas3Schema, ctx: UserContext) {
+          validateDefinedAndNonEmpty('type', schema, ctx);
+          validateDefinedAndNonEmpty('title', schema, ctx);
+        },
+      },
+    },
+  };
+};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 474ae695..bbd790b2 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -8,6 +8,7 @@ const builtInRulesList = [
   'info-license-url',
   'operation-2xx-response',
   'operation-4xx-response',
+  'operation-4xx-problem-details-rfc7807',
   'assertions',
   'operation-operationId-unique',
   'operation-parameters-unique',


Test patch:
diff --git a/__tests__/lint/operation-4xx-problem-details-rfc7807/.redocly.yaml b/__tests__/lint/operation-4xx-problem-details-rfc7807/.redocly.yaml
new file mode 100644
index 00000000..181437c6
--- /dev/null
+++ b/__tests__/lint/operation-4xx-problem-details-rfc7807/.redocly.yaml
@@ -0,0 +1,8 @@
+apis:
+  main:
+    root: ./openapi.yaml
+
+styleguide:
+  rules:
+    operation-4xx-problem-details-rfc7807: error
+  extends: []
diff --git a/__tests__/lint/operation-4xx-problem-details-rfc7807/openapi.yaml b/__tests__/lint/operation-4xx-problem-details-rfc7807/openapi.yaml
new file mode 100644
index 00000000..46de1fcf
--- /dev/null
+++ b/__tests__/lint/operation-4xx-problem-details-rfc7807/openapi.yaml
@@ -0,0 +1,22 @@
+openapi: 3.1.0
+info:
+  title: Example OpenAPI 3 definition.
+  version: 1.0
+
+paths:
+  '/ping':
+    get:
+      operationId: gitPing
+      summary: example text
+      responses:
+        '400':
+          description: example description
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  type:
+                    type: string
+                  title:
+                    type: string
\ No newline at end of file
diff --git a/__tests__/lint/operation-4xx-problem-details-rfc7807/snapshot.js b/__tests__/lint/operation-4xx-problem-details-rfc7807/snapshot.js
new file mode 100644
index 00000000..bead561c
--- /dev/null
+++ b/__tests__/lint/operation-4xx-problem-details-rfc7807/snapshot.js
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`E2E lint operation-4xx-problem-details-rfc7807 1`] = `
+
+validating /openapi.yaml...
+[1] openapi.yaml:12:9 at #/paths/~1ping/get/responses/400
+
+Response \`4xx\` must have content-type \`application/problem+json\`.
+
+10 | summary: example text
+11 | responses:
+12 |   '400':
+   |   ^^^^^
+13 |     description: example description
+14 |     content:
+
+Error was generated by the operation-4xx-problem-details-rfc7807 rule.
+
+
+/openapi.yaml: validated in <test>ms
+
+❌ Validation failed with 1 error.
+run \`openapi lint --generate-ignore-file\` to add all problems to the ignore file.
+
+
+`;
diff --git a/packages/core/src/rules/oas3/__tests__/operation-4xx-problem-details-rfc7807.test.ts b/packages/core/src/rules/oas3/__tests__/operation-4xx-problem-details-rfc7807.test.ts
new file mode 100644
index 00000000..ec14a81c
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/operation-4xx-problem-details-rfc7807.test.ts
@@ -0,0 +1,145 @@
+import { outdent } from 'outdent';
+import { lintDocument } from '../../../lint';
+import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils';
+import { BaseResolver } from '../../../resolve';
+
+describe('Oas3 operation-4xx-problem-details-rfc7807', () => {
+  it('should report `4xx` must have content type `application/problem+json` ', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: "3.0.0"
+        paths:
+          /pets:
+            get:
+              summary: List all pets
+              operationId: listPets
+              responses:
+                '400':
+                  description: Test
+                  content:
+                    application/json:
+                      schema:
+                        type: object
+                        properties:
+                          type:
+                            type: string
+                          title:
+                            type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'operation-4xx-problem-details-rfc7807': 'error' }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/paths/~1pets/get/responses/400",
+              "reportOnKey": true,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Response \`4xx\` must have content-type \`application/problem+json\`.",
+          "ruleId": "operation-4xx-problem-details-rfc7807",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
+  it('should report `application/problem+json` must have `type` property', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: "3.0.0"
+        paths:
+          /pets:
+            get:
+              summary: List all pets
+              operationId: listPets
+              responses:
+                '400':
+                  description: Test
+                  content:
+                    application/problem+json:
+                      schema:
+                        type: object
+                        properties:
+                          title:
+                            type: string
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'operation-4xx-problem-details-rfc7807': 'error' }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/paths/~1pets/get/responses/400/content/application~1problem+json/schema/properties/type",
+              "reportOnKey": true,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "SchemaProperties object should contain \`type\` field.",
+          "ruleId": "operation-4xx-problem-details-rfc7807",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+
+  it('should report `application/problem+json` must have `schema` property', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: "3.0.0"
+        paths:
+          /pets:
+            get:
+              summary: List all pets
+              operationId: listPets
+              responses:
+                '400':
+                  description: Test
+                  content:
+                    application/problem+json:
+                      example: asd
+        `,
+      'foobar.yaml'
+    );
+
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({ 'operation-4xx-problem-details-rfc7807': 'error' }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      Array [
+        Object {
+          "location": Array [
+            Object {
+              "pointer": "#/paths/~1pets/get/responses/400/content/application~1problem+json/schema",
+              "reportOnKey": true,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "MediaType object should contain \`schema\` field.",
+          "ruleId": "operation-4xx-problem-details-rfc7807",
+          "severity": "error",
+          "suggest": Array [],
+        },
+      ]
+    `);
+  });
+});