Skip to content
Draft
7 changes: 7 additions & 0 deletions .changeset/no-illogical-composition-keywords.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@redocly/cli': minor
---

Added the `no-illogical-composition-keywords` rule.

**Note**: the rule is set to `warn` in the `recommended` ruleset and to `error` in `recommended-strict`, so existing API descriptions may report new problems.
1 change: 1 addition & 0 deletions docs/@v2/rules/built-in-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ The rules list is split into sections.
- [no-duplicated-enum-values](./common/no-duplicated-enum-values.md): All values in an `enum` must be unique
- [no-enum-type-mismatch](./common/no-enum-type-mismatch.md): Enum options must match the data type declared in the schema
- [no-example-value-and-externalValue](./oas/no-example-value-and-externalValue.md): Either the `value` or `externalValue` may be present, but not both
- [no-illogical-composition-keywords](./oas/no-illogical-composition-keywords.md): `oneOf`, `anyOf`, and `allOf` must combine schemas a value can actually match
- [no-invalid-media-type-examples](./oas/no-invalid-media-type-examples.md): Example request bodies must match the declared schema
- [no-mixed-number-range-constraints](./common/no-mixed-number-range-constraints.md): Ensures that schemas do not use both `maximum` and `exclusiveMaximum` (or both `minimum` and `exclusiveMinimum`) at the same time.
- [no-invalid-schema-examples](./oas/no-invalid-schema-examples.md): Schema examples must match declared types
Expand Down
13 changes: 13 additions & 0 deletions docs/@v2/rules/configurable-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,19 @@ OpenAPI 3.0, 3.1, and 3.2 mostly share a type tree.

