From 2352e390b0ff65e2c8b1409ce99ff6461265f82a Mon Sep 17 00:00:00 2001 From: Pierre Paysant-Le Roux Date: Wed, 10 Jan 2024 17:31:17 +0100 Subject: [PATCH] fix(rulesets): example validation for required readOnly and writeOnly properties Required readOnly and writeOnly properties should not be considered required for respectively request and response bodies. --- .../oas2-valid-media-example.test.ts | 66 ++++++++ .../oas3-valid-media-example.test.ts | 152 ++++++++++++++++++ .../rulesets/src/oas/functions/oasExample.ts | 76 +++++++++ 3 files changed, 294 insertions(+) diff --git a/packages/rulesets/src/oas/__tests__/oas2-valid-media-example.test.ts b/packages/rulesets/src/oas/__tests__/oas2-valid-media-example.test.ts index 0f2954f88..d38a0c707 100644 --- a/packages/rulesets/src/oas/__tests__/oas2-valid-media-example.test.ts +++ b/packages/rulesets/src/oas/__tests__/oas2-valid-media-example.test.ts @@ -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: [], + }, ]); diff --git a/packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts b/packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts index 4308e983a..165219733 100644 --- a/packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts +++ b/packages/rulesets/src/oas/__tests__/oas3-valid-media-example.test.ts @@ -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: { diff --git a/packages/rulesets/src/oas/functions/oasExample.ts b/packages/rulesets/src/oas/functions/oasExample.ts index 9997b744e..c51144e83 100644 --- a/packages/rulesets/src/oas/functions/oasExample.ts +++ b/packages/rulesets/src/oas/functions/oasExample.ts @@ -11,6 +11,10 @@ export type Options = { type: 'media' | 'schema'; }; +type HasRequiredProperties = traverse.SchemaObject & { + required?: string[]; +}; + type MediaValidationItem = { field: string; multiple: boolean; @@ -39,6 +43,22 @@ const MEDIA_VALIDATION_ITEMS: Dictionary = { ], }; +const REQUEST_MEDIA_PATHS: Dictionary = { + 2: [], + 3: [ + ['components', 'requestBodies'], + ['paths', '*', '*', 'requestBody'], + ], +}; + +const RESPONSE_MEDIA_PATHS: Dictionary = { + 2: [['responses'], ['paths', '*', '*', 'responses']], + 3: [ + ['components', 'responses'], + ['paths', '*', '*', 'responses'], + ], +}; + const SCHEMA_VALIDATION_ITEMS: Dictionary = { 2: ['example', 'x-example', 'default'], 3: ['example', 'default'], @@ -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, @@ -146,6 +182,41 @@ function cleanSchema(schema: Record): 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, + readOnlyProperties: boolean, + writeOnlyProperties: boolean, +): void { + if (readOnlyProperties || writeOnlyProperties) + traverse(schema, {}, (( + 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, Options>( { input: { @@ -190,6 +261,11 @@ export default createRulesetFunction, 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, {