Repository: Redocly/redocly-cli
Pull request #1338 (2023-11-22 13:16:44)

feat: create new rule to enforce array parameters format
## What/Why/How?

Create new rule `array-parameter-serialization` rule to enforce array parameters format.

This Rule enforces to define  fields `explode` and `style` for parameter if param's schema type is `array` or has `items` or `preffixItems`

## Reference

## Testing
https://github.com/Redocly/redocly-cli/issues/1317
## Screenshots (optional)

## Check yourself

- [x] Code is linted
- [ ] Tested with redoc/reference-docs/workflows (internal)
- [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


changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: c94be1a64c3a5bef765e167b5d083b953761e543

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Redocly/redocly-cli/new/feat/array-parameter-serialization-rule?filename=.changeset/fifty-cycles-happen.md&value=---%0A%22%40redocly%2Fopenapi-core%22%3A%20patch%0A---%0A%0Afeat%3A%20create%20new%20rule%20to%20enforce%20array%20parameters%20format%0A)


github-actions[bot]: | Command | Mean [s] | Min [s] | Max [s] | Relative |
|:---|---:|---:|---:|---:|
| `redocly lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.009 ± 0.028 | 0.988 | 1.077 | 1.00 ± 0.03 |
| `redocly-next lint packages/core/src/benchmark/benches/rebilly.yaml` | 1.004 ± 0.018 | 0.980 | 1.037 | 1.00 |

<!-- thollander/actions-comment-pull-request "hyperfine-comparison" -->
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 | 76.15%     |    4067/5341    |
|                                                                           🟡                                                                           | Branches   | 66.04%     |    2166/3280    |
|                                                                           🟡                                                                           | Functions  | 68.39%     |     660/965     |
|                                                                           🟡                                                                           | Lines      | 76.34%     |    3814/4996    |

<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/array-parameter-serialization.ts">`...` / array-parameter-serialization.ts</div> | 90%        | 83.33%   | 100%      | 88.89% |

</details>


## Test suite run success
651 tests passing in 94 suites.

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

lornajane: Could we get a changeset please?

The rule name doesn't need `-rule` in it, but the rest is good :)
lornajane: I think I broke something! The following snippet does return as valid, which is correct:

```
      parameters:
        - name: f
          in: query
          style: label
          explode: true
          schema:
            type: array
            items:
              type: string