Learn more about the [OpenAPI node types](https://redocly.com/docs/openapi-visual-reference/openapi-node-types/).

The `OneOf`, `AnyOf`, and `AllOf` node types each match the list of schemas under the keyword of the same name.
Use them to assert on one composition keyword without matching the other two.

```yaml
rules:
rule/oneof-needs-two-schemas:
subject:
type: OneOf
message: Use at least two schemas in oneOf
assertions:
minLength: 2
```

### `any` example

The following example asserts that the maximum length of each description is 20 characters.
Expand Down
206 changes: 206 additions & 0 deletions docs/@v2/rules/oas/no-illogical-composition-keywords.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
---
slug: /docs/cli/rules/oas/no-illogical-composition-keywords
---

# no-illogical-composition-keywords

Ensures that `oneOf`, `anyOf`, and `allOf` combine schemas that a value can actually resolve against.

The rule reports:

- A `oneOf` or `anyOf` with fewer than two schemas, unless the schema declares a `discriminator`.
- An `allOf` with fewer than two schemas that neither declares another keyword of its own nor extends a discriminated schema.
- A schema repeated inside the same keyword.
- An empty schema (`{}`) used as a member.
- Two `oneOf` schemas that a single value can match at the same time.
- A nullable schema whose `oneOf` also accepts `null`.
- A `discriminator` whose property is missing from `required` in every member schema.
- An inline `oneOf` or `anyOf` member that a `discriminator` cannot select.

| OAS | Compatibility |
| --- | ------------- |
| 2.0 | ❌ |
| 3.0 | ✅ |
| 3.1 | ✅ |
| 3.2 | ✅ |

```mermaid
flowchart TD

Root ==> components --> NamedSchemas --> Schema

Schema ==> OneOf
Schema ==> AnyOf
Schema ==> AllOf

style OneOf fill:#codaf9,stroke:#0044d4,stroke-width:5px
style AnyOf fill:#codaf9,stroke:#0044d4,stroke-width:5px
style AllOf fill:#codaf9,stroke:#0044d4,stroke-width:5px
```

## API design principles

`oneOf` means "exactly one".
When a value matches two of the listed schemas, no tool can tell which one was intended, and validators, code generators, and documentation all disagree about the result.

The most common version of this is nullability: if a referenced schema already accepts `null` and the `oneOf` also lists `type: 'null'`, a null value matches both branches.
The same ambiguity appears one level up, when the schema holding the `oneOf` is itself nullable and a member accepts `null` too.

Deciding whether two arbitrary schemas overlap is not solvable in general, so the comparison stays deliberately narrow.
It reads `type`, `nullable`, `enum`, `const`, `properties`, `required`, and `additionalProperties: false`.
A `const` counts as a single-value `enum`, so one member can use `enum` and the other `const`.
When a member uses any other constraint, such as `not`, `pattern`, `minimum`, or a nested `allOf`, that constraint may be what separates the schemas, so the rule reports nothing for the pair.

A `discriminator` names the property that tells the members apart, so the rule trusts it and checks only what the specification requires.
The property must be listed in `required` in every member schema, because a value can otherwise omit it and nothing decides which schema applies.
Every member must also be a `$ref`: a `discriminator` selects a schema by its component name, and the specification states that inline `oneOf` and `anyOf` subschemas are not considered, so an inline member can never be selected.
A member that declares `$id` is exempt, because a `mapping` entry can name it by URI.

Wrapping one schema in `allOf` to attach sibling keywords, such as `description` or `readOnly` next to a `$ref`, stays common because support for `$ref` siblings is uneven across tools.
Referencing a schema that declares a `discriminator` carries meaning of its own too: the discriminator resolves the subtype by its schema name, so the wrapper declares a subtype even when it adds no properties.
The rule reports an `allOf` wrapper only when neither applies.

## Configuration

| Option | Type | Description |
| -------- | ------ | ----------------------------------------------------------------------------------------- |
| severity | string | Possible values: `off`, `warn`, `error`. Default `warn` (in `recommended` configuration). |

An example configuration:

```yaml
rules:
no-illogical-composition-keywords: error
```

## Examples

Given this configuration:

```yaml
rules:
no-illogical-composition-keywords: error
```

Example of an **incorrect** `oneOf` where both schemas accept `null`:

```yaml
components:
schemas:
TimeShift:
type: [object, 'null']
Invoice:
oneOf:
- $ref: '#/components/schemas/TimeShift'
- type: 'null'
```

Example of a **correct** `oneOf`:

```yaml
components:
schemas:
TimeShift:
type: object
Invoice:
oneOf:
- $ref: '#/components/schemas/TimeShift'
- type: 'null'
```

Example of an **incorrect** `discriminator` with an inline member:

```yaml
components:
schemas:
Cat:
type: object
properties:
petType:
type: string
required: [petType]
Pet:
discriminator:
propertyName: petType
oneOf:
- $ref: '#/components/schemas/Cat'
- type: object
properties:
petType:
type: string
required: [petType]
```

> Move the inline schema into `components/schemas` and reference it with a `$ref`.

Example of an **incorrect** `discriminator`, where `petType` is optional:

```yaml
components:
schemas:
Pet:
discriminator:
propertyName: petType
oneOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Dog'
Cat:
type: object
properties:
petType:
type: string
Dog:
type: object
properties:
petType:
type: string
```

> Add `petType` to `required` in both `Cat` and `Dog` to fix this.

Example of a **correct** single-schema `allOf` that declares a subtype:

```yaml
components:
schemas:
Pet:
type: object
required: [petType]
properties:
petType:
type: string
discriminator:
propertyName: petType
Cat:
allOf:
- $ref: '#/components/schemas/Pet'
```

Example of **incorrect** composition keywords:

```yaml
components:
schemas:
Pet:
oneOf:
- $ref: '#/components/schemas/Cat'
Animal:
allOf:
- $ref: '#/components/schemas/Cat'
- $ref: '#/components/schemas/Cat'
- {}
```

> `Pet` wraps a single schema, and `Animal` repeats one schema and adds an empty one that matches any value.

## Related rules

- [no-schema-type-mismatch](../common/no-schema-type-mismatch.md)
- [no-required-schema-properties-undefined](../common/no-required-schema-properties-undefined.md)
- [spec-discriminator-defaultMapping](./spec-discriminator-defaultMapping.md)

## Resources

- [Rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/oas3/no-illogical-composition-keywords.ts)
- [Schema object docs](https://redocly.com/docs/openapi-visual-reference/schemas/)
- [Discriminator object docs](https://redocly.com/docs/openapi-visual-reference/discriminator/)
1 change: 1 addition & 0 deletions docs/@v2/rules/recommended.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Warnings:
- [no-ambiguous-paths](./oas/no-ambiguous-paths.md)
- [no-duplicated-enum-values](./common/no-duplicated-enum-values.md)
- [no-duplicated-tag-names](./oas/no-duplicated-tag-names.md)
- [no-illogical-composition-keywords](./oas/no-illogical-composition-keywords.md)
- [no-invalid-media-type-examples](./oas/no-invalid-media-type-examples.md)
- [no-invalid-parameter-examples](./oas/no-invalid-parameter-examples.md)
- [no-invalid-schema-examples](./oas/no-invalid-schema-examples.md)
Expand Down
3 changes: 3 additions & 0 deletions docs/@v2/rules/ruleset-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ rules:
no-empty-servers: error
no-enum-type-mismatch: error
no-identical-paths: error
no-illogical-composition-keywords: warn
no-invalid-media-type-examples: warn
no-invalid-parameter-examples: warn
no-invalid-schema-examples: warn
Expand Down Expand Up @@ -237,6 +238,7 @@ rules:
no-enum-type-mismatch: error
no-example-value-and-externalValue: error
no-identical-paths: error
no-illogical-composition-keywords: warn
no-invalid-media-type-examples: warn
no-invalid-parameter-examples: warn
no-invalid-schema-examples: warn
Expand Down Expand Up @@ -283,6 +285,7 @@ rules:
no-enum-type-mismatch: error
no-example-value-and-externalValue: error
no-identical-paths: error
no-illogical-composition-keywords: warn
no-invalid-media-type-examples: warn
no-invalid-parameter-examples: warn
no-invalid-schema-examples: warn
Expand Down
1 change: 1 addition & 0 deletions docs/@v2/v2.sidebars.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
- page: rules/oas/no-example-value-and-externalValue.md
- page: rules/oas/no-http-verbs-in-paths.md
- page: rules/oas/no-identical-paths.md
- page: rules/oas/no-illogical-composition-keywords.md
- page: rules/oas/no-invalid-media-type-examples.md
- page: rules/oas/no-invalid-parameter-examples.md
- page: rules/oas/no-invalid-schema-examples.md
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`]
"EncodingMap",
"HeadersMap",
"Link",
"AllOf",
"AnyOf",
"OneOf",
"DiscriminatorMapping",
"Discriminator",
"Components",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"no-example-value-and-externalValue": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": {
"allowAdditionalProperties": false,
"severity": "error",
Expand Down Expand Up @@ -239,6 +240,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"no-example-value-and-externalValue": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": "error",
"no-invalid-parameter-examples": "warn",
"no-invalid-schema-examples": "warn",
Expand Down Expand Up @@ -302,6 +304,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
"no-enum-type-mismatch": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": "error",
"no-invalid-parameter-examples": "warn",
"no-invalid-schema-examples": "warn",
Expand Down Expand Up @@ -620,6 +623,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"no-example-value-and-externalValue": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": {
"allowAdditionalProperties": false,
"severity": "warn",
Expand Down Expand Up @@ -687,6 +691,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"no-example-value-and-externalValue": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": "warn",
"no-invalid-parameter-examples": "warn",
"no-invalid-schema-examples": "warn",
Expand Down Expand Up @@ -750,6 +755,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
"no-enum-type-mismatch": "error",
"no-http-verbs-in-paths": "off",
"no-identical-paths": "error",
"no-illogical-composition-keywords": "warn",
"no-invalid-media-type-examples": "warn",
"no-invalid-parameter-examples": "warn",
"no-invalid-schema-examples": "warn",
Expand Down
Loading
Loading