Skip to content

Commit 6517e77

Browse files
committed
feat: add semantic api diff reporting
1 parent 5772298 commit 6517e77

1 file changed

Lines changed: 224 additions & 0 deletions

File tree

src/semantic-diff.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { mkdir, readFile, writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import type {
4+
ApiParameterSpec,
5+
ApiRouteSpec,
6+
ApiSpec,
7+
HttpMethod,
8+
ParameterLocation,
9+
ValueType,
10+
} from "./types.js";
11+
12+
interface DiffSummary {
13+
readonly changed: boolean;
14+
readonly addedRoutes: readonly string[];
15+
readonly removedRoutes: readonly string[];
16+
readonly changedRoutes: readonly RouteChange[];
17+
}
18+
19+
interface RouteChange {
20+
readonly routeKey: string;
21+
readonly addedParams: readonly string[];
22+
readonly removedParams: readonly string[];
23+
readonly changedParamTypes: readonly ParamTypeChange[];
24+
}
25+
26+
interface ParamTypeChange {
27+
readonly parameterKey: string;
28+
readonly previousType: ValueType | undefined;
29+
readonly nextType: ValueType | undefined;
30+
}
31+
32+
interface ComparableRoute {
33+
readonly routeKey: string;
34+
readonly params: ReadonlyMap<string, ValueType | undefined>;
35+
}
36+
37+
export async function runSemanticDiff(options: {
38+
readonly previousPath: string;
39+
readonly nextPath: string;
40+
readonly jsonOutputPath: string | undefined;
41+
readonly markdownOutputPath: string | undefined;
42+
}): Promise<DiffSummary> {
43+
const previous = await readSpec(options.previousPath);
44+
const next = await readSpec(options.nextPath);
45+
const summary = diffSpecs(previous, next);
46+
47+
if (options.jsonOutputPath !== undefined) {
48+
await writeTextFile(options.jsonOutputPath, `${JSON.stringify(summary, null, 2)}\n`);
49+
}
50+
if (options.markdownOutputPath !== undefined) {
51+
await writeTextFile(options.markdownOutputPath, renderMarkdown(summary));
52+
}
53+
54+
return summary;
55+
}
56+
57+
async function readSpec(filePath: string): Promise<ApiSpec> {
58+
const sourceText = await readFile(filePath, "utf8");
59+
return JSON.parse(sourceText) as ApiSpec;
60+
}
61+
62+
function diffSpecs(previous: ApiSpec, next: ApiSpec): DiffSummary {
63+
const previousRoutes = indexRoutes(previous.routes);
64+
const nextRoutes = indexRoutes(next.routes);
65+
66+
const addedRoutes = sortedKeysDifference(nextRoutes, previousRoutes);
67+
const removedRoutes = sortedKeysDifference(previousRoutes, nextRoutes);
68+
69+
const changedRoutes = [...previousRoutes.keys()]
70+
.filter((routeKey) => nextRoutes.has(routeKey))
71+
.map((routeKey) => diffRoute(previousRoutes.get(routeKey), nextRoutes.get(routeKey)))
72+
.filter((route): route is RouteChange => route !== undefined)
73+
.sort((left, right) => left.routeKey.localeCompare(right.routeKey));
74+
75+
return {
76+
changed:
77+
addedRoutes.length > 0 ||
78+
removedRoutes.length > 0 ||
79+
changedRoutes.length > 0,
80+
addedRoutes,
81+
removedRoutes,
82+
changedRoutes,
83+
};
84+
}
85+
86+
function indexRoutes(routes: readonly ApiRouteSpec[]): ReadonlyMap<string, ComparableRoute> {
87+
return new Map(
88+
routes.map((route) => {
89+
const routeKey = buildRouteKey(route.method, route.pathTemplate);
90+
return [
91+
routeKey,
92+
{
93+
routeKey,
94+
params: new Map([
95+
...route.pathParams.map((parameter) => [buildParameterKey("path", parameter), parameter.valueType] as const),
96+
...route.queryParams.map((parameter) => [buildParameterKey("query", parameter), parameter.valueType] as const),
97+
...route.bodyParams.map((parameter) => [buildParameterKey("body", parameter), parameter.valueType] as const),
98+
]),
99+
},
100+
] as const;
101+
}),
102+
);
103+
}
104+
105+
function diffRoute(
106+
previous: ComparableRoute | undefined,
107+
next: ComparableRoute | undefined,
108+
): RouteChange | undefined {
109+
if (previous === undefined || next === undefined) {
110+
return undefined;
111+
}
112+
113+
const addedParams = sortedKeysDifference(next.params, previous.params);
114+
const removedParams = sortedKeysDifference(previous.params, next.params);
115+
const changedParamTypes = [...previous.params.keys()]
116+
.filter((parameterKey) => next.params.has(parameterKey))
117+
.map((parameterKey) => {
118+
const previousType = previous.params.get(parameterKey);
119+
const nextType = next.params.get(parameterKey);
120+
if (previousType === nextType) {
121+
return undefined;
122+
}
123+
return {
124+
parameterKey,
125+
previousType,
126+
nextType,
127+
} satisfies ParamTypeChange;
128+
})
129+
.filter((change): change is ParamTypeChange => change !== undefined)
130+
.sort((left, right) => left.parameterKey.localeCompare(right.parameterKey));
131+
132+
if (
133+
addedParams.length === 0 &&
134+
removedParams.length === 0 &&
135+
changedParamTypes.length === 0
136+
) {
137+
return undefined;
138+
}
139+
140+
return {
141+
routeKey: previous.routeKey,
142+
addedParams,
143+
removedParams,
144+
changedParamTypes,
145+
};
146+
}
147+
148+
function sortedKeysDifference<T>(
149+
left: ReadonlyMap<string, T>,
150+
right: ReadonlyMap<string, T>,
151+
): readonly string[] {
152+
return [...left.keys()]
153+
.filter((key) => !right.has(key))
154+
.sort((a, b) => a.localeCompare(b));
155+
}
156+
157+
function buildRouteKey(method: HttpMethod, pathTemplate: string): string {
158+
return `${method} ${pathTemplate}`;
159+
}
160+
161+
function buildParameterKey(location: ParameterLocation, parameter: ApiParameterSpec): string {
162+
return `${location}:${parameter.canonicalPath}`;
163+
}
164+
165+
function renderMarkdown(summary: DiffSummary): string {
166+
if (!summary.changed) {
167+
return [
168+
"# Typesense API extraction changed",
169+
"",
170+
"The committed extraction diff does not include any API-shape changes that require `openapi.yml` updates.",
171+
"",
172+
].join("\n");
173+
}
174+
175+
const lines = [
176+
"# Update `openapi.yml` for extracted API changes",
177+
"",
178+
"The latest committed extractor output includes API-shape changes compared with the previous committed JSON.",
179+
"",
180+
];
181+
182+
if (summary.addedRoutes.length > 0) {
183+
lines.push("## Added routes", "");
184+
lines.push(...summary.addedRoutes.map((routeKey) => `- ${routeKey}`), "");
185+
}
186+
187+
if (summary.removedRoutes.length > 0) {
188+
lines.push("## Removed routes", "");
189+
lines.push(...summary.removedRoutes.map((routeKey) => `- ${routeKey}`), "");
190+
}
191+
192+
if (summary.changedRoutes.length > 0) {
193+
lines.push("## Changed routes", "");
194+
summary.changedRoutes.forEach((route) => {
195+
lines.push(`### ${route.routeKey}`, "");
196+
if (route.addedParams.length > 0) {
197+
lines.push(...route.addedParams.map((parameter) => `- Added parameter: ${parameter}`));
198+
}
199+
if (route.removedParams.length > 0) {
200+
lines.push(...route.removedParams.map((parameter) => `- Removed parameter: ${parameter}`));
201+
}
202+
if (route.changedParamTypes.length > 0) {
203+
lines.push(
204+
...route.changedParamTypes.map(
205+
(change) =>
206+
`- Changed parameter type: ${change.parameterKey} (${formatType(change.previousType)} -> ${formatType(change.nextType)})`,
207+
),
208+
);
209+
}
210+
lines.push("");
211+
});
212+
}
213+
214+
return `${lines.join("\n").trimEnd()}\n`;
215+
}
216+
217+
function formatType(valueType: ValueType | undefined): string {
218+
return valueType ?? "undefined";
219+
}
220+
221+
async function writeTextFile(filePath: string, contents: string): Promise<void> {
222+
await mkdir(path.dirname(filePath), { recursive: true });
223+
await writeFile(filePath, contents, "utf8");
224+
}

0 commit comments

Comments
 (0)