From dd674805f2d6582032ce6ccf7d4cba5c3c956091 Mon Sep 17 00:00:00 2001 From: Benson Shen Date: Thu, 6 Feb 2025 14:24:59 -0500 Subject: [PATCH 1/2] feat: add detection for renamed endpoints - uses heuristic-based algorithm to determine if two endpoints are similar --- scripts/api-diff.js | 121 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/scripts/api-diff.js b/scripts/api-diff.js index cef61b6..02e554f 100644 --- a/scripts/api-diff.js +++ b/scripts/api-diff.js @@ -9,10 +9,102 @@ const changes = { added: {}, // Group by path removed: {}, // Group by path modified: {}, // Group by path + renamed: {}, // Track renamed endpoints components: new Set(), // Track changed components affectedByComponents: new Map() // Track path/method combinations affected by component changes }; +function checkSimilarity(endpoint1, endpoint2) { + // Required matches: HTTP method and operationId + if (endpoint1.method.toLowerCase() !== endpoint2.method.toLowerCase() || + !endpoint1.details.operationId || + !endpoint2.details.operationId || + endpoint1.details.operationId !== endpoint2.details.operationId) { + return false; + } + + let similarityScore = 0; + + // Similar response structure + if (JSON.stringify(endpoint1.details.responses) === JSON.stringify(endpoint2.details.responses)) { + similarityScore += 2; + } + + // Similar parameters + if (JSON.stringify(endpoint1.details.parameters) === JSON.stringify(endpoint2.details.parameters)) { + similarityScore += 2; + } + + // Similar request body + if (JSON.stringify(endpoint1.details.requestBody) === JSON.stringify(endpoint2.details.requestBody)) { + similarityScore += 2; + } + + // Similar summary/description if they exist + if (endpoint1.details.summary && endpoint2.details.summary && endpoint1.details.summary === endpoint2.details.summary) { + similarityScore += 1; + } + if (endpoint1.details.description && endpoint1.details.description && endpoint1.details.description === endpoint2.details.description) { + similarityScore += 1; + } + + return similarityScore >= 4; +} + +function detectRenamedEndpoints() { + const removedEndpoints = []; + const addedEndpoints = []; + + // Collect all removed endpoints + Object.entries(changes.removed).forEach(([path, methods]) => { + methods.forEach(method => { + removedEndpoints.push({ + path, + method, + details: previousSpec.paths[path][method.toLowerCase()] + }); + }); + }); + + // Collect all added endpoints + Object.entries(changes.added).forEach(([path, methods]) => { + methods.forEach(method => { + addedEndpoints.push({ + path, + method, + details: currentSpec.paths[path][method.toLowerCase()] + }); + }); + }); + + // Compare removed and added endpoints to find similarities + removedEndpoints.forEach(removedEndpoint => { + addedEndpoints.forEach(addedEndpoint => { + if (checkSimilarity(removedEndpoint, addedEndpoint)) { + // Remove from added and removed lists + changes.added[addedEndpoint.path].delete(addedEndpoint.method); + if (changes.added[addedEndpoint.path].size === 0) { + delete changes.added[addedEndpoint.path]; + } + + changes.removed[removedEndpoint.path].delete(removedEndpoint.method); + if (changes.removed[removedEndpoint.path].size === 0) { + delete changes.removed[removedEndpoint.path]; + } + + // Add to renamed list + if (!changes.renamed[removedEndpoint.path]) { + changes.renamed[removedEndpoint.path] = { + newPath: addedEndpoint.path, + methods: new Set() + }; + } + changes.renamed[removedEndpoint.path].methods.add(addedEndpoint.method); + } + }); + }); +} + // Helper function to track component references function findComponentRefs(obj, components, spec = currentSpec) { if (!obj) return; @@ -353,6 +445,34 @@ function generateReleaseNotes() { sections.push(section); } + + // Add renamed endpoints section + if (Object.keys(changes.renamed).length > 0) { + let section = '## Renamed\n'; + + // Group by old path and new path combination + const groupedRenames = {}; + Object.entries(changes.renamed).forEach(([oldPath, {newPath, methods}]) => { + const key = `${oldPath}→${newPath}`; + if (!groupedRenames[key]) { + groupedRenames[key] = { + oldPath, + newPath, + methods: new Set() + }; + } + methods.forEach(method => groupedRenames[key].methods.add(method)); + }); + + Object.values(groupedRenames) + .sort((a, b) => a.oldPath.localeCompare(b.oldPath)) + .forEach(({oldPath, newPath, methods}) => { + const methodsList = Array.from(methods).sort().join('] ['); + section += `- [${methodsList}] \`${oldPath}\` → \`${newPath}\`\n`; + }); + sections.push(section); + } + // Sort sections alphabetically and combine sections.sort((a, b) => { const titleA = a.split('\n')[0]; @@ -367,6 +487,7 @@ function generateReleaseNotes() { compareComponents(); findAffectedPaths(); comparePaths(); +detectRenamedEndpoints(); const releaseDescription = generateReleaseNotes(); // Write release notes to markdown file From 66dcf6d12311e600382eedc36ad17b2ea1c31d34 Mon Sep 17 00:00:00 2001 From: Benson Shen Date: Thu, 6 Feb 2025 14:26:14 -0500 Subject: [PATCH 2/2] test: add tests for renamed endpoint detection --- .../current.json | 76 +++++++++++++++++++ .../expected.md | 5 ++ .../previous.json | 76 +++++++++++++++++++ tests/fixtures/renamed-route/current.json | 76 +++++++++++++++++++ tests/fixtures/renamed-route/expected.md | 2 + tests/fixtures/renamed-route/previous.json | 76 +++++++++++++++++++ 6 files changed, 311 insertions(+) create mode 100644 tests/fixtures/renamed-route-new-operation-id/current.json create mode 100644 tests/fixtures/renamed-route-new-operation-id/expected.md create mode 100644 tests/fixtures/renamed-route-new-operation-id/previous.json create mode 100644 tests/fixtures/renamed-route/current.json create mode 100644 tests/fixtures/renamed-route/expected.md create mode 100644 tests/fixtures/renamed-route/previous.json diff --git a/tests/fixtures/renamed-route-new-operation-id/current.json b/tests/fixtures/renamed-route-new-operation-id/current.json new file mode 100644 index 0000000..54155d3 --- /dev/null +++ b/tests/fixtures/renamed-route-new-operation-id/current.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "description": "A sample API to for testing", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Creates a new user profile", + "operationId": "createUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/renamed-route-new-operation-id/expected.md b/tests/fixtures/renamed-route-new-operation-id/expected.md new file mode 100644 index 0000000..e42ccaa --- /dev/null +++ b/tests/fixtures/renamed-route-new-operation-id/expected.md @@ -0,0 +1,5 @@ +## Added +- [POST] `/user` + +## Removed +- [POST] `/users` diff --git a/tests/fixtures/renamed-route-new-operation-id/previous.json b/tests/fixtures/renamed-route-new-operation-id/previous.json new file mode 100644 index 0000000..d747312 --- /dev/null +++ b/tests/fixtures/renamed-route-new-operation-id/previous.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "description": "A sample API to for testing", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/users": { + "post": { + "summary": "Creates a new user profile", + "operationId": "createNewUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/renamed-route/current.json b/tests/fixtures/renamed-route/current.json new file mode 100644 index 0000000..54155d3 --- /dev/null +++ b/tests/fixtures/renamed-route/current.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "description": "A sample API to for testing", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/user": { + "post": { + "summary": "Creates a new user profile", + "operationId": "createUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +} diff --git a/tests/fixtures/renamed-route/expected.md b/tests/fixtures/renamed-route/expected.md new file mode 100644 index 0000000..ceca8cf --- /dev/null +++ b/tests/fixtures/renamed-route/expected.md @@ -0,0 +1,2 @@ +## Renamed +- [POST] `/users` → `/user` diff --git a/tests/fixtures/renamed-route/previous.json b/tests/fixtures/renamed-route/previous.json new file mode 100644 index 0000000..c6e8100 --- /dev/null +++ b/tests/fixtures/renamed-route/previous.json @@ -0,0 +1,76 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Test API", + "description": "A sample API to for testing", + "version": "1.0.0" + }, + "servers": [ + { + "url": "https://api.example.com/v1" + } + ], + "paths": { + "/users": { + "post": { + "summary": "Creates a new user profile", + "operationId": "createUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + } + }, + "responses": { + "201": { + "description": "User is created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "NewUser": { + "type": "object", + "required": [ + "name", + "email" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + } + } +}