Skip to content

Commit cea46a5

Browse files
committed
add discriminator-default-mapping rule
1 parent 31d7615 commit cea46a5

File tree

12 files changed

+225
-2
lines changed

12 files changed

+225
-2
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,7 @@ exports[`resolveConfig > should ignore minimal from the root and read local file
228228
"array-parameter-serialization": "off",
229229
"boolean-parameter-prefixes": "error",
230230
"component-name-unique": "off",
231+
"discriminator-default-mapping": "error",
231232
"info-contact": "off",
232233
"info-license": "warn",
233234
"info-license-strict": "warn",
@@ -594,6 +595,7 @@ exports[`resolveConfig > should resolve extends with local file config which con
594595
"array-parameter-serialization": "off",
595596
"boolean-parameter-prefixes": "error",
596597
"component-name-unique": "off",
598+
"discriminator-default-mapping": "error",
597599
"info-contact": "off",
598600
"info-license": "warn",
599601
"info-license-strict": "warn",

packages/core/src/config/__tests__/load.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ describe('loadConfig', () => {
358358
"severity": "off",
359359
},
360360
"component-name-unique": "off",
361+
"discriminator-default-mapping": "warn",
361362
"info-contact": "off",
362363
"info-license": "off",
363364
"info-license-strict": "off",
@@ -653,6 +654,7 @@ describe('loadConfig', () => {
653654
"array-parameter-serialization": "off",
654655
"boolean-parameter-prefixes": "off",
655656
"component-name-unique": "off",
657+
"discriminator-default-mapping": "error",
656658
"info-contact": "off",
657659
"info-license": "warn",
658660
"info-license-strict": "warn",
@@ -961,6 +963,7 @@ describe('loadConfig', () => {
961963
"array-parameter-serialization": "off",
962964
"boolean-parameter-prefixes": "off",
963965
"component-name-unique": "off",
966+
"discriminator-default-mapping": "warn",
964967
"info-contact": "off",
965968
"info-license": "off",
966969
"info-license-strict": "off",

packages/core/src/config/all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ const all: RawGovernanceConfig<'built-in'> = {
242242
'tags-alphabetical': 'error',
243243
'no-duplicated-tag-names': 'error',
244244
'no-invalid-encoding-combinations': 'error',
245+
'discriminator-default-mapping': 'error',
245246
},
246247
async2Rules: {
247248
'channels-kebab-case': 'error',

packages/core/src/config/minimal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ const minimal: RawGovernanceConfig<'built-in'> = {
221221
'tags-alphabetical': 'off',
222222
'no-duplicated-tag-names': 'off',
223223
'no-invalid-encoding-combinations': 'warn',
224+
'discriminator-default-mapping': 'warn',
224225
},
225226
async2Rules: {
226227
'channels-kebab-case': 'off',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ const recommendedStrict: RawGovernanceConfig<'built-in'> = {
221221
'tags-alphabetical': 'off',
222222
'no-duplicated-tag-names': 'error',
223223
'no-invalid-encoding-combinations': 'error',
224+
'discriminator-default-mapping': 'error',
224225
},
225226
async2Rules: {
226227
'channels-kebab-case': 'off',

packages/core/src/config/recommended.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ const recommended: RawGovernanceConfig<'built-in'> = {
221221
'tags-alphabetical': 'off',
222222
'no-duplicated-tag-names': 'warn',
223223
'no-invalid-encoding-combinations': 'error',
224+
'discriminator-default-mapping': 'error',
224225
},
225226
async2Rules: {
226227
'channels-kebab-case': 'off',

packages/core/src/config/spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ const spec: RawGovernanceConfig<'built-in'> = {
221221
'tag-description': 'off',
222222
'tags-alphabetical': 'off',
223223
'no-invalid-encoding-combinations': 'error',
224+
'discriminator-default-mapping': 'error',
224225
},
225226
async2Rules: {
226227
'channels-kebab-case': 'off',

packages/core/src/config/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,18 @@ export type RawGovernanceConfig<T extends 'built-in' | undefined = undefined> =
6060
rules?: RuleMap<BuiltInCommonRuleId, RuleConfig, T>;
6161
oas2Rules?: RuleMap<BuiltInOAS2RuleId, RuleConfig, T>;
6262
oas3_0Rules?: RuleMap<
63-
Exclude<BuiltInOAS3RuleId, 'no-invalid-encoding-combinations'>,
63+
Exclude<
64+
BuiltInOAS3RuleId,
65+
'no-invalid-encoding-combinations' | 'discriminator-default-mapping'
66+
>,
6467
RuleConfig,
6568
T
6669
>;
6770
oas3_1Rules?: RuleMap<
68-
Exclude<BuiltInOAS3RuleId, 'nullable-type-sibling' | 'no-invalid-encoding-combinations'>,
71+
Exclude<
72+
BuiltInOAS3RuleId,
73+
'nullable-type-sibling' | 'no-invalid-encoding-combinations' | 'discriminator-default-mapping'
74+
>,
6975
RuleConfig,
7076
T
7177
>;
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { outdent } from 'outdent';
2+
import { lintDocument } from '../../../lint.js';
3+
import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js';
4+
import { BaseResolver } from '../../../resolve.js';
5+
import { createConfig } from '../../../config/index.js';
6+
7+
describe('discriminator-default-mapping', () => {
8+
it('should pass when optional propertyName has defaultMapping', async () => {
9+
const document = parseYamlToDocument(
10+
outdent`
11+
openapi: 3.2.0
12+
components:
13+
schemas:
14+
Base:
15+
type: object
16+
discriminator:
17+
propertyName: test
18+
defaultMapping: DefaultType
19+
properties:
20+
test:
21+
type: string
22+
TypeA:
23+
allOf:
24+
- $ref: '#/components/schemas/Base'
25+
DefaultType:
26+
allOf:
27+
- $ref: '#/components/schemas/Base'
28+
`,
29+
'foobar.yaml'
30+
);
31+
32+
const results = await lintDocument({
33+
externalRefResolver: new BaseResolver(),
34+
document,
35+
config: await createConfig({ rules: { 'discriminator-default-mapping': 'error' } }),
36+
});
37+
38+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
39+
});
40+
41+
it('should pass when required propertyName does not need defaultMapping', async () => {
42+
const document = parseYamlToDocument(
43+
outdent`
44+
openapi: 3.2.0
45+
components:
46+
schemas:
47+
Base:
48+
type: object
49+
discriminator:
50+
propertyName: test
51+
mapping:
52+
a: TypeA
53+
required:
54+
- test
55+
properties:
56+
test:
57+
type: string
58+
TypeA:
59+
allOf:
60+
- $ref: '#/components/schemas/Base'
61+
`,
62+
'foobar.yaml'
63+
);
64+
65+
const results = await lintDocument({
66+
externalRefResolver: new BaseResolver(),
67+
document,
68+
config: await createConfig({ rules: { 'discriminator-default-mapping': 'error' } }),
69+
});
70+
71+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`[]`);
72+
});
73+
74+
it('should fail when optional propertyName lacks defaultMapping', async () => {
75+
const document = parseYamlToDocument(
76+
outdent`
77+
openapi: 3.2.0
78+
components:
79+
schemas:
80+
Base:
81+
type: object
82+
discriminator:
83+
propertyName: test
84+
mapping:
85+
a: TypeA
86+
properties:
87+
test:
88+
type: string
89+
TypeA:
90+
allOf:
91+
- $ref: '#/components/schemas/Base'
92+
`,
93+
'foobar.yaml'
94+
);
95+
96+
const results = await lintDocument({
97+
externalRefResolver: new BaseResolver(),
98+
document,
99+
config: await createConfig({ rules: { 'discriminator-default-mapping': 'error' } }),
100+
});
101+
102+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
103+
[
104+
{
105+
"location": [
106+
{
107+
"pointer": "#/components/schemas/Base/discriminator",
108+
"reportOnKey": false,
109+
"source": "foobar.yaml",
110+
},
111+
],
112+
"message": "Discriminator with optional property 'test' must include a defaultMapping field.",
113+
"ruleId": "discriminator-default-mapping",
114+
"severity": "error",
115+
"suggest": [],
116+
},
117+
]
118+
`);
119+
});
120+
121+
it('should fail when defaultMapping points to a non-existent component', async () => {
122+
const document = parseYamlToDocument(
123+
outdent`
124+
openapi: 3.2.0
125+
components:
126+
schemas:
127+
Base:
128+
type: object
129+
discriminator:
130+
propertyName: test
131+
defaultMapping: TypeB
132+
mapping:
133+
a: TypeA
134+
properties:
135+
test:
136+
type: string
137+
TypeA:
138+
allOf:
139+
- $ref: '#/components/schemas/Base'
140+
`,
141+
'foobar.yaml'
142+
);
143+
144+
const results = await lintDocument({
145+
externalRefResolver: new BaseResolver(),
146+
document,
147+
config: await createConfig({ rules: { 'discriminator-default-mapping': 'error' } }),
148+
});
149+
150+
expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`
151+
[
152+
{
153+
"location": [
154+
{
155+
"pointer": "#/components/schemas/Base/discriminator/defaultMapping",
156+
"reportOnKey": false,
157+
"source": "foobar.yaml",
158+
},
159+
],
160+
"message": "defaultMapping value 'TypeB' does not point to an existing schema component.",
161+
"ruleId": "discriminator-default-mapping",
162+
"severity": "error",
163+
"suggest": [],
164+
},
165+
]
166+
`);
167+
});
168+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { type Oas3Rule } from '../../visitors.js';
2+
import { type UserContext } from '../../walk.js';
3+
4+
export const DiscriminatorDefaultMapping: Oas3Rule = () => {
5+
let componentsSchemaNames: string[];
6+
7+
return {
8+
NamedSchemas: {
9+
enter(schemas) {
10+
componentsSchemaNames = Object.keys(schemas);
11+
},
12+
},
13+
Schema: {
14+
leave(schema, ctx: UserContext) {
15+
if (!schema.discriminator?.propertyName) return;
16+
17+
const defaultMapping = schema.discriminator.defaultMapping;
18+
const isPropertyOptional = !schema.required?.includes(schema.discriminator.propertyName);
19+
if (isPropertyOptional && defaultMapping === undefined) {
20+
ctx.report({
21+
message: `Discriminator with optional property '${schema.discriminator.propertyName}' must include a defaultMapping field.`,
22+
location: ctx.location.child('discriminator'),
23+
});
24+
return;
25+
}
26+
27+
if (defaultMapping !== undefined && !componentsSchemaNames.includes(defaultMapping)) {
28+
ctx.report({
29+
message: `defaultMapping value '${defaultMapping}' does not point to an existing schema component.`,
30+
location: ctx.location.child(['discriminator', 'defaultMapping']),
31+
});
32+
}
33+
},
34+
},
35+
};
36+
};

0 commit comments

Comments
 (0)