diff --git a/.changeset/silver-singers-glow.md b/.changeset/silver-singers-glow.md new file mode 100644 index 0000000000..345da536c7 --- /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 0000000000..8c0226060e --- /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/__tests__/__snapshots__/config-resolvers.test.ts.snap b/packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap index 59fa897f15..a9136e1f0c 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/config/all.ts b/packages/core/src/config/all.ts index c8ff3888ec..331a5672de 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 edf1a900b6..3361ae1221 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 24eed594a3..deceed8f99 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 bc4d513f5c..2855bcda1b 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/__tests__/array-parameter-serialization.test.ts b/packages/core/src/rules/oas3/__tests__/array-parameter-serialization.test.ts new file mode 100644 index 0000000000..bda47b8d8d --- /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": [], + }, + ] + `); + }); +}); 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 0000000000..b93f20f61c --- /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(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 aa1add84c2..900284377a 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 4c6c2fab97..9e4e567865 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 c6c1619eee..4c9a5895a4 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 {