From 103b9cf232233b5225b4b511b927f4243f6f2c91 Mon Sep 17 00:00:00 2001 From: Nicholas Lim <18374483+niclim@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:00:30 -0400 Subject: [PATCH] Surface reasons for breaking change in polymorphic types (#2466) --- package.json | 2 +- projects/fastify-capture/package.json | 2 +- projects/json-pointer-helpers/package.json | 2 +- projects/openapi-io/package.json | 2 +- projects/openapi-utilities/package.json | 2 +- projects/optic/package.json | 2 +- .../__snapshots__/diff.test.ts.snap | 113 ++--- .../diff/petstore/petstore-base.json | 7 + .../diff/petstore/petstore-updated.json | 4 + projects/rulesets-base/package.json | 2 +- projects/standard-rulesets/package.json | 2 +- .../breaking-changes.test.ts.snap | 409 +++++++++++++++++- .../__tests__/breaking-changes.test.ts | 144 ++++++ .../helpers/__tests__/unions.test.ts | 267 ++++++++---- .../breaking-changes/helpers/type-change.ts | 64 ++- .../src/breaking-changes/helpers/unions.ts | 330 +++++++++++--- .../src/breaking-changes/preventEnumBreak.ts | 24 +- .../preventRequestExpandingWithUnionTypes.ts | 39 +- .../preventRequestPropertyRequired.ts | 5 +- .../preventRequestPropertyTypeChange.ts | 8 +- .../preventResponseNarrowingWithUnionType.ts | 39 +- .../preventResponsePropertyOptional.ts | 4 +- .../preventResponsePropertyRemoval.ts | 3 +- .../preventResponsePropertyTypeChange.ts | 8 +- 24 files changed, 1218 insertions(+), 266 deletions(-) diff --git a/package.json b/package.json index 0e3b37a2a6..ab3e595281 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openapi-workspaces", "license": "MIT", "private": true, - "version": "0.50.15", + "version": "0.50.16", "workspaces": [ "projects/json-pointer-helpers", "projects/openapi-io", diff --git a/projects/fastify-capture/package.json b/projects/fastify-capture/package.json index 624861dfb9..440180e219 100644 --- a/projects/fastify-capture/package.json +++ b/projects/fastify-capture/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/fastify-capture", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/json-pointer-helpers/package.json b/projects/json-pointer-helpers/package.json index 5d73fab818..734121dc86 100644 --- a/projects/json-pointer-helpers/package.json +++ b/projects/json-pointer-helpers/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/json-pointer-helpers", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/openapi-io/package.json b/projects/openapi-io/package.json index 4b8b6ac2ef..41d7e956a6 100644 --- a/projects/openapi-io/package.json +++ b/projects/openapi-io/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/openapi-io", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/openapi-utilities/package.json b/projects/openapi-utilities/package.json index 1788417221..865498fc1d 100644 --- a/projects/openapi-utilities/package.json +++ b/projects/openapi-utilities/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/openapi-utilities", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/optic/package.json b/projects/optic/package.json index 6702501f03..fccf1f959f 100644 --- a/projects/optic/package.json +++ b/projects/optic/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/optic", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/optic/src/__tests__/integration/__snapshots__/diff.test.ts.snap b/projects/optic/src/__tests__/integration/__snapshots__/diff.test.ts.snap index ad158a66ab..ee635883d4 100644 --- a/projects/optic/src/__tests__/integration/__snapshots__/diff.test.ts.snap +++ b/projects/optic/src/__tests__/integration/__snapshots__/diff.test.ts.snap @@ -9,7 +9,7 @@ exports[`diff file doesn't exist 1`] = ` exports[`diff petstore diff 1`] = ` "x Swagger Petstore Updated petstore-base.json Operations: 5 operations added, 16 changed, 1 removed -x  Checks: 185/240 passed +x  Checks: 191/247 passed specification details: - /servers/2 added @@ -18,7 +18,7 @@ specification details: x GET /pet/findByStatus: added x [operation path component naming check] findByStatus is not snake_case - at petstore-updated.json:328:11223 + at petstore-updated.json:332:11417 - query parameter status: - cookie parameter debug: @@ -27,18 +27,18 @@ specification details: - property /schema/items/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:379:13073 + at petstore-updated.json:383:13267 - body application/json: - property /schema/items/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:423:14934 + at petstore-updated.json:427:15128 x GET /pet/findByTags: added x [operation path component naming check] findByTags is not snake_case - at petstore-updated.json:458:16248 + at petstore-updated.json:462:16442 - query parameter tags: - response 200: @@ -46,13 +46,13 @@ specification details: - property /schema/items/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:495:17706 + at petstore-updated.json:499:17900 - body application/json: - property /schema/items/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:539:19567 + at petstore-updated.json:543:19761 x GET /pet/{petId}: added @@ -61,13 +61,13 @@ specification details: - property /schema/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:608:22115 + at petstore-updated.json:612:22309 - body application/json: - property /schema/properties/photoUrls: x [response property naming check] photoUrls is not snake_case - at petstore-updated.json:649:23818 + at petstore-updated.json:653:24012 ✔ POST /pet/{petId}: added @@ -82,16 +82,16 @@ specification details: - property /schema/properties/userStatus: changed x [prevent request property type changes] expected request body property 'userStatus' not to be narrowed. This is a breaking change. - at petstore-updated.json:1359:48872 + at petstore-updated.json:1363:49066 x [request and response property enums] cannot add enum or const to request property userStatus. This is a breaking change. - at petstore-updated.json:1359:48872 + at petstore-updated.json:1363:49066 - property /schema/properties/lastName: changed - property /schema/properties/username: changed x [prevent changing request property to required] cannot make a request property required. This is a breaking change. - at petstore-updated.json:1354:48614 + at petstore-updated.json:1358:48808 x GET /user/{username}: - response 200: @@ -99,36 +99,36 @@ specification details: - property /schema/properties/phone: removed x [prevent removing response property] cannot remove response property 'phone'. This is a breaking change. - at petstore-base.json:851:29992 + at petstore-base.json:858:30351 - property /schema/properties/bio: added - property /schema/properties/userStatus: changed x [prevent response property type changes] expected response body property 'userStatus' not to be expanded. This is a breaking change. - at petstore-updated.json:1301:46861 + at petstore-updated.json:1305:47055 - property /schema/properties/lastName: changed x [prevent making response property optional] cannot make required response property 'lastName' optional. This is a breaking change. - at petstore-updated.json:1298:46702 + at petstore-updated.json:1302:46896 - property /schema/properties/username: changed - body application/xml: - property /schema/properties/phone: removed x [prevent removing response property] cannot remove response property 'phone'. This is a breaking change. - at petstore-base.json:830:29107 + at petstore-base.json:837:29466 - property /schema/properties/bio: added - property /schema/properties/userStatus: changed x [prevent response property type changes] expected response body property 'userStatus' not to be expanded. This is a breaking change. - at petstore-updated.json:1280:45942 + at petstore-updated.json:1284:46136 - property /schema/properties/lastName: changed x [prevent making response property optional] cannot make required response property 'lastName' optional. This is a breaking change. - at petstore-updated.json:1277:45783 + at petstore-updated.json:1281:45977 - property /schema/properties/username: changed - response 404: @@ -140,7 +140,7 @@ specification details: - response header X-Rate-Limit: added x [header parameter naming check] X-Rate-Limit is not snake_case - at petstore-updated.json:1220:43835 + at petstore-updated.json:1224:44029 - response header X-Expires-After: - /schema/type changed @@ -156,16 +156,16 @@ specification details: - property /schema/items/properties/userStatus: changed x [prevent request property type changes] expected request body property 'userStatus' not to be narrowed. This is a breaking change. - at petstore-updated.json:1175:42495 + at petstore-updated.json:1179:42689 x [request and response property enums] cannot add enum or const to request property userStatus. This is a breaking change. - at petstore-updated.json:1175:42495 + at petstore-updated.json:1179:42689 - property : changed - property /schema/items/properties/username: x [prevent changing request property to required] cannot make a request property required. This is a breaking change. - at petstore-updated.json:1170:42227 + at petstore-updated.json:1174:42421 x POST /user/createWithArray: - request: @@ -175,16 +175,16 @@ specification details: - property /schema/items/properties/userStatus: changed x [prevent request property type changes] expected request body property 'userStatus' not to be narrowed. This is a breaking change. - at petstore-updated.json:1134:41010 + at petstore-updated.json:1138:41204 x [request and response property enums] cannot add enum or const to request property userStatus. This is a breaking change. - at petstore-updated.json:1134:41010 + at petstore-updated.json:1138:41204 - property : changed - property /schema/items/properties/username: x [prevent changing request property to required] cannot make a request property required. This is a breaking change. - at petstore-updated.json:1129:40742 + at petstore-updated.json:1133:40936 x POST /user: - request: @@ -194,16 +194,16 @@ specification details: - property /schema/properties/userStatus: changed x [prevent request property type changes] expected request body property 'userStatus' not to be narrowed. This is a breaking change. - at petstore-updated.json:1094:39555 + at petstore-updated.json:1098:39749 x [request and response property enums] cannot add enum or const to request property userStatus. This is a breaking change. - at petstore-updated.json:1094:39555 + at petstore-updated.json:1098:39749 - property /schema/properties/lastName: changed - property /schema/properties/username: changed x [prevent changing request property to required] cannot make a request property required. This is a breaking change. - at petstore-updated.json:1089:39297 + at petstore-updated.json:1093:39491 ✔ DELETE /store/order/{orderId}: - response 404: @@ -216,19 +216,19 @@ specification details: - property /schema/properties/status: changed x [request and response property enums] cannot add enum option 'canceled' from 'status' property. This is a breaking change. - at petstore-updated.json:1006:36426 + at petstore-updated.json:1010:36620 - body application/xml: - property /schema/properties/summary: added - property /schema/properties/status: changed x [request and response property enums] cannot add enum option 'canceled' from 'status' property. This is a breaking change. - at petstore-updated.json:984:35448 + at petstore-updated.json:988:35642 - property /schema/properties/petId: changed x [prevent making response property optional] cannot make required response property 'petId' optional. This is a breaking change. - at petstore-updated.json:981:35226 + at petstore-updated.json:985:35420 - response 404: - body application/json: added @@ -240,7 +240,7 @@ specification details: - property /schema/properties/status: changed x [request and response property enums] cannot remove enum option 'approved' from 'status' property. This is a breaking change. - at petstore-updated.json:870:31049 + at petstore-updated.json:874:31243 - response 200: - body application/json: @@ -248,7 +248,7 @@ specification details: - property /schema/properties/status: changed x [request and response property enums] cannot add enum option 'canceled' from 'status' property. This is a breaking change. - at petstore-updated.json:930:33483 + at petstore-updated.json:934:33677 - /example added @@ -257,34 +257,34 @@ specification details: - property /schema/properties/status: changed x [request and response property enums] cannot add enum option 'canceled' from 'status' property. This is a breaking change. - at petstore-updated.json:899:32168 + at petstore-updated.json:903:32362 x POST /pet/{petId}/uploadImage: - cookie parameter debug: - /schema/enum added x [prevent cookie parameters enum breaking changes] cannot add an enum to restrict possible values for cookie parameter debug. This is a breaking change. - at petstore-updated.json:752:27313 + at petstore-updated.json:756:27507 - response 200: removed x [prevent response status code removal] must not remove response status code 200. This is a breaking change. - at petstore-base.json:402:13891 + at petstore-base.json:409:14250 - body application/json: - property /schema/properties/code: x [prevent removing response property] cannot remove response property 'code'. This is a breaking change. - at petstore-base.json:409:14130 + at petstore-base.json:416:14489 - property /schema/properties/type: x [prevent removing response property] cannot remove response property 'type'. This is a breaking change. - at petstore-base.json:410:14200 + at petstore-base.json:417:14559 - property /schema/properties/message: x [prevent removing response property] cannot remove response property 'message'. This is a breaking change. - at petstore-base.json:411:14250 + at petstore-base.json:418:14609 - response 201: added - body application/json: @@ -295,18 +295,18 @@ specification details: - property /schema/properties/type: changed x [prevent response property type changes] expected response body property 'type' not to be expanded. This is a breaking change. - at petstore-updated.json:818:29397 + at petstore-updated.json:822:29591 x GET /pet: removed x [prevent operation removal] cannot remove an operation. This is a breaking change. - at petstore-base.json:161:5034 + at petstore-base.json:168:5393 x [prevent response status code removal] must not remove response status code 404. This is a breaking change. - at petstore-base.json:163:5076 + at petstore-base.json:170:5435 x [prevent response status code removal] must not remove response status code 405. This is a breaking change. - at petstore-base.json:164:5144 + at petstore-base.json:171:5503 ✔ POST /pet: - /operationId changed @@ -320,23 +320,23 @@ specification details: - property /schema/properties/number: added x [prevent changing request property to required] cannot add a required request property 'number' to an existing operation. This is a breaking change. - at petstore-updated.json:185:5837 + at petstore-updated.json:189:6031 - response 400: removed x [prevent response status code removal] must not remove response status code 400. This is a breaking change. - at petstore-base.json:257:8689 + at petstore-base.json:264:9048 x GET /parameters_at_path: - query parameter query_in_path: added x [prevent new required query parameters] cannot add required query parameter query_in_path to an existing operation. This is a breaking change. - at petstore-updated.json:155:4992 + at petstore-updated.json:159:5186 x PATCH /example: - query parameter seomthing: added x [prevent new required query parameters] cannot add required query parameter seomthing to an existing operation. This is a breaking change. - at petstore-updated.json:142:4696 + at petstore-updated.json:146:4890 ✔ POST /example: - query parameter somethingelse: removed @@ -364,34 +364,39 @@ specification details: - property /schema/properties/toTypeArrays: changed x [prevent response property type changes] expected response body property 'toTypeArrays' not to be expanded. This is a breaking change. - at petstore-updated.json:119:4156 + at petstore-updated.json:123:4350 - property /schema/properties/fromTypeArrays: changed - property /schema/properties/requiredKeys/properties/requiredToOptional: changed x [prevent making response property optional] cannot make required response property 'requiredToOptional' optional. This is a breaking change. - at petstore-updated.json:114:3902 + at petstore-updated.json:118:4096 - property /schema/properties/requiredKeys/properties/deletedRequiredKey: removed x [prevent removing response property] cannot remove response property 'deletedRequiredKey'. This is a breaking change. - at petstore-base.json:112:3802 + at petstore-base.json:119:4161 - property /schema/properties/requiredKeys/properties/optionalToRequired: changed - property /schema/properties/requiredKeys/properties/addedRequiredKey: added x [response property naming check] addedRequiredKey is not snake_case - at petstore-updated.json:115:3970 + at petstore-updated.json:119:4164 - property /schema/properties/expandableObject: changed - property /schema/properties/composedObject/properties/orderId: changed x [prevent response property type changes] expected response body property 'orderId' not to be expanded. This is a breaking change. - at petstore-updated.json:83:2701 + at petstore-updated.json:87:2895 - property /schema/properties/stringOrNumberOrObject: changed - x [prevent narrowing in response union types] cannot narrow a response body + x [prevent narrowing in response union types] response property oneOf schema did not overlap with the previous schema. oneOf/2: type object was changed to string + at petstore-updated.json:80:2583 + + - property /schema/properties/oneOfToObject: changed + + x [prevent narrowing in response union types] response property changed from oneOf did not overlap with the previous schema. oneOf/0/id: was made optional at petstore-updated.json:76:2389 Rerun this command with the --web flag to view the detailed changes in your browser @@ -449,7 +454,7 @@ exports[`diff two files, no repo or config 1`] = ` `; exports[`diff with --json arg 1`] = ` -"{"operations":[{"name":"GET /pet/findByStatus","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Finds Pets by status","description":"Multiple status values can be provided with comma separated strings","operationId":"findPetsByStatus","parameters":[{"name":"status","in":"query","description":"Status values that need to be considered for filter","required":true,"style":"form","explode":true,"schema":{"type":"array","items":{"type":"string","default":"available","enum":["available","pending","sold"]}}},{"name":"debug","in":"cookie","description":"A debug token","required":false,"schema":{"type":"integer","enum":[0,1]}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"application/json":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}}},"headers":{}},"400":{"description":"Invalid status value","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"GET /pet/findByTags","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Finds Pets by tags","description":"Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","operationId":"findPetsByTags","parameters":[{"name":"tags","in":"query","description":"Tags to filter by","required":true,"style":"form","explode":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"application/json":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}}},"headers":{}},"400":{"description":"Invalid tag value","content":{},"headers":{}}},"deprecated":true,"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"GET /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Find pet by ID","description":"Returns a single pet","operationId":"getPetById","parameters":[{"name":"petId","in":"path","description":"ID of pet to return","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}},"application/json":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"headers":{}},"400":{"description":"Invalid ID supplied","content":{},"headers":{}},"404":{"description":"Pet not found","content":{},"headers":{}}},"security":[{"api_key":[]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"POST /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Updates a pet in the store with form data","operationId":"updatePetWithForm","parameters":[{"name":"petId","in":"path","description":"ID of pet that needs to be updated","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"properties":{"name":{"type":"string","description":"Updated name of the pet"},"status":{"type":"string","description":"Updated status of the pet"}}}}}},"responses":{"405":{"description":"Invalid input","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"DELETE /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Deletes a pet","operationId":"deletePet","parameters":[{"name":"api_key","in":"header","schema":{"type":"string"}},{"name":"petId","in":"path","description":"Pet id to delete","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"400":{"description":"Invalid ID supplied","content":{},"headers":{}},"404":{"description":"Pet not found","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"PUT /user/{username}","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}]},"responses":[]},{"name":"GET /user/{username}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}],"headers":[]},{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"GET /user/login","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[],"headers":[{"name":"response header content-type","change":"removed","attributes":[{"key":"","before":{"description":"the description goes here","schema":{"type":"string"}},"change":"removed"}]},{"name":"response header X-Rate-Limit","change":"added","attributes":[{"key":"","after":{"description":"calls per hour allowed by the user","schema":{"type":"integer","format":"int32"}},"change":"added"}]},{"name":"response header X-Expires-After","change":"changed","attributes":[{"key":"/schema/type","change":"changed","before":"number","after":"string"},{"key":"/schema/format","after":"date-time","change":"added"}]}]}]},{"name":"POST /user/createWithList","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/items/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/items/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/items/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"","change":"changed","before":{"schema":{"items":{"required":["id","email","lastName","userStatus"],"properties":{"phone":{"type":"string"},"userStatus":{"type":"integer","format":"int32"}}}}},"after":{"schema":{"items":{"required":["id","email","username","userStatus"],"properties":{"userStatus":{"type":"string","enum":["activation-pending","activated","blocked"]},"bio":{"type":"string"}}}}}}]}]},"responses":[]},{"name":"POST /user/createWithArray","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/items/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/items/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/items/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"","change":"changed","before":{"schema":{"items":{"required":["id","email","lastName","userStatus"],"properties":{"phone":{"type":"string"},"userStatus":{"type":"integer","format":"int32"}}}}},"after":{"schema":{"items":{"required":["id","email","username","userStatus"],"properties":{"userStatus":{"type":"string","enum":["activation-pending","activated","blocked"]},"bio":{"type":"string"}}}}}}]}]},"responses":[]},{"name":"POST /user","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}]},"responses":[]},{"name":"DELETE /store/order/{orderId}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"GET /store/order/{orderId}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}},{"key":"/schema/properties/petId","change":"changed","before":{"required":true},"after":{"required":false}}]}],"headers":[]},{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"POST /store/order","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]}]},"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}},{"key":"/example","after":{"id":458102,"petId":581231,"quantity":31,"shipDate":"2022-03-04T22:54:32.631Z","status":"delivered","summary":"31 boxes of dog food","complete":false},"change":"added"}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]}],"headers":[]}]},{"name":"POST /pet/{petId}/uploadImage","change":"changed","attributes":[],"parameters":[{"name":"cookie parameter 'debug'","change":"changed","attributes":[{"key":"/schema/enum","after":[0,1],"change":"added"}]}],"responses":[{"name":"200 response","change":"removed","attributes":[{"key":"","before":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"removed"}],"contentTypes":[],"headers":[]},{"name":"201 response","change":"added","attributes":[{"key":"","after":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"added"}],"contentTypes":[],"headers":[]},{"name":"404 response","change":"added","attributes":[{"key":"","after":{"description":"Pet not found","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"added"}],"contentTypes":[],"headers":[]},{"name":"default response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/type","change":"changed","before":{"type":"string"},"after":{"type":"integer"}}]}],"headers":[]}]},{"name":"GET /pet","change":"removed","attributes":[{"key":"","before":{"responses":{"404":{"description":"Pet not found","content":{},"headers":{}},"405":{"description":"Validation exception","content":{},"headers":{}}},"parameters":[]},"change":"removed"}],"parameters":[],"responses":[]},{"name":"POST /pet","change":"changed","attributes":[{"key":"/operationId","change":"changed","before":"addPet","after":"addPet-change"}],"parameters":[],"responses":[]},{"name":"PUT /pet","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"application/xml","change":"removed","attributes":[{"key":"","before":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}},"change":"removed"}]},{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/name","before":{"type":"string","example":"doggie","required":true},"change":"removed"},{"key":"/schema/properties/number","after":{"type":"string","required":true},"change":"added"}]}]},"responses":[{"name":"400 response","change":"removed","attributes":[{"key":"","before":{"description":"Invalid ID supplied","content":{},"headers":{}},"change":"removed"}],"contentTypes":[],"headers":[]}]},{"name":"GET /parameters_at_path","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'query_in_path'","change":"added","attributes":[{"key":"","after":{"name":"query_in_path","in":"query","description":"asada","required":true,"schema":{"type":"string"}},"change":"added"}]}],"responses":[]},{"name":"PATCH /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'seomthing'","change":"added","attributes":[{"key":"","after":{"name":"seomthing","in":"query","description":"something","required":true,"schema":{"type":"string"}},"change":"added"}]}],"responses":[]},{"name":"POST /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'somethingelse'","change":"removed","attributes":[{"key":"","before":{"name":"somethingelse","in":"query","description":"something","required":true,"schema":{"type":"string"}},"change":"removed"}]}],"responses":[]},{"name":"GET /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'deletedQueryParameter'","change":"removed","attributes":[{"key":"","before":{"name":"deletedQueryParameter","in":"query","description":"The user name for login","required":true,"schema":{"type":"string"}},"change":"removed"}]},{"name":"query parameter 'addedQueryParameter'","change":"added","attributes":[{"key":"","after":{"name":"addedQueryParameter","in":"query","description":"The user name for login","required":true,"schema":{"type":"string"}},"change":"added"}]},{"name":"query parameter 'requiredToOptional'","change":"changed","attributes":[{"key":"/required","before":true,"change":"removed"}]},{"name":"query parameter 'optionalToRequired'","change":"changed","attributes":[{"key":"/required","after":true,"change":"added"}]}],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/toTypeArrays","change":"changed","before":{"type":"string"},"after":{"type":["string","number"]}},{"key":"/schema/properties/fromTypeArrays","change":"changed","before":{"type":["string","number"]},"after":{"type":"string"}},{"key":"/schema/properties/requiredKeys/properties/requiredToOptional","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/requiredKeys/properties/deletedRequiredKey","before":{"type":"string","required":true},"change":"removed"},{"key":"/schema/properties/requiredKeys/properties/optionalToRequired","change":"changed","before":{"required":false},"after":{"required":true}},{"key":"/schema/properties/requiredKeys/properties/addedRequiredKey","after":{"type":"string","required":true},"change":"added"},{"key":"/schema/properties/expandableObject","change":"changed","before":{"anyOf":[{"type":"object","properties":{"orderId":{"type":"string"}}}]},"after":{"anyOf":[{"type":"object","properties":{"orderId":{"type":"string"}}},{"type":"object","properties":{"order":{"type":"object","properties":{"id":{"type":"string"}}}}}]}},{"key":"/schema/properties/composedObject/properties/orderId","change":"changed","before":{"type":"string"},"after":{"type":"number"}},{"key":"/schema/properties/stringOrNumberOrObject","change":"changed","before":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"orderId":{"type":"string"}}}]},"after":{"oneOf":[{"type":"string"},{"type":"number"}]}}]}],"headers":[]}]}]} +"{"operations":[{"name":"GET /pet/findByStatus","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Finds Pets by status","description":"Multiple status values can be provided with comma separated strings","operationId":"findPetsByStatus","parameters":[{"name":"status","in":"query","description":"Status values that need to be considered for filter","required":true,"style":"form","explode":true,"schema":{"type":"array","items":{"type":"string","default":"available","enum":["available","pending","sold"]}}},{"name":"debug","in":"cookie","description":"A debug token","required":false,"schema":{"type":"integer","enum":[0,1]}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"application/json":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}}},"headers":{}},"400":{"description":"Invalid status value","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"GET /pet/findByTags","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Finds Pets by tags","description":"Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.","operationId":"findPetsByTags","parameters":[{"name":"tags","in":"query","description":"Tags to filter by","required":true,"style":"form","explode":true,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"application/json":{"schema":{"type":"array","items":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}}},"headers":{}},"400":{"description":"Invalid tag value","content":{},"headers":{}}},"deprecated":true,"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"GET /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Find pet by ID","description":"Returns a single pet","operationId":"getPetById","parameters":[{"name":"petId","in":"path","description":"ID of pet to return","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"successful operation","content":{"application/xml":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}},"application/json":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}}},"headers":{}},"400":{"description":"Invalid ID supplied","content":{},"headers":{}},"404":{"description":"Pet not found","content":{},"headers":{}}},"security":[{"api_key":[]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"POST /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Updates a pet in the store with form data","operationId":"updatePetWithForm","parameters":[{"name":"petId","in":"path","description":"ID of pet that needs to be updated","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"properties":{"name":{"type":"string","description":"Updated name of the pet"},"status":{"type":"string","description":"Updated status of the pet"}}}}}},"responses":{"405":{"description":"Invalid input","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"DELETE /pet/{petId}","change":"added","attributes":[{"key":"","after":{"tags":["pet"],"summary":"Deletes a pet","operationId":"deletePet","parameters":[{"name":"api_key","in":"header","schema":{"type":"string"}},{"name":"petId","in":"path","description":"Pet id to delete","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"400":{"description":"Invalid ID supplied","content":{},"headers":{}},"404":{"description":"Pet not found","content":{},"headers":{}}},"security":[{"petstore_auth":["write:pets","read:pets"]}]},"change":"added"}],"parameters":[],"responses":[]},{"name":"PUT /user/{username}","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}]},"responses":[]},{"name":"GET /user/{username}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}],"headers":[]},{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"GET /user/login","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[],"headers":[{"name":"response header content-type","change":"removed","attributes":[{"key":"","before":{"description":"the description goes here","schema":{"type":"string"}},"change":"removed"}]},{"name":"response header X-Rate-Limit","change":"added","attributes":[{"key":"","after":{"description":"calls per hour allowed by the user","schema":{"type":"integer","format":"int32"}},"change":"added"}]},{"name":"response header X-Expires-After","change":"changed","attributes":[{"key":"/schema/type","change":"changed","before":"number","after":"string"},{"key":"/schema/format","after":"date-time","change":"added"}]}]}]},{"name":"POST /user/createWithList","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/items/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/items/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/items/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"","change":"changed","before":{"schema":{"items":{"required":["id","email","lastName","userStatus"],"properties":{"phone":{"type":"string"},"userStatus":{"type":"integer","format":"int32"}}}}},"after":{"schema":{"items":{"required":["id","email","username","userStatus"],"properties":{"userStatus":{"type":"string","enum":["activation-pending","activated","blocked"]},"bio":{"type":"string"}}}}}}]}]},"responses":[]},{"name":"POST /user/createWithArray","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/items/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/items/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/items/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"","change":"changed","before":{"schema":{"items":{"required":["id","email","lastName","userStatus"],"properties":{"phone":{"type":"string"},"userStatus":{"type":"integer","format":"int32"}}}}},"after":{"schema":{"items":{"required":["id","email","username","userStatus"],"properties":{"userStatus":{"type":"string","enum":["activation-pending","activated","blocked"]},"bio":{"type":"string"}}}}}}]}]},"responses":[]},{"name":"POST /user","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/phone","before":{"type":"string","required":false},"change":"removed"},{"key":"/schema/properties/bio","after":{"type":"string","required":false},"change":"added"},{"key":"/schema/properties/userStatus","change":"changed","before":{"type":"integer","format":"int32"},"after":{"type":"string","enum":["activation-pending","activated","blocked"]}},{"key":"/schema/properties/lastName","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/username","change":"changed","before":{"required":false},"after":{"required":true}}]}]},"responses":[]},{"name":"DELETE /store/order/{orderId}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"GET /store/order/{orderId}","change":"changed","attributes":[],"parameters":[],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}},{"key":"/schema/properties/petId","change":"changed","before":{"required":true},"after":{"required":false}}]}],"headers":[]},{"name":"404 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"added","attributes":[{"key":"","after":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}},"change":"added"}]}],"headers":[]}]},{"name":"POST /store/order","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"*/*","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]}]},"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}},{"key":"/example","after":{"id":458102,"petId":581231,"quantity":31,"shipDate":"2022-03-04T22:54:32.631Z","status":"delivered","summary":"31 boxes of dog food","complete":false},"change":"added"}]},{"name":"application/xml","change":"changed","attributes":[{"key":"/schema/properties/summary","after":{"type":"string","description":"Human readable summary of order","required":false},"change":"added"},{"key":"/schema/properties/status","change":"changed","before":{"enum":["placed","approved","delivered"]},"after":{"enum":["placed","delivered","canceled"]}}]}],"headers":[]}]},{"name":"POST /pet/{petId}/uploadImage","change":"changed","attributes":[],"parameters":[{"name":"cookie parameter 'debug'","change":"changed","attributes":[{"key":"/schema/enum","after":[0,1],"change":"added"}]}],"responses":[{"name":"200 response","change":"removed","attributes":[{"key":"","before":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"removed"}],"contentTypes":[],"headers":[]},{"name":"201 response","change":"added","attributes":[{"key":"","after":{"description":"successful operation","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"added"}],"contentTypes":[],"headers":[]},{"name":"404 response","change":"added","attributes":[{"key":"","after":{"description":"Pet not found","content":{"application/json":{"schema":{"type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}}}}},"headers":{}},"change":"added"}],"contentTypes":[],"headers":[]},{"name":"default response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/type","change":"changed","before":{"type":"string"},"after":{"type":"integer"}}]}],"headers":[]}]},{"name":"GET /pet","change":"removed","attributes":[{"key":"","before":{"responses":{"404":{"description":"Pet not found","content":{},"headers":{}},"405":{"description":"Validation exception","content":{},"headers":{}}},"parameters":[]},"change":"removed"}],"parameters":[],"responses":[]},{"name":"POST /pet","change":"changed","attributes":[{"key":"/operationId","change":"changed","before":"addPet","after":"addPet-change"}],"parameters":[],"responses":[]},{"name":"PUT /pet","change":"changed","attributes":[],"parameters":[],"requestBody":{"name":"Request Body","change":"changed","attributes":[],"contentTypes":[{"name":"application/xml","change":"removed","attributes":[{"key":"","before":{"schema":{"required":["name","photoUrls"],"type":"object","properties":{"id":{"type":"integer","format":"int64"},"category":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","enum":["available","pending","sold"]}},"xml":{"name":"Pet"}}},"change":"removed"}]},{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/name","before":{"type":"string","example":"doggie","required":true},"change":"removed"},{"key":"/schema/properties/number","after":{"type":"string","required":true},"change":"added"}]}]},"responses":[{"name":"400 response","change":"removed","attributes":[{"key":"","before":{"description":"Invalid ID supplied","content":{},"headers":{}},"change":"removed"}],"contentTypes":[],"headers":[]}]},{"name":"GET /parameters_at_path","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'query_in_path'","change":"added","attributes":[{"key":"","after":{"name":"query_in_path","in":"query","description":"asada","required":true,"schema":{"type":"string"}},"change":"added"}]}],"responses":[]},{"name":"PATCH /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'seomthing'","change":"added","attributes":[{"key":"","after":{"name":"seomthing","in":"query","description":"something","required":true,"schema":{"type":"string"}},"change":"added"}]}],"responses":[]},{"name":"POST /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'somethingelse'","change":"removed","attributes":[{"key":"","before":{"name":"somethingelse","in":"query","description":"something","required":true,"schema":{"type":"string"}},"change":"removed"}]}],"responses":[]},{"name":"GET /example","change":"changed","attributes":[],"parameters":[{"name":"query parameter 'deletedQueryParameter'","change":"removed","attributes":[{"key":"","before":{"name":"deletedQueryParameter","in":"query","description":"The user name for login","required":true,"schema":{"type":"string"}},"change":"removed"}]},{"name":"query parameter 'addedQueryParameter'","change":"added","attributes":[{"key":"","after":{"name":"addedQueryParameter","in":"query","description":"The user name for login","required":true,"schema":{"type":"string"}},"change":"added"}]},{"name":"query parameter 'requiredToOptional'","change":"changed","attributes":[{"key":"/required","before":true,"change":"removed"}]},{"name":"query parameter 'optionalToRequired'","change":"changed","attributes":[{"key":"/required","after":true,"change":"added"}]}],"responses":[{"name":"200 response","change":"changed","attributes":[],"contentTypes":[{"name":"application/json","change":"changed","attributes":[{"key":"/schema/properties/toTypeArrays","change":"changed","before":{"type":"string"},"after":{"type":["string","number"]}},{"key":"/schema/properties/fromTypeArrays","change":"changed","before":{"type":["string","number"]},"after":{"type":"string"}},{"key":"/schema/properties/requiredKeys/properties/requiredToOptional","change":"changed","before":{"required":true},"after":{"required":false}},{"key":"/schema/properties/requiredKeys/properties/deletedRequiredKey","before":{"type":"string","required":true},"change":"removed"},{"key":"/schema/properties/requiredKeys/properties/optionalToRequired","change":"changed","before":{"required":false},"after":{"required":true}},{"key":"/schema/properties/requiredKeys/properties/addedRequiredKey","after":{"type":"string","required":true},"change":"added"},{"key":"/schema/properties/expandableObject","change":"changed","before":{"anyOf":[{"type":"object","properties":{"orderId":{"type":"string"}}}]},"after":{"anyOf":[{"type":"object","properties":{"orderId":{"type":"string"}}},{"type":"object","properties":{"order":{"type":"object","properties":{"id":{"type":"string"}}}}}]}},{"key":"/schema/properties/composedObject/properties/orderId","change":"changed","before":{"type":"string"},"after":{"type":"number"}},{"key":"/schema/properties/stringOrNumberOrObject","change":"changed","before":{"oneOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"orderId":{"type":"string"}}}]},"after":{"oneOf":[{"type":"string"},{"type":"number"}]}},{"key":"/schema/properties/oneOfToObject","change":"changed","before":{"oneOf":[{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}]},"after":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"}}}}]}],"headers":[]}]}]} " `; diff --git a/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-base.json b/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-base.json index a2b96b667c..b359cda521 100644 --- a/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-base.json +++ b/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-base.json @@ -72,6 +72,13 @@ "schema": { "type": "object", "properties": { + "oneOfToObject": { + "oneOf": [ + { "type": "object", "properties": { "id": {"type":"string"}}, "required": ["id"]}, + { "type": "object", "properties": { "name": {"type":"string"}}, "required": ["name"]} + + ] + }, "stringOrNumberOrObject": { "oneOf": [ { "type": "string" }, diff --git a/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-updated.json b/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-updated.json index 16c6e4499f..607f88f2d2 100644 --- a/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-updated.json +++ b/projects/optic/src/__tests__/integration/workspaces/diff/petstore/petstore-updated.json @@ -73,6 +73,10 @@ "schema": { "type": "object", "properties": { + "oneOfToObject": { + "type": "object", + "properties": { "id": {"type": "string"}, "name": {"type": "string"}} + }, "stringOrNumberOrObject": { "oneOf": [{ "type": "string" }, { "type": "number" }] }, diff --git a/projects/rulesets-base/package.json b/projects/rulesets-base/package.json index 6f1d73061c..e47d6ee184 100644 --- a/projects/rulesets-base/package.json +++ b/projects/rulesets-base/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/rulesets-base", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/standard-rulesets/package.json b/projects/standard-rulesets/package.json index 98690d8f3e..b1b9d4f975 100644 --- a/projects/standard-rulesets/package.json +++ b/projects/standard-rulesets/package.json @@ -2,7 +2,7 @@ "name": "@useoptic/standard-rulesets", "license": "MIT", "packageManager": "yarn@3.6.4", - "version": "0.50.15", + "version": "0.50.16", "main": "build/index.js", "types": "build/index.d.ts", "files": [ diff --git a/projects/standard-rulesets/src/breaking-changes/__tests__/__snapshots__/breaking-changes.test.ts.snap b/projects/standard-rulesets/src/breaking-changes/__tests__/__snapshots__/breaking-changes.test.ts.snap index 3234b0982b..a545335208 100644 --- a/projects/standard-rulesets/src/breaking-changes/__tests__/__snapshots__/breaking-changes.test.ts.snap +++ b/projects/standard-rulesets/src/breaking-changes/__tests__/__snapshots__/breaking-changes.test.ts.snap @@ -1,5 +1,412 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`breaking changes ruleset invalid union type enum transition in request 1`] = ` +[ + { + "change": { + "added": { + "flatSchema": { + "type": "string", + }, + "key": "id", + "required": false, + }, + "changeType": "added", + "location": { + "conceptualLocation": { + "inRequest": { + "body": { + "contentType": "application/json", + }, + }, + "jsonSchemaTrail": [ + "id", + ], + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "application/json", + "oneOf", + "1", + "id", + ], + "jsonPath": "/paths/~1api~1users/get/requestBody/content/application~1json/schema/oneOf/1/properties/id", + "kind": "field", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": undefined, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent changing request property to required", + "passed": true, + "received": undefined, + "severity": 2, + "type": "added", + "where": "GET /api/users request body: application/json property: id", + }, + { + "change": { + "added": { + "flatSchema": { + "enum": [ + "online", + ], + "type": "string", + }, + "key": "status", + "required": false, + }, + "changeType": "added", + "location": { + "conceptualLocation": { + "inRequest": { + "body": { + "contentType": "application/json", + }, + }, + "jsonSchemaTrail": [ + "status", + ], + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "application/json", + "oneOf", + "1", + "status", + ], + "jsonPath": "/paths/~1api~1users/get/requestBody/content/application~1json/schema/oneOf/1/properties/status", + "kind": "field", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": undefined, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent changing request property to required", + "passed": true, + "received": undefined, + "severity": 2, + "type": "added", + "where": "GET /api/users request body: application/json property: status", + }, + { + "change": { + "changeType": "changed", + "changed": { + "after": { + "contentType": "application/json", + "flatSchema": { + "oneOf": [ + { + "type": "number", + }, + { + "properties": { + "id": { + "type": "string", + }, + "status": { + "enum": [ + "online", + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + }, + }, + "before": { + "contentType": "application/json", + "flatSchema": { + "type": [ + "number", + "object", + ], + }, + }, + }, + "location": { + "conceptualLocation": { + "inRequest": { + "body": { + "contentType": "application/json", + }, + }, + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "application/json", + ], + "jsonPath": "/paths/~1api~1users/get/requestBody/content/application~1json", + "kind": "body", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": undefined, + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent request property type changes", + "passed": true, + "received": undefined, + "severity": 2, + "type": "changed", + "where": "GET /api/users request body: application/json", + }, + { + "change": { + "changeType": "changed", + "changed": { + "after": { + "contentType": "application/json", + "flatSchema": { + "oneOf": [ + { + "type": "number", + }, + { + "properties": { + "id": { + "type": "string", + }, + "status": { + "enum": [ + "online", + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + }, + }, + "before": { + "contentType": "application/json", + "flatSchema": { + "type": [ + "number", + "object", + ], + }, + }, + }, + "location": { + "conceptualLocation": { + "inRequest": { + "body": { + "contentType": "application/json", + }, + }, + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "application/json", + ], + "jsonPath": "/paths/~1api~1users/get/requestBody/content/application~1json", + "kind": "body", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": "request body changed to oneOf did not overlap with the previous schema. oneOf/1/status: enums offline were added", + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent expanded in request union types", + "passed": false, + "received": undefined, + "severity": 2, + "type": "changed", + "where": "GET /api/users request body: application/json", + }, +] +`; + +exports[`breaking changes ruleset invalid union type enum transition in response 1`] = ` +[ + { + "change": { + "changeType": "changed", + "changed": { + "after": { + "contentType": "application/json", + "flatSchema": { + "oneOf": [ + { + "type": "number", + }, + { + "properties": { + "id": { + "type": "string", + }, + "status": { + "enum": [ + "online", + "offline", + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + }, + }, + "before": { + "contentType": "application/json", + "flatSchema": { + "type": [ + "number", + "object", + ], + }, + }, + }, + "location": { + "conceptualLocation": { + "inResponse": { + "body": { + "contentType": "application/json", + }, + "statusCode": "200", + }, + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "responses", + "200", + "application/json", + ], + "jsonPath": "/paths/~1api~1users/get/responses/200/content/application~1json", + "kind": "body", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": undefined, + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent response property type changes", + "passed": true, + "received": undefined, + "severity": 2, + "type": "changed", + "where": "GET /api/users response 200 response body: application/json", + }, + { + "change": { + "changeType": "changed", + "changed": { + "after": { + "contentType": "application/json", + "flatSchema": { + "oneOf": [ + { + "type": "number", + }, + { + "properties": { + "id": { + "type": "string", + }, + "status": { + "enum": [ + "online", + "offline", + ], + "type": "string", + }, + }, + "type": "object", + }, + ], + }, + }, + "before": { + "contentType": "application/json", + "flatSchema": { + "type": [ + "number", + "object", + ], + }, + }, + }, + "location": { + "conceptualLocation": { + "inResponse": { + "body": { + "contentType": "application/json", + }, + "statusCode": "200", + }, + "method": "get", + "path": "/api/users", + }, + "conceptualPath": [ + "operations", + "/api/users", + "get", + "responses", + "200", + "application/json", + ], + "jsonPath": "/paths/~1api~1users/get/responses/200/content/application~1json", + "kind": "body", + }, + }, + "condition": undefined, + "docsLink": undefined, + "error": "response body changed to oneOf did not overlap with the previous schema. /status: enums offline were removed", + "exempted": false, + "expected": undefined, + "isMust": true, + "isShould": false, + "name": "prevent narrowing in response union types", + "passed": false, + "received": undefined, + "severity": 2, + "type": "changed", + "where": "GET /api/users response 200 response body: application/json", + }, +] +`; + exports[`breaking changes ruleset invalid union type transition 1`] = ` [ { @@ -134,7 +541,7 @@ exports[`breaking changes ruleset invalid union type transition 1`] = ` }, "condition": undefined, "docsLink": undefined, - "error": "cannot narrow a response body", + "error": "response body changed to oneOf did not overlap with the previous schema. /id: was made optional, /name: was made optional", "exempted": false, "expected": undefined, "isMust": true, diff --git a/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts b/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts index 704dd3c2ce..ca0be0ba33 100644 --- a/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts +++ b/projects/standard-rulesets/src/breaking-changes/__tests__/breaking-changes.test.ts @@ -1087,6 +1087,150 @@ describe('breaking changes ruleset', () => { expect(results).toMatchSnapshot(); expect(results.some((result) => !result.passed)).toBe(true); }); + + test('invalid union type enum transition in request', async () => { + const beforeJson: any = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + requestBody: { + content: { + 'application/json': { + schema: { + type: ['number', 'object'], + properties: { + id: { type: 'string' }, + status: { type: 'string', enum: ['online', 'offline'] }, + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'response', + }, + }, + }, + }, + }, + }; + const afterJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + requestBody: { + content: { + 'application/json': { + schema: { + oneOf: [ + { type: 'number' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + status: { + type: 'string', + enum: ['online'], + }, + }, + }, + ], + }, + }, + }, + }, + responses: { + '200': { + description: 'response', + }, + }, + }, + }, + }, + }; + const results = await TestHelpers.runRulesWithInputs( + [new BreakingChangesRuleset()], + beforeJson, + afterJson + ); + expect(results.length > 0).toBe(true); + + expect(results).toMatchSnapshot(); + expect(results.some((result) => !result.passed)).toBe(true); + }); + + test('invalid union type enum transition in response', async () => { + const beforeJson: any = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: { + '200': { + description: 'response', + content: { + 'application/json': { + schema: { + type: ['number', 'object'], + properties: { + id: { type: 'string' }, + status: { type: 'string', enum: ['online'] }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const afterJson: OpenAPIV3.Document = { + ...TestHelpers.createEmptySpec(), + paths: { + '/api/users': { + get: { + responses: { + '200': { + description: 'response', + content: { + 'application/json': { + schema: { + oneOf: [ + { type: 'number' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + status: { + type: 'string', + enum: ['online', 'offline'], + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }, + }; + const results = await TestHelpers.runRulesWithInputs( + [new BreakingChangesRuleset()], + beforeJson, + afterJson + ); + expect(results.length > 0).toBe(true); + + expect(results).toMatchSnapshot(); + expect(results.some((result) => !result.passed)).toBe(true); + }); }); describe('breaking change ruleset configuration', () => { diff --git a/projects/standard-rulesets/src/breaking-changes/helpers/__tests__/unions.test.ts b/projects/standard-rulesets/src/breaking-changes/helpers/__tests__/unions.test.ts index f9f73ec7fa..c069e1c658 100644 --- a/projects/standard-rulesets/src/breaking-changes/helpers/__tests__/unions.test.ts +++ b/projects/standard-rulesets/src/breaking-changes/helpers/__tests__/unions.test.ts @@ -250,7 +250,7 @@ const schemas: { }; describe('computeUnionTransition', () => { - describe.each([['narrowing'], ['expanding']])('%s', (type) => { + describe.each([['narrowing'], ['request']])('%s', (type) => { describe('transitions', () => { describe('type to type', () => { test('type is changed', () => { @@ -282,13 +282,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: true, - narrowed: true, + request: true, + response: true, + responseReasons: expect.any(Array), + requestReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: true, + request: true, + response: true, + responseReasons: expect.any(Array), + requestReasons: expect.any(Array), }); } }); @@ -323,13 +327,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -350,13 +358,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -377,19 +389,23 @@ describe('computeUnionTransition', () => { if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: false, + request: false, + response: false, + requestReasons: [], + responseReasons: [], }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: false, - narrowed: false, + request: false, + response: false, + requestReasons: [], + responseReasons: [], }); } }); test('enums', () => { - const narrowed: OpenAPIV3_1.SchemaObject = { + const moreEnums: OpenAPIV3_1.SchemaObject = { type: 'object', properties: { user: { @@ -397,13 +413,13 @@ describe('computeUnionTransition', () => { properties: { status: { type: 'string', - enum: ['online'], + enum: ['online', 'offline'], }, }, }, }, }; - const expanded: OpenAPIV3_1.SchemaObject = { + const lessEnums: OpenAPIV3_1.SchemaObject = { type: 'object', properties: { user: { @@ -411,21 +427,25 @@ describe('computeUnionTransition', () => { properties: { status: { type: 'string', - enum: ['online', 'offline'], + enum: ['online'], }, }, }, }, }; if (type === 'narrowing') { - expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + expect(computeUnionTransition(lessEnums, moreEnums)).toEqual({ + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { - expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + expect(computeUnionTransition(moreEnums, lessEnums)).toEqual({ + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -440,8 +460,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + responseReasons: expect.any(Array), + requestReasons: [], }); } else { expect( @@ -450,8 +472,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.expanded ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + responseReasons: [], + requestReasons: expect.any(Array), }); } @@ -461,8 +485,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.expanded ) ).toEqual({ - expanded: false, - narrowed: false, + request: false, + response: false, + responseReasons: [], + requestReasons: [], }); }); @@ -473,8 +499,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - narrowed: false, - expanded: false, + response: false, + request: false, + responseReasons: [], + requestReasons: [], }); }); @@ -486,8 +514,11 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + + requestReasons: [], + responseReasons: expect.any(Array), }); expect( computeUnionTransition( @@ -495,8 +526,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect( @@ -505,8 +538,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.expandedObject ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); expect( computeUnionTransition( @@ -514,8 +549,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.expandedArray ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -530,8 +567,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.simple.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect( @@ -540,8 +579,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.simple.expanded ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } @@ -551,8 +592,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.simple.expanded ) ).toEqual({ - expanded: false, - narrowed: false, + request: false, + response: false, + requestReasons: [], + responseReasons: [], }); }); @@ -563,8 +606,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.nested.narrowed ) ).toEqual({ - narrowed: false, - expanded: false, + response: false, + request: false, + requestReasons: [], + responseReasons: [], }); }); @@ -576,8 +621,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); expect( computeUnionTransition( @@ -585,8 +632,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect( @@ -595,8 +644,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.nested.expandedObject ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); expect( computeUnionTransition( @@ -604,8 +655,10 @@ describe('computeUnionTransition', () => { schemas.oneOf.nested.expandedArray ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -620,8 +673,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect( @@ -630,8 +685,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.expanded ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } @@ -641,8 +698,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.simple.expanded ) ).toEqual({ - expanded: false, - narrowed: false, + request: false, + response: false, + requestReasons: [], + responseReasons: [], }); }); @@ -653,8 +712,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - narrowed: false, - expanded: false, + response: false, + request: false, + requestReasons: [], + responseReasons: [], }); }); @@ -666,8 +727,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); expect( computeUnionTransition( @@ -675,8 +738,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.narrowed ) ).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect( @@ -685,8 +750,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.expandedObject ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); expect( computeUnionTransition( @@ -694,8 +761,10 @@ describe('computeUnionTransition', () => { schemas.typeArrays.nested.expandedArray ) ).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -730,13 +799,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -770,13 +843,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -788,13 +865,17 @@ describe('computeUnionTransition', () => { .properties.nested; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); @@ -817,13 +898,17 @@ describe('computeUnionTransition', () => { }; if (type === 'narrowing') { expect(computeUnionTransition(expanded, narrowed)).toEqual({ - expanded: false, - narrowed: true, + request: false, + response: true, + requestReasons: [], + responseReasons: expect.any(Array), }); } else { expect(computeUnionTransition(narrowed, expanded)).toEqual({ - expanded: true, - narrowed: false, + request: true, + response: false, + requestReasons: expect.any(Array), + responseReasons: [], }); } }); diff --git a/projects/standard-rulesets/src/breaking-changes/helpers/type-change.ts b/projects/standard-rulesets/src/breaking-changes/helpers/type-change.ts index ee4aa356fa..98326c9db7 100644 --- a/projects/standard-rulesets/src/breaking-changes/helpers/type-change.ts +++ b/projects/standard-rulesets/src/breaking-changes/helpers/type-change.ts @@ -6,28 +6,28 @@ type PropertyType = { }; type BreakingChangeResult = { - enum: boolean; - typeChange: boolean; - requiredChange: boolean; + enum: string | false; + typeChange: string | false; + requiredChange: string | false; }; export function computeTypeTransition( before: PropertyType, after: PropertyType ): { - expanded: BreakingChangeResult; - narrowed: BreakingChangeResult; + request: BreakingChangeResult; + response: BreakingChangeResult; } { const results: { - expanded: BreakingChangeResult; - narrowed: BreakingChangeResult; + request: BreakingChangeResult; + response: BreakingChangeResult; } = { - expanded: { + request: { enum: false, typeChange: false, requiredChange: false, }, - narrowed: { + response: { enum: false, typeChange: false, requiredChange: false, @@ -39,14 +39,22 @@ export function computeTypeTransition( before.schema.type, after.schema.type ); - if (typeChange.expanded) results.expanded.typeChange = true; - if (typeChange.narrowed) results.narrowed.typeChange = true; + const beforeType = Array.isArray(before.schema.type) + ? `[${before.schema.type.join(',')}]` + : before.schema.type; + const afterType = Array.isArray(after.schema.type) + ? `[${after.schema.type.join(',')}]` + : after.schema.type; + if (typeChange.expanded) + results.request.typeChange = `type ${beforeType} was changed to ${afterType}`; + if (typeChange.narrowed) + results.response.typeChange = `type ${beforeType} was changed to ${afterType}`; // Check required changes if (before.required && !after.required) { - results.narrowed.requiredChange = true; + results.response.requiredChange = `was made optional`; } else if (!before.required && after.required) { - results.expanded.requiredChange = true; + results.request.requiredChange = `was made required`; } // Check enum change @@ -62,12 +70,20 @@ export function computeTypeTransition( : null; if (beforeEnum && afterEnum) { const enumResults = diffSets(new Set(beforeEnum), new Set(afterEnum)); - if (enumResults.expanded.length) results.expanded.enum = true; - if (enumResults.narrowed.length) results.narrowed.enum = true; + if (enumResults.beforeSetDiff.length) + results.request.enum = `enums ${enumResults.beforeSetDiff.join( + ',' + )} were added`; + if (enumResults.afterSetDiff.length) + results.response.enum = `enums ${enumResults.afterSetDiff.join( + ',' + )} were removed`; } else if (beforeEnum && !afterEnum) { - results.expanded.enum = true; + const keyword = 'const' in before.schema ? 'const' : 'enum'; + results.request.enum = `${keyword} keyword was removed`; } else if (!beforeEnum && afterEnum) { - results.narrowed.enum = true; + const keyword = 'const' in after.schema ? 'const' : 'enum'; + results.response.enum = `${keyword} keyword added`; } return results; @@ -84,21 +100,21 @@ export function computeEffectiveTypeChange( const after = typeToSet(afterType); const diff = diffSets(before, after); return { - narrowed: diff.narrowed.length > 0, - expanded: diff.expanded.length > 0, + narrowed: diff.beforeSetDiff.length > 0, + expanded: diff.afterSetDiff.length > 0, }; } export function diffSets( beforeSet: Set, afterSet: Set -): { expanded: string[]; narrowed: string[] } { - const narrowedSet = new Set([...beforeSet].filter((x) => !afterSet.has(x))); - const expandedSet = new Set([...afterSet].filter((x) => !beforeSet.has(x))); +): { beforeSetDiff: string[]; afterSetDiff: string[] } { + const beforeSetDiff = new Set([...beforeSet].filter((x) => !afterSet.has(x))); + const afterSetDiff = new Set([...afterSet].filter((x) => !beforeSet.has(x))); return { - expanded: [...expandedSet], - narrowed: [...narrowedSet], + afterSetDiff: [...afterSetDiff], + beforeSetDiff: [...beforeSetDiff], }; } diff --git a/projects/standard-rulesets/src/breaking-changes/helpers/unions.ts b/projects/standard-rulesets/src/breaking-changes/helpers/unions.ts index 0a4e28bf04..4ae84c5e55 100644 --- a/projects/standard-rulesets/src/breaking-changes/helpers/unions.ts +++ b/projects/standard-rulesets/src/breaking-changes/helpers/unions.ts @@ -3,16 +3,22 @@ import { FlatOpenAPIV3_1, OpenAPIV3, OpenAPIV3_1, + OpenApi3SchemaFact, } from '@useoptic/openapi-utilities'; import { computeTypeTransition } from './type-change'; +const SEPARATOR = '/'; + export function isInUnionProperty(jsonPath: string): boolean { const parts = jsonPointerHelpers.decode(jsonPath); return parts.some((p) => p === 'oneOf' || p === 'anyOf'); } export function schemaIsUnion( - schema?: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject + schema?: + | OpenAPIV3.SchemaObject + | OpenAPIV3.ReferenceObject + | OpenApi3SchemaFact ): schema is OpenAPIV3.SchemaObject { return !!(schema && !('$ref' in schema) && (schema.oneOf || schema.anyOf)); } @@ -30,6 +36,36 @@ type KeyNode = }; type KeyMap = Map; +type UnionDiffResult = { + request: boolean; + requestReasons: { key: string; reason: string }[]; + response: boolean; + responseReasons: { key: string; reason: string }[]; +}; + +function getDeepestDiffLevel(reasons: { key: string }[]): number { + let max = 0; + for (const { key } of reasons) { + const keyLength = key === SEPARATOR ? 0 : key.split(SEPARATOR).length; + max = Math.max(max, keyLength); + } + + return max; +} + +function compareReasons( + a: { key: string; reason: string }[], + b: { key: string; reason: string }[] +): number { + const aDiffLevel = getDeepestDiffLevel(a); + const bDiffLevel = getDeepestDiffLevel(b); + if (aDiffLevel === bDiffLevel) { + return a.length - b.length; + } else { + return bDiffLevel - aDiffLevel; + } +} + function traverseTypeArraySchemas( schema: FlatOpenAPIV3_1.SchemaObject ): KeyMap[] { @@ -54,6 +90,81 @@ function traverseTypeArraySchemas( : []; } +function areKeymapsResponseBreaking( + aKeymaps: KeyMap[], + bKeymaps: KeyMap[], + keyName: string, + keyword: string | null +) { + const responseResults = aKeymaps + .map((aKeymap, i) => { + const key = keyword + ? keyName === '/' + ? `${keyword}/${i}` + : `${keyword}/${keyName}/${i}` + : keyName; + const diffResults = bKeymaps.map((bKeymap) => + diffKeyMaps(aKeymap, bKeymap, key) + ); + const hasAnyValidTransition = diffResults.some((d) => !d.response); + // There could be multiple reasons a type does not overlap - we select the one that we think is the most relevant, + // in this case, this is a diff at the deepest level + return hasAnyValidTransition + ? null + : diffResults.sort((a, b) => + compareReasons(a.responseReasons, b.responseReasons) + )[0]; + }) + .filter((r) => r !== null) as UnionDiffResult[]; + const isResponse = responseResults.length !== 0; + return { + isResponse, + reasons: isResponse + ? responseResults.sort((a, b) => + compareReasons(a.responseReasons, b.responseReasons) + )[0].responseReasons + : [], + }; +} + +function areKeymapsRequestBreaking( + aKeymaps: KeyMap[], + bKeymaps: KeyMap[], + keyName: string, + keyword: string | null +) { + const requestResults = bKeymaps + .map((bKeymap, i) => { + const key = keyword + ? keyName === '/' + ? `${keyword}/${i}` + : `${keyword}/${keyName}/${i}` + : keyName; + const diffResults = aKeymaps.map((aKeymap) => + diffKeyMaps(aKeymap, bKeymap, key) + ); + const hasAnyValidTransition = diffResults.some((d) => !d.request); + + // There could be multiple reasons a type does not overlap - we select the one that we think is the most relevant, + // in this case, this is a diff at the deepest level + return hasAnyValidTransition + ? null + : diffResults.sort((a, b) => + compareReasons(a.requestReasons, b.requestReasons) + )[0]; + }) + .filter((r) => r !== null) as UnionDiffResult[]; + const isRequestBreaking = requestResults.length !== 0; + return { + isRequestBreaking, + reasons: isRequestBreaking + ? requestResults.sort((a, b) => + compareReasons(a.requestReasons, b.requestReasons) + )[0].requestReasons + : [], + }; +} + // Return an array of Maps that have different keys that they require; if there is a oneOf or anyOf // create a key with multiple sets function createKeyMapFromSchema(schema: FlatOpenAPIV3_1.SchemaObject): KeyMap { @@ -63,7 +174,7 @@ function createKeyMapFromSchema(schema: FlatOpenAPIV3_1.SchemaObject): KeyMap { if (schema.type === 'object') { if (schema.properties) { for (const [key, value] of Object.entries(schema.properties)) { - const fullKey = `${path}.${key}`; + const fullKey = path ? `${path}${SEPARATOR}${key}` : key; const required = schema.required ? schema.required.includes(key) : false; @@ -161,58 +272,153 @@ function createKeyMapFromSchema(schema: FlatOpenAPIV3_1.SchemaObject): KeyMap { return keyMap; } -function diffKeyMaps(aMap: KeyMap, bMap: KeyMap) { - const results = { - expanded: false, - narrowed: false, +function diffKeyMaps( + aMap: KeyMap, + bMap: KeyMap, + parentKey: string +): UnionDiffResult { + const results: UnionDiffResult = { + request: false, + requestReasons: [], + response: false, + responseReasons: [], }; for (const [key, aValue] of aMap) { const bValue = bMap.get(key); + const keyName = + key === '' + ? parentKey + : parentKey === '/' + ? `${parentKey}${key}` + : `${parentKey}${SEPARATOR}${key}`; + const prefix = keyName ? `${keyName}: ` : ''; if (bValue) { if (aValue.keyword === 'type' && bValue.keyword === 'type') { const typeTransition = computeTypeTransition(aValue, bValue); - // TODO add in reasons why something failed - if ( - typeTransition.expanded.enum || - typeTransition.expanded.requiredChange || - typeTransition.expanded.typeChange - ) { - results.expanded = true; + if (typeTransition.request.enum) { + results.request = true; + results.requestReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.request.enum}`, + }); } - if ( - typeTransition.narrowed.enum || - typeTransition.narrowed.requiredChange || - typeTransition.narrowed.typeChange - ) { - results.narrowed = true; + if (typeTransition.request.requiredChange) { + results.request = true; + results.requestReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.request.requiredChange}`, + }); } - } else if (aValue.keyword === 'type' || bValue.keyword === 'type') { - if (aValue.keyword === 'type' && bValue.keyword !== 'type') { - results.expanded = true; - } else { - results.narrowed = true; + if (typeTransition.request.typeChange) { + results.request = true; + results.requestReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.request.typeChange}`, + }); } - } else { - // A type is considered narrowed if any before item does not have an equivalent set with any item in the after set - const isNarrowed = !aValue.type.every((aKeyMap) => - bValue.type.some((bKeyMap) => !diffKeyMaps(aKeyMap, bKeyMap).narrowed) - ); - // A type is considered expanded if any after item does not have an equivalent set with any item in the before set - const isExpanded = !bValue.type.every((bKeyMap) => - aValue.type.some((aKeyMap) => !diffKeyMaps(aKeyMap, bKeyMap).expanded) - ); - if (isNarrowed) results.narrowed = true; - if (isExpanded) results.expanded = true; + if (typeTransition.response.enum) { + results.response = true; + results.responseReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.response.enum}`, + }); + } + if (typeTransition.response.requiredChange) { + results.response = true; + results.responseReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.response.requiredChange}`, + }); + } + if (typeTransition.response.typeChange) { + results.response = true; + results.responseReasons.push({ + key: keyName, + reason: `${prefix}${typeTransition.response.typeChange}`, + }); + } + } else { + const aKeymaps = + aValue.keyword === 'type' + ? [ + createKeyMapFromSchema( + aValue.schema as FlatOpenAPIV3_1.SchemaObject + ), + ] + : aValue.type; + const bKeymaps = + bValue.keyword === 'type' + ? [ + createKeyMapFromSchema( + bValue.schema as FlatOpenAPIV3_1.SchemaObject + ), + ] + : bValue.type; + const responseKeyword = + aValue.keyword === 'anyOf' || aValue.keyword === 'oneOf' + ? aValue.keyword + : null; + const { isResponse, reasons: responseReasons } = + areKeymapsResponseBreaking( + aKeymaps, + bKeymaps, + keyName, + responseKeyword + ); + if (isResponse) { + results.response = true; + results.responseReasons.push({ + key: keyName, + reason: `${prefix}${aValue.keyword}: ${responseReasons + .map((r) => r.reason) + .join(', ')}`, + }); + } + const requestKeyword = + bValue.keyword === 'anyOf' || bValue.keyword === 'oneOf' + ? bValue.keyword + : null; + const { isRequestBreaking, reasons: requestReasons } = + areKeymapsRequestBreaking( + aKeymaps, + bKeymaps, + keyName, + requestKeyword + ); + if (isRequestBreaking) { + results.request = true; + results.requestReasons.push({ + key: keyName, + reason: `${prefix}${aValue.keyword}: ${requestReasons + .map((r) => r.reason) + .join(', ')}`, + }); + } } } else if (aValue.required) { - results.narrowed = true; + results.response = true; + results.responseReasons.push({ + key: keyName, + reason: `${prefix}required property was removed`, + }); } } for (const [key, bValue] of bMap) { + const keyName = + key === '' + ? parentKey + : parentKey === '/' + ? `${parentKey}${key}` + : `${parentKey}${SEPARATOR}${key}`; + const prefix = keyName ? `${keyName}: ` : ''; if (!aMap.has(key) && bValue.required) { - results.expanded = true; + results.requestReasons.push({ + key: keyName, + reason: `${prefix}required property was added`, + }); + results.request = true; } } @@ -228,10 +434,13 @@ export function computeUnionTransition( | OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject | OpenAPIV3.ReferenceObject -): { - expanded: boolean; - narrowed: boolean; -} { +): UnionDiffResult { + const results: UnionDiffResult = { + response: false, + responseReasons: [], + request: false, + requestReasons: [], + }; const b = before as FlatOpenAPIV3_1.SchemaObject; const a = after as FlatOpenAPIV3_1.SchemaObject; @@ -242,6 +451,7 @@ export function computeUnionTransition( : Array.isArray(b.type) ? traverseTypeArraySchemas(b) : [createKeyMapFromSchema(b)]; + const beforeKeyword = b.oneOf ? 'oneOf' : b.anyOf ? 'anyOf' : null; const afterMaps = a.oneOf ? a.oneOf.map((s) => createKeyMapFromSchema(s)) : a.anyOf @@ -249,22 +459,30 @@ export function computeUnionTransition( : Array.isArray(a.type) ? traverseTypeArraySchemas(a) : [createKeyMapFromSchema(a)]; + const afterKeyword = a.oneOf ? 'oneOf' : a.anyOf ? 'anyOf' : null; - // A type is considered narrowed if any before item does not have an equivalent set with any item in the after set - const isNarrowed = !beforeMaps.every((beforeKeyMap) => - afterMaps.some( - (afterKeyMap) => !diffKeyMaps(beforeKeyMap, afterKeyMap).narrowed - ) - ); - // A type is considered expanded if any after item does not have an equivalent set with any item in the before set - const isExpanded = !afterMaps.every((afterKeyMap) => - beforeMaps.some( - (beforeKeyMap) => !diffKeyMaps(beforeKeyMap, afterKeyMap).expanded - ) + const { isResponse, reasons: responseReasons } = areKeymapsResponseBreaking( + beforeMaps, + afterMaps, + '/', + beforeKeyword ); + if (isResponse) { + results.response = true; + results.responseReasons.push({ + key: 'root', + reason: `${responseReasons.map((r) => r.reason).join(', ')}`, + }); + } + const { isRequestBreaking, reasons: expandedReasons } = + areKeymapsRequestBreaking(beforeMaps, afterMaps, '/', afterKeyword); + if (isRequestBreaking) { + results.request = true; + results.requestReasons.push({ + key: 'root', + reason: `${expandedReasons.map((r) => r.reason).join(', ')}`, + }); + } - return { - narrowed: isNarrowed, - expanded: isExpanded, - }; + return results; } diff --git a/projects/standard-rulesets/src/breaking-changes/preventEnumBreak.ts b/projects/standard-rulesets/src/breaking-changes/preventEnumBreak.ts index 12251ac025..cb6dff204b 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventEnumBreak.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventEnumBreak.ts @@ -6,7 +6,7 @@ import { import { getOperationAssertionsParameter } from './helpers/getOperationAssertionsParameter'; import { ParameterIn } from './helpers/types'; import { OpenAPIV3 } from 'openapi-types'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; import { diffSets } from './helpers/type-change'; const InfiniteSet = Symbol('infinite enum set'); @@ -72,11 +72,11 @@ const getPreventParameterEnumBreak =

(parameterIn: P) => isSchemaWithEnum(after.value?.schema)) && diffSets(new Set(beforeEnum), new Set(afterEnum)); - if (enumDiff && enumDiff.narrowed.length) { + if (enumDiff && enumDiff.beforeSetDiff.length) { throw new RuleError({ message: `cannot remove enum option${ - enumDiff.narrowed.length > 1 ? 's' : '' - } '${enumDiff.narrowed.join( + enumDiff.beforeSetDiff.length > 1 ? 's' : '' + } '${enumDiff.beforeSetDiff.join( ', ' )}' from ${parameterIn} parameter '${ after.value.name @@ -107,7 +107,9 @@ export const preventPropertyEnumBreak = () => { property.changed((before, after) => { if ( isInUnionProperty(before.location.jsonPath) || - isInUnionProperty(after.location.jsonPath) + schemaIsUnion(before.value.flatSchema) || + isInUnionProperty(after.location.jsonPath) || + schemaIsUnion(after.value.flatSchema) ) { return; } @@ -142,19 +144,19 @@ export const preventPropertyEnumBreak = () => { let beforeSet = new Set(beforeEnum); let afterSet = new Set(afterEnum); const results = diffSets(beforeSet, afterSet); - if (inRequest && results.narrowed.length) { + if (inRequest && results.beforeSetDiff.length) { throw new RuleError({ message: `cannot remove enum option${ - results.narrowed.length > 1 ? 's' : '' - } '${results.narrowed.join(', ')}' from '${ + results.beforeSetDiff.length > 1 ? 's' : '' + } '${results.beforeSetDiff.join(', ')}' from '${ after.value.key }' property. This is a breaking change.`, }); - } else if (!inRequest && results.expanded.length) { + } else if (!inRequest && results.afterSetDiff.length) { throw new RuleError({ message: `cannot add enum option${ - results.expanded.length > 1 ? 's' : '' - } '${results.expanded.join(', ')}' from '${ + results.afterSetDiff.length > 1 ? 's' : '' + } '${results.afterSetDiff.join(', ')}' from '${ after.value.key }' property. This is a breaking change.`, }); diff --git a/projects/standard-rulesets/src/breaking-changes/preventRequestExpandingWithUnionTypes.ts b/projects/standard-rulesets/src/breaking-changes/preventRequestExpandingWithUnionTypes.ts index 9351523609..d35512160c 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventRequestExpandingWithUnionTypes.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventRequestExpandingWithUnionTypes.ts @@ -11,9 +11,22 @@ export const preventRequestExpandingInUnionTypes = () => if (!beforeSchema || !afterSchema) return; if (schemaIsUnion(beforeSchema) || schemaIsUnion(afterSchema)) { const results = computeUnionTransition(beforeSchema, afterSchema); - if (results.expanded) { - // TODO add in the reason of where something was expanded - throw new RuleError({ message: 'cannot expand a request body' }); + if (results.request) { + const keyword = + 'oneOf' in beforeSchema || 'oneOf' in afterSchema + ? 'oneOf' + : 'anyOf'; + const prefix = + schemaIsUnion(beforeSchema) && schemaIsUnion(afterSchema) + ? `request body ${keyword} schema` + : schemaIsUnion(afterSchema) + ? `request body changed to ${keyword}` + : `request body changed from ${keyword}`; + throw new RuleError({ + message: `${prefix} did not overlap with the previous schema. ${results.requestReasons + .map((r) => r.reason) + .join(', ')}`, + }); } } }); @@ -24,9 +37,23 @@ export const preventRequestExpandingInUnionTypes = () => if (!beforeSchema || !afterSchema) return; if (schemaIsUnion(beforeSchema) || schemaIsUnion(afterSchema)) { const results = computeUnionTransition(beforeSchema, afterSchema); - if (results.expanded) { - // TODO add in the reason of where something was expanded - throw new RuleError({ message: 'cannot expand a request body' }); + if (results.request) { + const keyword = + 'oneOf' in beforeSchema || 'oneOf' in afterSchema + ? 'oneOf' + : 'anyOf'; + const prefix = + schemaIsUnion(beforeSchema) && schemaIsUnion(afterSchema) + ? `request property ${keyword} schema` + : schemaIsUnion(afterSchema) + ? `request property changed to ${keyword}` + : `request property changed from ${keyword}`; + + throw new RuleError({ + message: `${prefix} did not overlap with the previous schema. ${results.requestReasons + .map((r) => r.reason) + .join(', ')}`, + }); } } }); diff --git a/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyRequired.ts b/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyRequired.ts index 8d904ee7de..d5ee34adb9 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyRequired.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyRequired.ts @@ -1,5 +1,5 @@ import { RequestRule, RuleError } from '@useoptic/rulesets-base'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; export const preventRequestPropertyRequired = () => new RequestRule({ @@ -13,6 +13,7 @@ export const preventRequestPropertyRequired = () => if (ruleContext.operation.change === 'added') return; // rule doesn't apply for new operations // Children of union properties / transitions are handled in a separate rule if ( + schemaIsUnion(property.value.flatSchema) || isInUnionProperty(property.location.jsonPath) || beforePolymorphicSchemas.some((schemaPath) => property.location.jsonPath.startsWith(schemaPath) @@ -30,7 +31,9 @@ export const preventRequestPropertyRequired = () => requestAssertions.property.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( + schemaIsUnion(before.value.flatSchema) || isInUnionProperty(before.location.jsonPath) || + schemaIsUnion(after.value.flatSchema) || isInUnionProperty(after.location.jsonPath) ) { return; diff --git a/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyTypeChange.ts b/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyTypeChange.ts index 66e8049dbd..3c32185958 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyTypeChange.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventRequestPropertyTypeChange.ts @@ -1,6 +1,6 @@ import { RequestRule, RuleError } from '@useoptic/rulesets-base'; import { computeEffectiveTypeChange } from './helpers/type-change'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; export const preventRequestPropertyTypeChange = () => new RequestRule({ @@ -9,8 +9,8 @@ export const preventRequestPropertyTypeChange = () => requestAssertions.body.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( - isInUnionProperty(before.location.jsonPath) || - isInUnionProperty(after.location.jsonPath) + schemaIsUnion(before.value.flatSchema) || + schemaIsUnion(after.value.flatSchema) ) { return; } @@ -30,7 +30,9 @@ export const preventRequestPropertyTypeChange = () => requestAssertions.property.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( + schemaIsUnion(before.value.flatSchema) || isInUnionProperty(before.location.jsonPath) || + schemaIsUnion(after.value.flatSchema) || isInUnionProperty(after.location.jsonPath) ) { return; diff --git a/projects/standard-rulesets/src/breaking-changes/preventResponseNarrowingWithUnionType.ts b/projects/standard-rulesets/src/breaking-changes/preventResponseNarrowingWithUnionType.ts index a5d9dd796b..482863089c 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventResponseNarrowingWithUnionType.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventResponseNarrowingWithUnionType.ts @@ -11,9 +11,22 @@ export const preventResponseNarrowingInUnionTypes = () => if (!beforeSchema || !afterSchema) return; if (schemaIsUnion(beforeSchema) || schemaIsUnion(afterSchema)) { const results = computeUnionTransition(beforeSchema, afterSchema); - if (results.narrowed) { - // TODO add in the reason of where something was narrowed - throw new RuleError({ message: 'cannot narrow a response body' }); + if (results.response) { + const keyword = + 'oneOf' in beforeSchema || 'oneOf' in afterSchema + ? 'oneOf' + : 'anyOf'; + const prefix = + schemaIsUnion(beforeSchema) && schemaIsUnion(afterSchema) + ? `response body ${keyword} schema` + : schemaIsUnion(afterSchema) + ? `response body changed to ${keyword}` + : `response body changed from ${keyword}`; + throw new RuleError({ + message: `${prefix} did not overlap with the previous schema. ${results.responseReasons + .map((r) => r.reason) + .join(', ')}`, + }); } } }); @@ -24,9 +37,23 @@ export const preventResponseNarrowingInUnionTypes = () => if (!beforeSchema || !afterSchema) return; if (schemaIsUnion(beforeSchema) || schemaIsUnion(afterSchema)) { const results = computeUnionTransition(beforeSchema, afterSchema); - if (results.narrowed) { - // TODO add in the reason of where something was narrowed - throw new RuleError({ message: 'cannot narrow a response body' }); + if (results.response) { + const keyword = + 'oneOf' in beforeSchema || 'oneOf' in afterSchema + ? 'oneOf' + : 'anyOf'; + const prefix = + schemaIsUnion(beforeSchema) && schemaIsUnion(afterSchema) + ? `response property ${keyword} schema` + : schemaIsUnion(afterSchema) + ? `response property changed to ${keyword}` + : `response property changed from ${keyword}`; + + throw new RuleError({ + message: `${prefix} did not overlap with the previous schema. ${results.responseReasons + .map((r) => r.reason) + .join(', ')}`, + }); } } }); diff --git a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyOptional.ts b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyOptional.ts index 27960ab078..fea1ab8d46 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyOptional.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyOptional.ts @@ -1,5 +1,5 @@ import { ResponseBodyRule, RuleError } from '@useoptic/rulesets-base'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; export const preventResponsePropertyOptional = () => new ResponseBodyRule({ @@ -8,7 +8,9 @@ export const preventResponsePropertyOptional = () => responseAssertions.property.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( + schemaIsUnion(before.value.flatSchema) || isInUnionProperty(before.location.jsonPath) || + schemaIsUnion(after.value.flatSchema) || isInUnionProperty(after.location.jsonPath) ) { return; diff --git a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyRemoval.ts b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyRemoval.ts index bce46ce824..53f9db31a0 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyRemoval.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyRemoval.ts @@ -1,5 +1,5 @@ import { ResponseBodyRule, RuleError } from '@useoptic/rulesets-base'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; export const preventResponsePropertyRemoval = () => new ResponseBodyRule({ @@ -12,6 +12,7 @@ export const preventResponsePropertyRemoval = () => // Children of union properties / transitions are handled in a separate rule if ( isInUnionProperty(property.location.jsonPath) || + schemaIsUnion(property.value.flatSchema) || afterPolymorphicSchemas.some((schemaPath) => property.location.jsonPath.startsWith(schemaPath) ) diff --git a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyTypeChange.ts b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyTypeChange.ts index 3a50de4abd..0d56c38200 100644 --- a/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyTypeChange.ts +++ b/projects/standard-rulesets/src/breaking-changes/preventResponsePropertyTypeChange.ts @@ -1,6 +1,6 @@ import { ResponseBodyRule, RuleError } from '@useoptic/rulesets-base'; import { computeEffectiveTypeChange } from './helpers/type-change'; -import { isInUnionProperty } from './helpers/unions'; +import { isInUnionProperty, schemaIsUnion } from './helpers/unions'; export const preventResponsePropertyTypeChange = () => new ResponseBodyRule({ @@ -9,8 +9,8 @@ export const preventResponsePropertyTypeChange = () => responseAssertions.body.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( - isInUnionProperty(before.location.jsonPath) || - isInUnionProperty(after.location.jsonPath) + schemaIsUnion(before.value.flatSchema) || + schemaIsUnion(after.value.flatSchema) ) { return; } @@ -29,7 +29,9 @@ export const preventResponsePropertyTypeChange = () => responseAssertions.property.changed((before, after) => { // Children of union properties / transitions are handled in a separate rule if ( + schemaIsUnion(before.value.flatSchema) || isInUnionProperty(before.location.jsonPath) || + schemaIsUnion(after.value.flatSchema) || isInUnionProperty(after.location.jsonPath) ) { return;