```

However if the `explode` field is set to `false` (it's boolean, so it might well be set to false!) then I get the error `Parameter `f` should have `style` and `explode ` fields`. Could you take another look?
IgorKarpiuk: Thanks @lornajane I updated the code and added it to test
IgorKarpiuk: @lornajane here is the test https://github.com/Redocly/redocly-cli/pull/1338/files#diff-4cf43b18ff2bdc2e4cdc694067da4ae9be20137568cc0196702c01433b0e0ebaR161

Patch:
diff --git a/.changeset/silver-singers-glow.md b/.changeset/silver-singers-glow.md
new file mode 100644
index 00000000..345da536
--- /dev/null
+++ b/.changeset/silver-singers-glow.md
@@ -0,0 +1,6 @@
+---
+"@redocly/openapi-core": minor
+"@redocly/cli": minor
+---
+
+Added new rule `array-parameter-serialization` to require that serialization parameters `style` and `explode` are present on array parameters.
diff --git a/docs/rules/array-parameter-serialization.md b/docs/rules/array-parameter-serialization.md
new file mode 100644
index 00000000..8c022606
--- /dev/null
+++ b/docs/rules/array-parameter-serialization.md
@@ -0,0 +1,109 @@
+---
+slug: /docs/cli/rules/array-parameter-serialization
+---
+
+# array-parameter-serialization
+
+Enforces the inclusion of `style` and `explode` fields for parameters with array type or parameters with a schema that includes `items` or `prefixItems`.
+
+| OAS | Compatibility |
+| --- | ------------- |
+| 2.0 | ❌            |
+| 3.0 | ✅            |
+| 3.1 | ✅            |
+
+```mermaid
+flowchart TD
+
+root ==> Paths --> PathItem --> Operation --> Parameter --enforces style and explode fields for array types--> Schema
+PathItem --> Parameter
+NamedParameter --> Parameter
+
+root ==> components
+
+subgraph components
+NamedParameter
+end
+
+style Parameter fill:#codaf9,stroke:#0044d4,stroke-width:5px
+style Schema fill:#codaf9,stroke:#0044d4,stroke-width:5px
+```
+
+## API design principles
+
+Specifying serialization details consistently helps developers understand how to interact with the API effectively.
+
+## Configuration
+
+| Option   | Type     | Description                                                                                                                      |
+| -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| severity | string   | Possible values: `off`, `warn`, `error`. Default `off`.                                                                          |
+| in       | [string] | List of valid parameter locations where the rule should be enforced. By default the rule applies to parameters in all locations. |
+
+An example configuration:
+
+```yaml
+rules:
+  array-parameter-serialization:
+    severity: error
+    in:
+      - query
+      - header
+```
+
+## Examples
+
+Given this configuration:
+
+```yaml
+rules:
+  array-parameter-serialization:
+    severity: error
+    in:
+      - query
+```
+
+Example of **incorrect** parameter:
+
+```yaml
+paths:
+  /example:
+    get:
+      parameters:
+        - name: exampleArray
+          in: query
+          schema:
+            type: array
+            items:
+              type: string
+```
+
+Example of **correct** parameter:
+
+```yaml
+paths:
+  /example:
+    get:
+      parameters:
+        - name: exampleArray
+          in: query
+          style: form
+          explode: true
+          schema:
+            type: array
+            items:
+              type: string
+```
+
+## Related rules
+
+- [configurable rules](./configurable-rules.md)
+- [boolean-parameter-prefixes](./boolean-parameter-prefixes.md)
+- [no-invalid-parameter-examples](./no-invalid-parameter-examples.md)
+- [parameter-description](./parameter-description.md)
+- [operation-parameters-unique](./operation-parameters-unique.md)
+
+## Resources
+
+- [Rule source for OAS 3.0 and 3.1](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/oas3/array-parameter-serialization.ts)
+- [OpenAPI Parameter](https://redocly.com/docs/openapi-visual-reference/parameter/) docs
diff --git a/packages/core/src/config/all.ts b/packages/core/src/config/all.ts
index c8ff3888..331a5672 100644
--- a/packages/core/src/config/all.ts
+++ b/packages/core/src/config/all.ts
@@ -78,6 +78,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'component-name-unique': 'error',
     'response-contains-property': 'error',
     'spec-components-invalid-map-name': 'error',
+    'array-parameter-serialization': 'error',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'error',
@@ -101,6 +102,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
     'component-name-unique': 'error',
     'response-contains-property': 'error',
     'spec-components-invalid-map-name': 'error',
+    'array-parameter-serialization': 'error',
   },
   async2Rules: {
     'channels-kebab-case': 'error',
diff --git a/packages/core/src/config/minimal.ts b/packages/core/src/config/minimal.ts
index edf1a900..3361ae12 100644
--- a/packages/core/src/config/minimal.ts
+++ b/packages/core/src/config/minimal.ts
@@ -66,6 +66,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'warn',
@@ -83,6 +84,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   async2Rules: {
     'channels-kebab-case': 'off',
diff --git a/packages/core/src/config/recommended-strict.ts b/packages/core/src/config/recommended-strict.ts
index 24eed594..deceed8f 100644
--- a/packages/core/src/config/recommended-strict.ts
+++ b/packages/core/src/config/recommended-strict.ts
@@ -66,6 +66,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'error',
@@ -83,6 +84,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   async2Rules: {
     'channels-kebab-case': 'off',
diff --git a/packages/core/src/config/recommended.ts b/packages/core/src/config/recommended.ts
index bc4d513f..2855bcda 100644
--- a/packages/core/src/config/recommended.ts
+++ b/packages/core/src/config/recommended.ts
@@ -66,6 +66,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   oas3_1Rules: {
     'no-invalid-media-type-examples': 'warn',
@@ -83,6 +84,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
     'request-mime-type': 'off',
     'response-contains-property': 'off',
     'response-mime-type': 'off',
+    'array-parameter-serialization': 'off',
   },
   async2Rules: {
     'channels-kebab-case': 'off',
diff --git a/packages/core/src/rules/oas3/array-parameter-serialization.ts b/packages/core/src/rules/oas3/array-parameter-serialization.ts
new file mode 100644
index 00000000..b93f20f6
--- /dev/null
+++ b/packages/core/src/rules/oas3/array-parameter-serialization.ts
@@ -0,0 +1,43 @@
+import { Oas3Rule, Oas3Visitor } from '../../visitors';
+import { isRef } from '../../ref-utils';
+import { Oas3_1Schema, Oas3Parameter } from '../../typings/openapi';
+
+export type ArrayParameterSerializationOptions = {
+  in?: string[];
+};
+
+export const ArrayParameterSerialization: Oas3Rule = (
+  options: ArrayParameterSerializationOptions
+): Oas3Visitor => {
+  return {
+    Parameter: {
+      leave(node: Oas3Parameter, ctx) {
+        if (!node.schema) {
+          return;
+        }
+        const schema = isRef(node.schema)
+          ? ctx.resolve<Oas3_1Schema>(node.schema).node
+          : (node.schema as Oas3_1Schema);
+
+        if (schema && shouldReportMissingStyleAndExplode(node, schema, options)) {
+          ctx.report({
+            message: `Parameter \`${node.name}\` should have \`style\` and \`explode \` fields`,
+            location: ctx.location,
+          });
+        }
+      },
+    },
+  };
+};
+
+function shouldReportMissingStyleAndExplode(
+  node: Oas3Parameter,
+  schema: Oas3_1Schema,
+  options: ArrayParameterSerializationOptions
+) {
+  return (
+    (schema.type === 'array' || schema.items || schema.prefixItems) &&
+    (node.style === undefined || node.explode === undefined) &&
+    (!options.in || (node.in && options.in?.includes(node.in)))
+  );
+}
diff --git a/packages/core/src/rules/oas3/index.ts b/packages/core/src/rules/oas3/index.ts
index aa1add84..90028437 100644
--- a/packages/core/src/rules/oas3/index.ts
+++ b/packages/core/src/rules/oas3/index.ts
@@ -52,6 +52,7 @@ import { Operation4xxProblemDetailsRfc7807 } from './operation-4xx-problem-detai
 import { RequiredStringPropertyMissingMinLength } from '../common/required-string-property-missing-min-length';
 import { SpecStrictRefs } from '../common/spec-strict-refs';
 import { ComponentNameUnique } from './component-name-unique';
