Skip to content

Commit ae1fea5

Browse files
authored
fix(rulesets): example validation for required readOnly and writeOnly properties (#2573)
Required readOnly and writeOnly properties should not be considered required for respectively request and response bodies.
1 parent 8df2c36 commit ae1fea5

File tree

3 files changed

+294
-0
lines changed

3 files changed

+294
-0
lines changed

packages/rulesets/src/oas/__tests__/oas2-valid-media-example.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,70 @@ testRule('oas2-valid-media-example', [
4545
},
4646
],
4747
},
48+
49+
{
50+
name: 'Ignore required writeOnly parameters on responses',
51+
document: {
52+
swagger: '2.0',
53+
paths: {
54+
'/': {
55+
post: {
56+
responses: {
57+
'200': {
58+
schema: {
59+
required: ['ro', 'wo'],
60+
properties: {
61+
ro: {
62+
type: 'string',
63+
readOnly: true,
64+
},
65+
wo: {
66+
type: 'string',
67+
writeOnly: true,
68+
},
69+
other: {
70+
type: 'string',
71+
},
72+
},
73+
},
74+
examples: {
75+
'application/json': {
76+
other: 'foobar',
77+
ro: 'some',
78+
},
79+
},
80+
},
81+
},
82+
},
83+
},
84+
},
85+
responses: {
86+
foo: {
87+
schema: {
88+
required: ['ro', 'wo', 'other'],
89+
properties: {
90+
ro: {
91+
type: 'string',
92+
readOnly: true,
93+
},
94+
wo: {
95+
type: 'string',
96+
writeOnly: true,
97+
},
98+
other: {
99+
type: 'string',
100+
},
101+
},
102+
},
103+
examples: {
104+
'application/json': {
105+
other: 'foo',
106+
ro: 'some',
107+
},
108+
},
109+
},
110+
},
111+
},
112+
errors: [],
113+
},
48114
]);

packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,158 @@ testRule('oas3-valid-media-example', [
312312
errors: [],
313313
},
314314

315+
{
316+
name: 'Ignore required readOnly parameters on requests',
317+
document: {
318+
openapi: '3.0.0',
319+
paths: {
320+
'/': {
321+
post: {
322+
requestBody: {
323+
content: {
324+
'application/json': {
325+
schema: {
326+
required: ['ro', 'wo'],
327+
properties: {
328+
ro: {
329+
type: 'string',
330+
readOnly: true,
331+
},
332+
wo: {
333+
type: 'string',
334+
writeOnly: true,
335+
},
336+
other: {
337+
type: 'string',
338+
},
339+
},
340+
},
341+
example: {
342+
other: 'foobar',
343+
wo: 'some',
344+
},
345+
},
346+
},
347+
},
348+
},
349+
},
350+
},
351+
components: {
352+
requestBodies: {
353+
foo: {
354+
content: {
355+
'application/json': {
356+
schema: {
357+
required: ['ro', 'wo', 'other'],
358+
properties: {
359+
ro: {
360+
type: 'string',
361+
readOnly: true,
362+
},
363+
wo: {
364+
type: 'string',
365+
writeOnly: true,
366+
},
367+
other: {
368+
type: 'string',
369+
},
370+
},
371+
},
372+
examples: {
373+
valid: {
374+
summary: 'should be valid',
375+
value: {
376+
other: 'foo',
377+
wo: 'some',
378+
},
379+
},
380+
},
381+
},
382+
},
383+
},
384+
},
385+
},
386+
},
387+
errors: [],
388+
},
389+
390+
{
391+
name: 'Ignore required writeOnly parameters on responses',
392+
document: {
393+
openapi: '3.0.0',
394+
paths: {
395+
'/': {
396+
post: {
397+
responses: {
398+
'200': {
399+
content: {
400+
'application/json': {
401+
schema: {
402+
required: ['ro', 'wo'],
403+
properties: {
404+
ro: {
405+
type: 'string',
406+
readOnly: true,
407+
},
408+
wo: {
409+
type: 'string',
410+
writeOnly: true,
411+
},
412+
other: {
413+
type: 'string',
414+
},
415+
},
416+
},
417+
example: {
418+
other: 'foobar',
419+
ro: 'some',
420+
},
421+
},
422+
},
423+
},
424+
},
425+
},
426+
},
427+
},
428+
components: {
429+
responses: {
430+
foo: {
431+
content: {
432+
'application/json': {
433+
schema: {
434+
required: ['ro', 'wo', 'other'],
435+
properties: {
436+
ro: {
437+
type: 'string',
438+
readOnly: true,
439+
},
440+
wo: {
441+
type: 'string',
442+
writeOnly: true,
443+
},
444+
other: {
445+
type: 'string',
446+
},
447+
},
448+
},
449+
examples: {
450+
valid: {
451+
summary: 'should be valid',
452+
value: {
453+
other: 'foo',
454+
ro: 'some',
455+
},
456+
},
457+
},
458+
},
459+
},
460+
},
461+
},
462+
},
463+
},
464+
errors: [],
465+
},
466+
315467
{
316468
name: 'parameters: will fail when complex example is used',
317469
document: {

packages/rulesets/src/oas/functions/oasExample.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export type Options = {
1111
type: 'media' | 'schema';
1212
};
1313

14+
type HasRequiredProperties = traverse.SchemaObject & {
15+
required?: string[];
16+
};
17+
1418
type MediaValidationItem = {
1519
field: string;
1620
multiple: boolean;
@@ -39,6 +43,22 @@ const MEDIA_VALIDATION_ITEMS: Dictionary<MediaValidationItem[], 2 | 3> = {
3943
],
4044
};
4145

46+
const REQUEST_MEDIA_PATHS: Dictionary<JsonPath[], 2 | 3> = {
47+
2: [],
48+
3: [
49+
['components', 'requestBodies'],
50+
['paths', '*', '*', 'requestBody'],
51+
],
52+
};
53+
54+
const RESPONSE_MEDIA_PATHS: Dictionary<JsonPath[], 2 | 3> = {
55+
2: [['responses'], ['paths', '*', '*', 'responses']],
56+
3: [
57+
['components', 'responses'],
58+
['paths', '*', '*', 'responses'],
59+
],
60+
};
61+
4262
const SCHEMA_VALIDATION_ITEMS: Dictionary<string[], 2 | 3> = {
4363
2: ['example', 'x-example', 'default'],
4464
3: ['example', 'default'],
@@ -49,6 +69,22 @@ type ValidationItem = {
4969
path: JsonPath;
5070
};
5171

72+
function hasRequiredProperties(schema: traverse.SchemaObject): schema is HasRequiredProperties {
73+
return schema.required === undefined || Array.isArray(schema.required);
74+
}
75+
76+
function isSubpath(path: JsonPath, subPaths: JsonPath[]): boolean {
77+
return subPaths.some(subPath => subPath.every((segment, idx) => segment === '*' || segment === path[idx]));
78+
}
79+
80+
function isMediaRequest(path: JsonPath, oasVersion: 2 | 3): boolean {
81+
return isSubpath(path, REQUEST_MEDIA_PATHS[oasVersion]);
82+
}
83+
84+
function isMediaResponse(path: JsonPath, oasVersion: 2 | 3): boolean {
85+
return isSubpath(path, RESPONSE_MEDIA_PATHS[oasVersion]);
86+
}
87+
5288
function* getMediaValidationItems(
5389
items: MediaValidationItem[],
5490
targetVal: Dictionary<unknown>,
@@ -146,6 +182,41 @@ function cleanSchema(schema: Record<string, unknown>): void {
146182
}));
147183
}
148184

185+
/**
186+
* Modifies 'schema' (and all its sub-schemas) to make all
187+
* readOnly or writeOnly properties optional.
188+
* In this context, "sub-schemas" refers to all schemas reachable from 'schema'
189+
* (e.g. properties, additionalProperties, allOf/anyOf/oneOf, not, items, etc.)
190+
* @param schema the schema to be modified
191+
* @param readOnlyProperties make readOnly properties optional
192+
* @param writeOnlyProperties make writeOnly properties optional
193+
*/
194+
function relaxRequired(
195+
schema: Record<string, unknown>,
196+
readOnlyProperties: boolean,
197+
writeOnlyProperties: boolean,
198+
): void {
199+
if (readOnlyProperties || writeOnlyProperties)
200+
traverse(schema, {}, <traverse.Callback>((
201+
fragment,
202+
jsonPtr,
203+
rootSchema,
204+
parentJsonPtr,
205+
parentKeyword,
206+
parent,
207+
propertyName,
208+
) => {
209+
if ((fragment.readOnly === true && readOnlyProperties) || (fragment.writeOnly === true && writeOnlyProperties)) {
210+
if (parentKeyword == 'properties' && parent && hasRequiredProperties(parent)) {
211+
parent.required = parent.required?.filter(p => p !== propertyName);
212+
if (parent.required?.length === 0) {
213+
delete parent.required;
214+
}
215+
}
216+
}
217+
}));
218+
}
219+
149220
export default createRulesetFunction<Record<string, unknown>, Options>(
150221
{
151222
input: {
@@ -190,6 +261,11 @@ export default createRulesetFunction<Record<string, unknown>, Options>(
190261
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
191262
schemaOpts.schema = JSON.parse(JSON.stringify(schemaOpts.schema));
192263
cleanSchema(schemaOpts.schema);
264+
relaxRequired(
265+
schemaOpts.schema,
266+
opts.type === 'media' && isMediaRequest(context.path, opts.oasVersion),
267+
opts.type === 'media' && isMediaResponse(context.path, opts.oasVersion),
268+
);
193269

194270
for (const validationItem of validationItems) {
195271
const result = oasSchema(validationItem.value, schemaOpts, {

0 commit comments

Comments
 (0)