Skip to content

Commit

Permalink
fix(rulesets): example validation for required readOnly and writeOnly…
Browse files Browse the repository at this point in the history
… properties (#2573)

Required readOnly and writeOnly properties should not be considered required
for respectively request and response bodies.
  • Loading branch information
pplr committed May 3, 2024
1 parent 8df2c36 commit ae1fea5
Show file tree
Hide file tree
Showing 3 changed files with 294 additions and 0 deletions.
Expand Up @@ -45,4 +45,70 @@ testRule('oas2-valid-media-example', [
},
],
},

{
name: 'Ignore required writeOnly parameters on responses',
document: {
swagger: '2.0',
paths: {
'/': {
post: {
responses: {
'200': {
schema: {
required: ['ro', 'wo'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
examples: {
'application/json': {
other: 'foobar',
ro: 'some',
},
},
},
},
},
},
},
responses: {
foo: {
schema: {
required: ['ro', 'wo', 'other'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
examples: {
'application/json': {
other: 'foo',
ro: 'some',
},
},
},
},
},
errors: [],
},
]);
152 changes: 152 additions & 0 deletions packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts
Expand Up @@ -312,6 +312,158 @@ testRule('oas3-valid-media-example', [
errors: [],
},

{
name: 'Ignore required readOnly parameters on requests',
document: {
openapi: '3.0.0',
paths: {
'/': {
post: {
requestBody: {
content: {
'application/json': {
schema: {
required: ['ro', 'wo'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
example: {
other: 'foobar',
wo: 'some',
},
},
},
},
},
},
},
components: {
requestBodies: {
foo: {
content: {
'application/json': {
schema: {
required: ['ro', 'wo', 'other'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
examples: {
valid: {
summary: 'should be valid',
value: {
other: 'foo',
wo: 'some',
},
},
},
},
},
},
},
},
},
errors: [],
},

{
name: 'Ignore required writeOnly parameters on responses',
document: {
openapi: '3.0.0',
paths: {
'/': {
post: {
responses: {
'200': {
content: {
'application/json': {
schema: {
required: ['ro', 'wo'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
example: {
other: 'foobar',
ro: 'some',
},
},
},
},
},
},
},
},
components: {
responses: {
foo: {
content: {
'application/json': {
schema: {
required: ['ro', 'wo', 'other'],
properties: {
ro: {
type: 'string',
readOnly: true,
},
wo: {
type: 'string',
writeOnly: true,
},
other: {
type: 'string',
},
},
},
examples: {
valid: {
summary: 'should be valid',
value: {
other: 'foo',
ro: 'some',
},
},
},
},
},
},
},
},
},
errors: [],
},

{
name: 'parameters: will fail when complex example is used',
document: {
Expand Down
76 changes: 76 additions & 0 deletions packages/rulesets/src/oas/functions/oasExample.ts
Expand Up @@ -11,6 +11,10 @@ export type Options = {
type: 'media' | 'schema';
};

type HasRequiredProperties = traverse.SchemaObject & {
required?: string[];
};

type MediaValidationItem = {
field: string;
multiple: boolean;
Expand Down Expand Up @@ -39,6 +43,22 @@ const MEDIA_VALIDATION_ITEMS: Dictionary<MediaValidationItem[], 2 | 3> = {
],
};

const REQUEST_MEDIA_PATHS: Dictionary<JsonPath[], 2 | 3> = {
2: [],
3: [
['components', 'requestBodies'],
['paths', '*', '*', 'requestBody'],
],
};

const RESPONSE_MEDIA_PATHS: Dictionary<JsonPath[], 2 | 3> = {
2: [['responses'], ['paths', '*', '*', 'responses']],
3: [
['components', 'responses'],
['paths', '*', '*', 'responses'],
],
};

const SCHEMA_VALIDATION_ITEMS: Dictionary<string[], 2 | 3> = {
2: ['example', 'x-example', 'default'],
3: ['example', 'default'],
Expand All @@ -49,6 +69,22 @@ type ValidationItem = {
path: JsonPath;
};

function hasRequiredProperties(schema: traverse.SchemaObject): schema is HasRequiredProperties {
return schema.required === undefined || Array.isArray(schema.required);
}

function isSubpath(path: JsonPath, subPaths: JsonPath[]): boolean {
return subPaths.some(subPath => subPath.every((segment, idx) => segment === '*' || segment === path[idx]));
}

function isMediaRequest(path: JsonPath, oasVersion: 2 | 3): boolean {
return isSubpath(path, REQUEST_MEDIA_PATHS[oasVersion]);
}

function isMediaResponse(path: JsonPath, oasVersion: 2 | 3): boolean {
return isSubpath(path, RESPONSE_MEDIA_PATHS[oasVersion]);
}

function* getMediaValidationItems(
items: MediaValidationItem[],
targetVal: Dictionary<unknown>,
Expand Down Expand Up @@ -146,6 +182,41 @@ function cleanSchema(schema: Record<string, unknown>): void {
}));
}

/**
* Modifies 'schema' (and all its sub-schemas) to make all
* readOnly or writeOnly properties optional.
* In this context, "sub-schemas" refers to all schemas reachable from 'schema'
* (e.g. properties, additionalProperties, allOf/anyOf/oneOf, not, items, etc.)
* @param schema the schema to be modified
* @param readOnlyProperties make readOnly properties optional
* @param writeOnlyProperties make writeOnly properties optional
*/
function relaxRequired(
schema: Record<string, unknown>,
readOnlyProperties: boolean,
writeOnlyProperties: boolean,
): void {
if (readOnlyProperties || writeOnlyProperties)
traverse(schema, {}, <traverse.Callback>((
fragment,
jsonPtr,
rootSchema,
parentJsonPtr,
parentKeyword,
parent,
propertyName,
) => {
if ((fragment.readOnly === true && readOnlyProperties) || (fragment.writeOnly === true && writeOnlyProperties)) {
if (parentKeyword == 'properties' && parent && hasRequiredProperties(parent)) {
parent.required = parent.required?.filter(p => p !== propertyName);
if (parent.required?.length === 0) {
delete parent.required;
}
}
}
}));
}

export default createRulesetFunction<Record<string, unknown>, Options>(
{
input: {
Expand Down Expand Up @@ -190,6 +261,11 @@ export default createRulesetFunction<Record<string, unknown>, Options>(
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
schemaOpts.schema = JSON.parse(JSON.stringify(schemaOpts.schema));
cleanSchema(schemaOpts.schema);
relaxRequired(
schemaOpts.schema,
opts.type === 'media' && isMediaRequest(context.path, opts.oasVersion),
opts.type === 'media' && isMediaResponse(context.path, opts.oasVersion),
);

for (const validationItem of validationItems) {
const result = oasSchema(validationItem.value, schemaOpts, {
Expand Down

0 comments on commit ae1fea5

Please sign in to comment.