+import { ArrayParameterSerialization } from './array-parameter-serialization';
 
 export const rules: Oas3RuleSet<'built-in'> = {
   spec: Spec,
@@ -108,6 +109,7 @@ export const rules: Oas3RuleSet<'built-in'> = {
   'required-string-property-missing-min-length': RequiredStringPropertyMissingMinLength,
   'spec-strict-refs': SpecStrictRefs,
   'component-name-unique': ComponentNameUnique,
+  'array-parameter-serialization': ArrayParameterSerialization,
 };
 
 export const preprocessors = {};
diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts
index 4c6c2fab..9e4e5678 100644
--- a/packages/core/src/types/redocly-yaml.ts
+++ b/packages/core/src/types/redocly-yaml.ts
@@ -77,6 +77,7 @@ const builtInOAS3Rules = [
   'response-contains-property',
   'response-mime-type',
   'spec-components-invalid-map-name',
+  'array-parameter-serialization',
 ] as const;
 
 export type BuiltInOAS3RuleId = typeof builtInOAS3Rules[number];
diff --git a/packages/core/src/typings/openapi.ts b/packages/core/src/typings/openapi.ts
index c6c1619e..4c9a5895 100644
--- a/packages/core/src/typings/openapi.ts
+++ b/packages/core/src/typings/openapi.ts
@@ -156,6 +156,7 @@ export interface Oas3Schema {
 export type Oas3_1Schema = Oas3Schema & {
   type?: string | string[];
   examples?: any[];
+  prefixItems?: Oas3_1Schema[];
 };
 
 export interface Oas3_1Definition extends Oas3Definition {


Test patch:
diff --git a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
index 59fa897f..a9136e1f 100644
--- a/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
+++ b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap
@@ -21,6 +21,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
   "oas3_0Decorators": {},
   "oas3_0Preprocessors": {},
   "oas3_0Rules": {
+    "array-parameter-serialization": "off",
     "boolean-parameter-prefixes": "error",
     "component-name-unique": "off",
     "no-empty-servers": "error",
@@ -40,6 +41,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
   "oas3_1Decorators": {},
   "oas3_1Preprocessors": {},
   "oas3_1Rules": {
+    "array-parameter-serialization": "off",
     "boolean-parameter-prefixes": "error",
     "component-name-unique": "off",
     "no-empty-servers": "error",
@@ -125,6 +127,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
   "oas3_0Decorators": {},
   "oas3_0Preprocessors": {},
   "oas3_0Rules": {
+    "array-parameter-serialization": "off",
     "boolean-parameter-prefixes": "error",
     "component-name-unique": "off",
     "no-empty-servers": "error",
@@ -144,6 +147,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
   "oas3_1Decorators": {},
   "oas3_1Preprocessors": {},
   "oas3_1Rules": {
+    "array-parameter-serialization": "off",
     "boolean-parameter-prefixes": "error",
     "component-name-unique": "off",
     "no-empty-servers": "error",
diff --git a/packages/core/src/rules/oas3/__tests__/array-parameter-serialization.test.ts b/packages/core/src/rules/oas3/__tests__/array-parameter-serialization.test.ts
new file mode 100644
index 00000000..bda47b8d
--- /dev/null
+++ b/packages/core/src/rules/oas3/__tests__/array-parameter-serialization.test.ts
@@ -0,0 +1,263 @@
+import { outdent } from 'outdent';
+import { parseYamlToDocument, replaceSourceWithRef, makeConfig } from '../../../../__tests__/utils';
+import { lintDocument } from '../../../lint';
+import { BaseResolver } from '../../../resolve';
+
+describe('oas3 array-parameter-serialization', () => {
+  it('should report on array parameter without style and explode', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          '/test':
+            parameters:
+            - name: a
+              in: query
+              schema:
+                type: array
+                items:
+                  type: string
+            - name: b
+              in: header
+              schema:
+                type: array
+                items:
+                  type: string     
+      `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error', in: ['query'] },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/paths/~1test/parameters/0",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Parameter \`a\` should have \`style\` and \`explode \` fields",
+          "ruleId": "array-parameter-serialization",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report on array parameter with style but without explode', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          '/test':
+            parameters:
+            - name: a
+              in: query
+              style: form
+              schema:
+                type: array
+                items:
+                  type: string
+      `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error', in: ['query'] },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/paths/~1test/parameters/0",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Parameter \`a\` should have \`style\` and \`explode \` fields",
+          "ruleId": "array-parameter-serialization",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should report on parameter without type but with items', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.1.0
+        paths: 
+          /test:
+            parameters: 
+              - name: test only type, path level
+                in: query
+                schema:
+                  type: array # no items
+            get: 
+              parameters: 
+                - name: test only items, operation level
+                  in: header
+                  items: # no type
+                    type: string
+        components: 
+          parameters:
+            TestParameter: 
+              in: cookie
+              name: test only prefixItems, components level
+              prefixItems: # no type or items
+                - type: number    
+              `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error', in: ['query'] },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/paths/~1test/parameters/0",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Parameter \`test only type, path level\` should have \`style\` and \`explode \` fields",
+          "ruleId": "array-parameter-serialization",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+
+  it('should not report on array parameter with style and explode', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          '/test':
+            parameters:
+            - name: a
+              in: query
+              style: form
+              explode: false
+              schema:
+                type: array
+                items:
+                  type: string
+      `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error', in: ['query'] },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it('should not report non-array parameter without style and explode', async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          '/test':
+            parameters:
+            - name: a
+              in: query
+              schema:
+                type: string
+      `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error', in: ['query'] },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
+  });
+
+  it("should report all array parameter without style and explode if property 'in' not defined ", async () => {
+    const document = parseYamlToDocument(
+      outdent`
+        openapi: 3.0.0
+        paths:
+          '/test':
+            parameters:
+            - name: a
+              in: query
+              schema:
+                type: array
+                items:
+                  type: string
+            - name: b
+              in: header
+              schema:
+                type: array
+                items:
+                  type: string     
+      `,
+      'foobar.yaml'
+    );
+    const results = await lintDocument({
+      externalRefResolver: new BaseResolver(),
+      document,
+      config: await makeConfig({
+        'array-parameter-serialization': { severity: 'error' },
+      }),
+    });
+    expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
+      [
+        {
+          "location": [
+            {
+              "pointer": "#/paths/~1test/parameters/0",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Parameter \`a\` should have \`style\` and \`explode \` fields",
+          "ruleId": "array-parameter-serialization",
+          "severity": "error",
+          "suggest": [],
+        },
+        {
+          "location": [
+            {
+              "pointer": "#/paths/~1test/parameters/1",
+              "reportOnKey": false,
+              "source": "foobar.yaml",
+            },
+          ],
+          "message": "Parameter \`b\` should have \`style\` and \`explode \` fields",
+          "ruleId": "array-parameter-serialization",
+          "severity": "error",
+          "suggest": [],
+        },
+      ]
+    `);
+  });
+});