Skip to content

Commit e1c065b

Browse files
authored
feat: add schema type mismatch rule (#1890)
1 parent d449fa4 commit e1c065b

File tree

13 files changed

+294
-2
lines changed

13 files changed

+294
-2
lines changed

.changeset/good-ducks-turn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@redocly/openapi-core": minor
3+
---
4+
5+
Added the `no-schema-type-mismatch` rule.

docs/rules/built-in-rules.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ The rules list is split into sections.
8282
- [no-invalid-media-type-examples](./oas/no-invalid-media-type-examples.md): Example request bodies must match the declared schema
8383
- [no-invalid-schema-examples](./oas/no-invalid-schema-examples.md): Schema examples must match declared types
8484
- [no-required-schema-properties-undefined](./oas/no-required-schema-properties-undefined.md): All properties marked as required must be defined
85+
- [no-schema-type-mismatch](./oas/no-schema-type-mismatch.md): Detects schemas with type mismatches between object and items fields, and array and properties fields.
8586
- [request-mime-type](./oas/request-mime-type.md): Configure allowed mime types for requests
8687
- [response-mime-type](./oas/response-mime-type.md): Configure allowed mime types for responses
8788
- [response-contains-header](./oas/response-contains-header.md): List headers that must be included with specific response types
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
---
2+
slug: /docs/cli/rules/oas/no-schema-type-mismatch
3+
---
4+
5+
# no-schema-type-mismatch
6+
7+
Ensures that a schema's structural properties match its declared `type`. In particular:
8+
9+
- A schema of type `object` **must not** include an `items` field.
10+
- A schema of type `array` **must not** include a `properties` field.
11+
12+
| OAS | Compatibility |
13+
| --- | ------------- |
14+
| 2.0 ||
15+
| 3.0 ||
16+
| 3.1 ||
17+
18+
```mermaid
19+
flowchart TD
20+
Schema -->|if type is object| CheckItems["'items' field exists?"]
21+
Schema -->|if type is array| CheckProps["'properties' field exists?"]
22+
```
23+
24+
## API design principles
25+
26+
When designing an API schema, the defined `type` should be consistent with its structure:
27+
28+
- **Objects** are collections of key/value pairs. They should be defined using `properties` (or additionalProperties) and must not use `items`.
29+
- **Arrays** are ordered lists of items and must use `items` to define their content. Including `properties` is invalid.
30+
31+
This rule helps catch typos and misconfigurations early in your API definition.
32+
33+
## Configuration
34+
35+
| Option | Type | Description |
36+
| -------- | ------ | --------------------------------------------------------------------------------------------- |
37+
| severity | string | Possible values: `off`, `warn`, `error`. Default is `error` in the recommended configuration. |
38+
39+
Example configuration:
40+
41+
```yaml
42+
rules:
43+
no-schema-type-mismatch: error
44+
```
45+
46+
## Examples
47+
48+
### Incorrect Examples
49+
50+
#### Object type with an `items` field
51+
52+
```yaml
53+
properties:
54+
user:
55+
type: object
56+
properties:
57+
id:
58+
type: string
59+
items:
60+
type: number
61+
```
62+
63+
_Error:_ An `object` type should not include an `items` field.
64+
65+
#### Array type with a `properties` field
66+
67+
```yaml
68+
properties:
69+
tags:
70+
type: array
71+
properties:
72+
name:
73+
type: string
74+
```
75+
76+
_Error:_ An `array` type should not include a `properties` field.
77+
78+
### Correct Examples
79+
80+
#### Object type with proper `properties`
81+
82+
```yaml
83+
properties:
84+
user:
85+
type: object
86+
properties:
87+
id:
88+
type: string
89+
name:
90+
type: string
91+
```
92+
93+
#### Array type with proper `items`
94+
95+
```yaml
96+
properties:
97+
tags:
98+
type: array
99+
items:
100+
type: string
101+
```
102+
103+
## Related rules
104+
105+
- [configurable rules](../configurable-rules.md)
106+
- [no-invalid-media-type-examples](./no-invalid-media-type-examples.md)
107+
- [no-invalid-parameter-examples](./no-invalid-parameter-examples.md)
108+
- [no-invalid-schema-examples](./no-invalid-schema-examples.md)
109+
110+
## Resources
111+
112+
- [Rule source](https://github.com/Redocly/redocly-cli/blob/main/packages/core/src/rules/common/no-schema-type-mismatch.ts)

packages/core/src/config/__tests__/__snapshots__/config-resolvers.test.ts.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
5959
"no-invalid-schema-examples": "off",
6060
"no-path-trailing-slash": "error",
6161
"no-required-schema-properties-undefined": "off",
62+
"no-schema-type-mismatch": "warn",
6263
"no-unresolved-refs": "error",
6364
"operation-2xx-response": "warn",
6465
"operation-4xx-response": "error",
@@ -114,6 +115,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
114115
"no-invalid-schema-examples": "off",
115116
"no-path-trailing-slash": "error",
116117
"no-required-schema-properties-undefined": "off",
118+
"no-schema-type-mismatch": "warn",
117119
"no-server-example.com": "warn",
118120
"no-server-trailing-slash": "error",
119121
"no-server-variables-empty-enum": "error",
@@ -173,6 +175,7 @@ exports[`resolveConfig should ignore minimal from the root and read local file 1
173175
"no-invalid-schema-examples": "off",
174176
"no-path-trailing-slash": "error",
175177
"no-required-schema-properties-undefined": "off",
178+
"no-schema-type-mismatch": "warn",
176179
"no-server-example.com": "warn",
177180
"no-server-trailing-slash": "error",
178181
"no-server-variables-empty-enum": "error",
@@ -285,6 +288,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
285288
"no-invalid-schema-examples": "off",
286289
"no-path-trailing-slash": "error",
287290
"no-required-schema-properties-undefined": "off",
291+
"no-schema-type-mismatch": "warn",
288292
"no-unresolved-refs": "error",
289293
"operation-2xx-response": "error",
290294
"operation-4xx-response": "off",
@@ -340,6 +344,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
340344
"no-invalid-schema-examples": "off",
341345
"no-path-trailing-slash": "error",
342346
"no-required-schema-properties-undefined": "off",
347+
"no-schema-type-mismatch": "warn",
343348
"no-server-example.com": "warn",
344349
"no-server-trailing-slash": "error",
345350
"no-server-variables-empty-enum": "error",
@@ -399,6 +404,7 @@ exports[`resolveStyleguideConfig should resolve extends with local file config w
399404
"no-invalid-schema-examples": "off",
400405
"no-path-trailing-slash": "error",
401406
"no-required-schema-properties-undefined": "off",
407+
"no-schema-type-mismatch": "warn",
402408
"no-server-example.com": "warn",
403409
"no-server-trailing-slash": "error",
404410
"no-server-variables-empty-enum": "error",

packages/core/src/config/all.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
1919
'no-enum-type-mismatch': 'error',
2020
'no-unresolved-refs': 'error',
2121
'no-required-schema-properties-undefined': 'error',
22+
'no-schema-type-mismatch': 'error',
2223
'operation-summary': 'error',
2324
'operation-operationId': 'error',
2425
'operation-operationId-unique': 'error',
@@ -75,6 +76,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
7576
'no-enum-type-mismatch': 'error',
7677
'no-unresolved-refs': 'error',
7778
'no-required-schema-properties-undefined': 'error',
79+
'no-schema-type-mismatch': 'error',
7880
'no-invalid-media-type-examples': 'error',
7981
'no-server-example.com': 'error',
8082
'no-server-trailing-slash': 'error',
@@ -141,6 +143,7 @@ const all: PluginStyleguideConfig<'built-in'> = {
141143
'no-enum-type-mismatch': 'error',
142144
'no-unresolved-refs': 'error',
143145
'no-required-schema-properties-undefined': 'error',
146+
'no-schema-type-mismatch': 'error',
144147
'no-invalid-media-type-examples': 'error',
145148
'no-server-example.com': 'error',
146149
'no-server-trailing-slash': 'error',

packages/core/src/config/minimal.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
1919
'no-enum-type-mismatch': 'warn',
2020
'no-unresolved-refs': 'error',
2121
'no-required-schema-properties-undefined': 'off',
22+
'no-schema-type-mismatch': 'off',
2223
'operation-summary': 'warn',
2324
'operation-operationId': 'warn',
2425
'operation-operationId-unique': 'warn',
@@ -66,6 +67,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
6667
'no-enum-type-mismatch': 'warn',
6768
'no-unresolved-refs': 'error',
6869
'no-required-schema-properties-undefined': 'off',
70+
'no-schema-type-mismatch': 'off',
6971
'no-invalid-media-type-examples': {
7072
severity: 'warn',
7173
allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const minimal: PluginStyleguideConfig<'built-in'> = {
126128
'no-enum-type-mismatch': 'warn',
127129
'no-unresolved-refs': 'error',
128130
'no-required-schema-properties-undefined': 'off',
131+
'no-schema-type-mismatch': 'off',
129132
'no-invalid-media-type-examples': 'warn',
130133
'no-server-example.com': 'warn',
131134
'no-server-trailing-slash': 'error',

packages/core/src/config/recommended-strict.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
1919
'no-enum-type-mismatch': 'error',
2020
'no-unresolved-refs': 'error',
2121
'no-required-schema-properties-undefined': 'off',
22+
'no-schema-type-mismatch': 'error',
2223
'operation-summary': 'error',
2324
'operation-operationId': 'error',
2425
'operation-operationId-unique': 'error',
@@ -66,6 +67,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
6667
'no-enum-type-mismatch': 'error',
6768
'no-unresolved-refs': 'error',
6869
'no-required-schema-properties-undefined': 'off',
70+
'no-schema-type-mismatch': 'error',
6971
'no-invalid-media-type-examples': {
7072
severity: 'error',
7173
allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const recommendedStrict: PluginStyleguideConfig<'built-in'> = {
126128
'no-enum-type-mismatch': 'error',
127129
'no-unresolved-refs': 'error',
128130
'no-required-schema-properties-undefined': 'off',
131+
'no-schema-type-mismatch': 'error',
129132
'no-invalid-media-type-examples': 'error',
130133
'no-server-example.com': 'error',
131134
'no-server-trailing-slash': 'error',

packages/core/src/config/recommended.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
1919
'no-enum-type-mismatch': 'error',
2020
'no-unresolved-refs': 'error',
2121
'no-required-schema-properties-undefined': 'off',
22+
'no-schema-type-mismatch': 'warn',
2223
'operation-summary': 'error',
2324
'operation-description': 'off',
2425
'operation-operationId': 'warn',
@@ -66,6 +67,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
6667
'no-enum-type-mismatch': 'error',
6768
'no-unresolved-refs': 'error',
6869
'no-required-schema-properties-undefined': 'off',
70+
'no-schema-type-mismatch': 'warn',
6971
'no-invalid-media-type-examples': {
7072
severity: 'warn',
7173
allowAdditionalProperties: false,
@@ -126,6 +128,7 @@ const recommended: PluginStyleguideConfig<'built-in'> = {
126128
'no-enum-type-mismatch': 'error',
127129
'no-unresolved-refs': 'error',
128130
'no-required-schema-properties-undefined': 'off',
131+
'no-schema-type-mismatch': 'warn',
129132
'no-invalid-media-type-examples': 'warn',
130133
'no-server-example.com': 'warn',
131134
'no-server-trailing-slash': 'error',
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { outdent } from 'outdent';
2+
import { makeConfig, parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils';
3+
import { lintDocument } from '../../../lint';
4+
import { BaseResolver } from '../../../resolve';
5+
6+
describe('no-schema-type-mismatch rule', () => {
7+
it('should report a warning for object type with items field', async () => {
8+
const yaml = outdent`
9+
openapi: 3.0.0
10+
info:
11+
title: Test API
12+
version: 1.0.0
13+
paths:
14+
/test:
15+
get:
16+
responses:
17+
'200':
18+
description: OK
19+
content:
20+
application/json:
21+
schema:
22+
type: object
23+
items:
24+
type: string
25+
`;
26+
27+
const document = parseYamlToDocument(yaml, 'test.yaml');
28+
const results = await lintDocument({
29+
document,
30+
externalRefResolver: new BaseResolver(),
31+
config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
32+
});
33+
34+
expect(replaceSourceWithRef(results)).toEqual([
35+
{
36+
location: [
37+
{
38+
pointer: '#/paths/~1test/get/responses/200/content/application~1json/schema/items',
39+
reportOnKey: false,
40+
source: 'test.yaml',
41+
},
42+
],
43+
message: "Schema type mismatch: 'object' type should not contain 'items' field.",
44+
ruleId: 'no-schema-type-mismatch',
45+
severity: 'warn',
46+
suggest: [],
47+
},
48+
]);
49+
});
50+
51+
it('should report a warning for array type with properties field', async () => {
52+
const yaml = outdent`
53+
openapi: 3.0.0
54+
info:
55+
title: Test API
56+
version: 1.0.0
57+
paths:
58+
/test:
59+
get:
60+
responses:
61+
'200':
62+
description: OK
63+
content:
64+
application/json:
65+
schema:
66+
type: array
67+
properties:
68+
name:
69+
type: string
70+
`;
71+
72+
const document = parseYamlToDocument(yaml, 'test.yaml');
73+
const results = await lintDocument({
74+
document,
75+
externalRefResolver: new BaseResolver(),
76+
config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
77+
});
78+
79+
expect(replaceSourceWithRef(results)).toEqual([
80+
{
81+
location: [
82+
{
83+
pointer: '#/paths/~1test/get/responses/200/content/application~1json/schema/properties',
84+
reportOnKey: false,
85+
source: 'test.yaml',
86+
},
87+
],
88+
message: "Schema type mismatch: 'array' type should not contain 'properties' field.",
89+
ruleId: 'no-schema-type-mismatch',
90+
severity: 'warn',
91+
suggest: [],
92+
},
93+
]);
94+
});
95+
96+
it('should not report a warning for valid schemas', async () => {
97+
const yaml = outdent`
98+
openapi: 3.0.0
99+
info:
100+
title: Test API
101+
version: 1.0.0
102+
paths:
103+
/test:
104+
get:
105+
responses:
106+
'200':
107+
description: OK
108+
content:
109+
application/json:
110+
schema:
111+
type: object
112+
properties:
113+
name:
114+
type: string
115+
`;
116+
117+
const document = parseYamlToDocument(yaml, 'test.yaml');
118+
const results = await lintDocument({
119+
document,
120+
externalRefResolver: new BaseResolver(),
121+
config: await makeConfig({ rules: { 'no-schema-type-mismatch': 'warn' } }),
122+
});
123+
124+
expect(replaceSourceWithRef(results)).toEqual([]);
125+
});
126+
});

0 commit comments

Comments
 (0)