@arpixel/api-contract 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +699 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +56 -0
- package/dist/commands/diff.d.ts +1 -0
- package/dist/commands/diff.js +15 -0
- package/dist/commands/generate.d.ts +1 -0
- package/dist/commands/generate.js +19 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +33 -0
- package/dist/diff/comparator.d.ts +3 -0
- package/dist/diff/comparator.js +416 -0
- package/dist/diff/reporter.d.ts +2 -0
- package/dist/diff/reporter.js +32 -0
- package/dist/generator/api.d.ts +1 -0
- package/dist/generator/api.js +106 -0
- package/dist/generator/typescript.d.ts +1 -0
- package/dist/generator/typescript.js +14 -0
- package/dist/openapi/loader.d.ts +2 -0
- package/dist/openapi/loader.js +4 -0
- package/dist/types/diff.d.ts +161 -0
- package/dist/types/diff.js +1 -0
- package/integrations/vscode/api-contract-generate.agent.md +161 -0
- package/integrations/vscode/api-contract-generate.prompt.md +172 -0
- package/integrations/vscode/api-contract-impact.agent.md +193 -0
- package/integrations/vscode/api-contract-impact.prompt.md +285 -0
- package/package.json +44 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { generateCommand } from "./commands/generate.js";
|
|
4
|
+
import { diffCommand } from "./commands/diff.js";
|
|
5
|
+
import { initCommand } from "./commands/init.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("api-contract")
|
|
9
|
+
.description("Understand OpenAPI contract changes and generate frontend-ready TypeScript.")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
program
|
|
12
|
+
.command("generate")
|
|
13
|
+
.description("Generate TypeScript types and API method signatures from an OpenAPI file.")
|
|
14
|
+
.argument("<openapi-file>", "Path to OpenAPI YAML or JSON file")
|
|
15
|
+
.option("-o, --output <directory>", "Output directory")
|
|
16
|
+
.action(async (file, options) => {
|
|
17
|
+
try {
|
|
18
|
+
await generateCommand(file, options.output);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
22
|
+
process.exitCode = 1;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
program
|
|
26
|
+
.command("diff")
|
|
27
|
+
.description("Compare two OpenAPI contracts.")
|
|
28
|
+
.argument("<old-file>", "Previous OpenAPI YAML or JSON file")
|
|
29
|
+
.argument("<new-file>", "New OpenAPI YAML or JSON file")
|
|
30
|
+
.option("-f, --format <format>", "Output format: text or json", "text")
|
|
31
|
+
.action(async (oldFile, newFile, options) => {
|
|
32
|
+
try {
|
|
33
|
+
if (options.format !== "text" && options.format !== "json") {
|
|
34
|
+
throw new Error(`Unsupported format "${options.format}". Use "text" or "json".`);
|
|
35
|
+
}
|
|
36
|
+
await diffCommand(oldFile, newFile, options.format);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
program
|
|
44
|
+
.command("init")
|
|
45
|
+
.description("Install optional API Contract integrations.")
|
|
46
|
+
.option("--host <host>", "Integration host")
|
|
47
|
+
.action(async (options) => {
|
|
48
|
+
try {
|
|
49
|
+
await initCommand(options.host);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
53
|
+
process.exitCode = 1;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
await program.parseAsync();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function diffCommand(oldPath: string, newPath: string, format: "text" | "json"): Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { loadOpenApi } from "../openapi/loader.js";
|
|
2
|
+
import { compareContracts } from "../diff/comparator.js";
|
|
3
|
+
import { formatDiff } from "../diff/reporter.js";
|
|
4
|
+
export async function diffCommand(oldPath, newPath, format) {
|
|
5
|
+
const [oldDocument, newDocument] = await Promise.all([
|
|
6
|
+
loadOpenApi(oldPath),
|
|
7
|
+
loadOpenApi(newPath)
|
|
8
|
+
]);
|
|
9
|
+
const result = compareContracts(oldDocument, newDocument);
|
|
10
|
+
if (format === "json") {
|
|
11
|
+
console.log(JSON.stringify(result, null, 2));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
console.log(formatDiff(result));
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateCommand(inputPath: string, outputDir?: string): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { generateApi } from "../generator/api.js";
|
|
4
|
+
import { generateTypes } from "../generator/typescript.js";
|
|
5
|
+
export async function generateCommand(inputPath, outputDir) {
|
|
6
|
+
const input = resolve(inputPath);
|
|
7
|
+
const output = outputDir
|
|
8
|
+
? resolve(outputDir)
|
|
9
|
+
: resolve(process.cwd(), "api-contract/generated");
|
|
10
|
+
await mkdir(output, { recursive: true });
|
|
11
|
+
const typesPath = `${output}/types.ts`;
|
|
12
|
+
const apiPath = `${output}/api.ts`;
|
|
13
|
+
await generateTypes(input, typesPath);
|
|
14
|
+
await generateApi(input, apiPath);
|
|
15
|
+
console.log("Generated TypeScript API contract.");
|
|
16
|
+
console.log("\nOutput:");
|
|
17
|
+
console.log(` ${typesPath}`);
|
|
18
|
+
console.log(` ${apiPath}`);
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function initCommand(host: string | undefined, homeDirectory?: string): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { copyFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const integrationDirectory = join(dirname(fileURLToPath(import.meta.url)), "../../integrations/vscode");
|
|
6
|
+
const promptFiles = [
|
|
7
|
+
"api-contract-generate.prompt.md",
|
|
8
|
+
"api-contract-impact.prompt.md"
|
|
9
|
+
];
|
|
10
|
+
const agentFiles = [
|
|
11
|
+
"api-contract-generate.agent.md",
|
|
12
|
+
"api-contract-impact.agent.md"
|
|
13
|
+
];
|
|
14
|
+
export async function initCommand(host, homeDirectory = homedir()) {
|
|
15
|
+
if (!host) {
|
|
16
|
+
throw new Error('A host is required. Supported hosts: "vscode".');
|
|
17
|
+
}
|
|
18
|
+
if (host !== "vscode") {
|
|
19
|
+
throw new Error(`Unsupported host "${host}". Supported hosts: "vscode".`);
|
|
20
|
+
}
|
|
21
|
+
const promptDirectory = join(homeDirectory, ".api-contract", "prompts");
|
|
22
|
+
const agentDirectory = join(homeDirectory, ".copilot", "agents");
|
|
23
|
+
await mkdir(promptDirectory, { recursive: true });
|
|
24
|
+
await mkdir(agentDirectory, { recursive: true });
|
|
25
|
+
await Promise.all([
|
|
26
|
+
...promptFiles.map(file => copyFile(join(integrationDirectory, file), join(promptDirectory, file))),
|
|
27
|
+
...agentFiles.map(file => copyFile(join(integrationDirectory, file), join(agentDirectory, file)))
|
|
28
|
+
]);
|
|
29
|
+
console.log("API Contract VS Code integration installed.");
|
|
30
|
+
console.log(`\nPrompts:\n ~/.api-contract/prompts/${promptFiles.join("\n ~/.api-contract/prompts/")}`);
|
|
31
|
+
console.log(`\nAgents:\n ~/.copilot/agents/${agentFiles.join("\n ~/.copilot/agents/")}`);
|
|
32
|
+
console.log(`\nPrompt usage options:\n\n1. Repository-local\n Copy prompts from:\n ~/.api-contract/prompts/\n to:\n .github/prompts/\n\n2. Global\n Add this directory to VS Code's chat.promptFilesLocations:\n ~/.api-contract/prompts\n\nThe API Contract agents are installed globally and can be selected from VS Code Chat.`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
const METHODS = ["get", "post", "put", "patch", "delete", "head", "options"];
|
|
2
|
+
function asRecord(value) {
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function asStringArray(value) {
|
|
9
|
+
if (!Array.isArray(value)) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
return value.filter((item) => typeof item === "string");
|
|
13
|
+
}
|
|
14
|
+
function schemaType(schema) {
|
|
15
|
+
const value = asRecord(schema);
|
|
16
|
+
if (!value)
|
|
17
|
+
return "unknown";
|
|
18
|
+
if (typeof value.$ref === "string")
|
|
19
|
+
return value.$ref;
|
|
20
|
+
if (value.type === "array")
|
|
21
|
+
return `array<${schemaType(value.items)}>`;
|
|
22
|
+
if (typeof value.type === "string")
|
|
23
|
+
return String(value.type);
|
|
24
|
+
if (Array.isArray(value.oneOf))
|
|
25
|
+
return `oneOf(${value.oneOf.map(item => schemaType(item)).join("|")})`;
|
|
26
|
+
if (Array.isArray(value.allOf))
|
|
27
|
+
return `allOf(${value.allOf.map(item => schemaType(item)).join("&")})`;
|
|
28
|
+
return "object";
|
|
29
|
+
}
|
|
30
|
+
function schemaLocation(name) {
|
|
31
|
+
return `components.schemas.${name}`;
|
|
32
|
+
}
|
|
33
|
+
function propertyLocation(name, property) {
|
|
34
|
+
return `${schemaLocation(name)}.properties.${property}`;
|
|
35
|
+
}
|
|
36
|
+
function operationLocation(path, method) {
|
|
37
|
+
return `paths.${path}.${method}`;
|
|
38
|
+
}
|
|
39
|
+
function changeLocation(path, method, suffix) {
|
|
40
|
+
return `${operationLocation(path, method)}.${suffix}`;
|
|
41
|
+
}
|
|
42
|
+
function compareOperationDetails(path, method, oldOperation, newOperation, changes) {
|
|
43
|
+
const oldParameters = Array.isArray(oldOperation.parameters)
|
|
44
|
+
? oldOperation.parameters
|
|
45
|
+
.map(item => asRecord(item))
|
|
46
|
+
.filter((item) => Boolean(item))
|
|
47
|
+
: [];
|
|
48
|
+
const newParameters = Array.isArray(newOperation.parameters)
|
|
49
|
+
? newOperation.parameters
|
|
50
|
+
.map(item => asRecord(item))
|
|
51
|
+
.filter((item) => Boolean(item))
|
|
52
|
+
: [];
|
|
53
|
+
const parameterKey = (parameter) => `${String(parameter.in ?? "unknown")}.${String(parameter.name ?? "unknown")}`;
|
|
54
|
+
const oldParameterMap = new Map();
|
|
55
|
+
for (const parameter of oldParameters) {
|
|
56
|
+
oldParameterMap.set(parameterKey(parameter), parameter);
|
|
57
|
+
}
|
|
58
|
+
const newParameterMap = new Map();
|
|
59
|
+
for (const parameter of newParameters) {
|
|
60
|
+
newParameterMap.set(parameterKey(parameter), parameter);
|
|
61
|
+
}
|
|
62
|
+
for (const key of new Set([...oldParameterMap.keys(), ...newParameterMap.keys()])) {
|
|
63
|
+
const oldParameter = oldParameterMap.get(key);
|
|
64
|
+
const newParameter = newParameterMap.get(key);
|
|
65
|
+
const location = changeLocation(path, method, `parameters.${key}`);
|
|
66
|
+
const parameterName = typeof newParameter?.name === "string"
|
|
67
|
+
? newParameter.name
|
|
68
|
+
: typeof oldParameter?.name === "string"
|
|
69
|
+
? oldParameter.name
|
|
70
|
+
: key;
|
|
71
|
+
if (!oldParameter) {
|
|
72
|
+
const required = Boolean(newParameter?.required);
|
|
73
|
+
changes.push({
|
|
74
|
+
type: "PARAMETER_ADDED",
|
|
75
|
+
path,
|
|
76
|
+
method: method.toUpperCase(),
|
|
77
|
+
parameter: parameterName,
|
|
78
|
+
location,
|
|
79
|
+
to: { required },
|
|
80
|
+
detail: `${method.toUpperCase()} ${path} parameter ${parameterName} was added${required ? " as required" : ""}.`,
|
|
81
|
+
breaking: required
|
|
82
|
+
});
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!newParameter) {
|
|
86
|
+
changes.push({
|
|
87
|
+
type: "PARAMETER_REMOVED",
|
|
88
|
+
path,
|
|
89
|
+
method: method.toUpperCase(),
|
|
90
|
+
parameter: parameterName,
|
|
91
|
+
location,
|
|
92
|
+
from: { required: Boolean(oldParameter.required) },
|
|
93
|
+
detail: `${method.toUpperCase()} ${path} parameter ${parameterName} was removed.`,
|
|
94
|
+
breaking: true
|
|
95
|
+
});
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const oldType = schemaType(oldParameter.schema);
|
|
99
|
+
const newType = schemaType(newParameter.schema);
|
|
100
|
+
if (oldType !== newType) {
|
|
101
|
+
changes.push({
|
|
102
|
+
type: "PARAMETER_TYPE_CHANGED",
|
|
103
|
+
path,
|
|
104
|
+
method: method.toUpperCase(),
|
|
105
|
+
parameter: parameterName,
|
|
106
|
+
location,
|
|
107
|
+
from: oldType,
|
|
108
|
+
to: newType,
|
|
109
|
+
detail: `${method.toUpperCase()} ${path} parameter ${parameterName} type changed from ${oldType} to ${newType}.`,
|
|
110
|
+
breaking: true
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (Boolean(oldParameter.required) !== Boolean(newParameter.required)) {
|
|
114
|
+
const from = Boolean(oldParameter.required) ? "required" : "optional";
|
|
115
|
+
const to = Boolean(newParameter.required) ? "required" : "optional";
|
|
116
|
+
changes.push({
|
|
117
|
+
type: "PARAMETER_REQUIRED_CHANGED",
|
|
118
|
+
path,
|
|
119
|
+
method: method.toUpperCase(),
|
|
120
|
+
parameter: parameterName,
|
|
121
|
+
location,
|
|
122
|
+
from,
|
|
123
|
+
to,
|
|
124
|
+
detail: `${method.toUpperCase()} ${path} parameter ${parameterName} changed from ${from} to ${to}.`,
|
|
125
|
+
breaking: !Boolean(oldParameter.required) && Boolean(newParameter.required)
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const oldResponses = asRecord(oldOperation.responses) ?? {};
|
|
130
|
+
const newResponses = asRecord(newOperation.responses) ?? {};
|
|
131
|
+
for (const status of new Set([...Object.keys(oldResponses), ...Object.keys(newResponses)])) {
|
|
132
|
+
const oldResponse = asRecord(oldResponses[status]);
|
|
133
|
+
const newResponse = asRecord(newResponses[status]);
|
|
134
|
+
const location = changeLocation(path, method, `responses.${status}`);
|
|
135
|
+
const oldResponseContent = asRecord(oldResponse?.content);
|
|
136
|
+
const newResponseContent = asRecord(newResponse?.content);
|
|
137
|
+
const oldResponseJson = asRecord(oldResponseContent?.["application/json"]);
|
|
138
|
+
const newResponseJson = asRecord(newResponseContent?.["application/json"]);
|
|
139
|
+
const oldResponseType = schemaType(oldResponseJson?.schema);
|
|
140
|
+
const newResponseType = schemaType(newResponseJson?.schema);
|
|
141
|
+
if (!oldResponse) {
|
|
142
|
+
changes.push({
|
|
143
|
+
type: "RESPONSE_ADDED",
|
|
144
|
+
path,
|
|
145
|
+
method: method.toUpperCase(),
|
|
146
|
+
status,
|
|
147
|
+
location,
|
|
148
|
+
to: newResponseType,
|
|
149
|
+
detail: `${method.toUpperCase()} ${path} response ${status} was added.`,
|
|
150
|
+
breaking: false
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (!newResponse) {
|
|
154
|
+
changes.push({
|
|
155
|
+
type: "RESPONSE_REMOVED",
|
|
156
|
+
path,
|
|
157
|
+
method: method.toUpperCase(),
|
|
158
|
+
status,
|
|
159
|
+
location,
|
|
160
|
+
from: oldResponseType,
|
|
161
|
+
detail: `${method.toUpperCase()} ${path} response ${status} was removed.`,
|
|
162
|
+
breaking: true
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
else if (oldResponseType !== newResponseType) {
|
|
166
|
+
changes.push({
|
|
167
|
+
type: "RESPONSE_CHANGED",
|
|
168
|
+
path,
|
|
169
|
+
method: method.toUpperCase(),
|
|
170
|
+
status,
|
|
171
|
+
location,
|
|
172
|
+
from: oldResponseType,
|
|
173
|
+
to: newResponseType,
|
|
174
|
+
detail: `${method.toUpperCase()} ${path} response ${status} changed from ${oldResponseType} to ${newResponseType}.`,
|
|
175
|
+
breaking: true
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const oldRequestBody = asRecord(oldOperation.requestBody);
|
|
180
|
+
const newRequestBody = asRecord(newOperation.requestBody);
|
|
181
|
+
const requestBodyLocation = changeLocation(path, method, "requestBody");
|
|
182
|
+
const oldRequestBodyContent = asRecord(oldRequestBody?.content);
|
|
183
|
+
const newRequestBodyContent = asRecord(newRequestBody?.content);
|
|
184
|
+
const oldRequestBodyJson = asRecord(oldRequestBodyContent?.["application/json"]);
|
|
185
|
+
const newRequestBodyJson = asRecord(newRequestBodyContent?.["application/json"]);
|
|
186
|
+
const oldRequestBodyType = schemaType(oldRequestBodyJson?.schema);
|
|
187
|
+
const newRequestBodyType = schemaType(newRequestBodyJson?.schema);
|
|
188
|
+
if (!oldRequestBody && newRequestBody) {
|
|
189
|
+
changes.push({
|
|
190
|
+
type: "REQUEST_BODY_ADDED",
|
|
191
|
+
path,
|
|
192
|
+
method: method.toUpperCase(),
|
|
193
|
+
location: requestBodyLocation,
|
|
194
|
+
to: newRequestBodyType,
|
|
195
|
+
detail: `${method.toUpperCase()} ${path} request body was added.`,
|
|
196
|
+
breaking: Boolean(newRequestBody.required)
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (oldRequestBody && !newRequestBody) {
|
|
200
|
+
changes.push({
|
|
201
|
+
type: "REQUEST_BODY_REMOVED",
|
|
202
|
+
path,
|
|
203
|
+
method: method.toUpperCase(),
|
|
204
|
+
location: requestBodyLocation,
|
|
205
|
+
from: oldRequestBodyType,
|
|
206
|
+
detail: `${method.toUpperCase()} ${path} request body was removed.`,
|
|
207
|
+
breaking: true
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else if (oldRequestBody && newRequestBody && oldRequestBodyType !== newRequestBodyType) {
|
|
211
|
+
changes.push({
|
|
212
|
+
type: "REQUEST_BODY_CHANGED",
|
|
213
|
+
path,
|
|
214
|
+
method: method.toUpperCase(),
|
|
215
|
+
location: requestBodyLocation,
|
|
216
|
+
from: oldRequestBodyType,
|
|
217
|
+
to: newRequestBodyType,
|
|
218
|
+
detail: `${method.toUpperCase()} ${path} request body changed from ${oldRequestBodyType} to ${newRequestBodyType}.`,
|
|
219
|
+
breaking: true
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function compareSchemas(oldSchemas, newSchemas, changes) {
|
|
224
|
+
const names = new Set([...Object.keys(oldSchemas), ...Object.keys(newSchemas)]);
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const oldSchema = oldSchemas[name];
|
|
227
|
+
const newSchema = newSchemas[name];
|
|
228
|
+
if (!oldSchema) {
|
|
229
|
+
changes.push({
|
|
230
|
+
type: "SCHEMA_ADDED",
|
|
231
|
+
schema: name,
|
|
232
|
+
location: schemaLocation(name),
|
|
233
|
+
detail: `Schema ${name} was added.`,
|
|
234
|
+
breaking: false
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!newSchema) {
|
|
239
|
+
changes.push({
|
|
240
|
+
type: "SCHEMA_REMOVED",
|
|
241
|
+
schema: name,
|
|
242
|
+
location: schemaLocation(name),
|
|
243
|
+
detail: `Schema ${name} was removed.`,
|
|
244
|
+
breaking: true
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const oldProps = asRecord(oldSchema.properties) ?? {};
|
|
249
|
+
const newProps = asRecord(newSchema.properties) ?? {};
|
|
250
|
+
const oldRequired = new Set(asStringArray(oldSchema.required));
|
|
251
|
+
const newRequired = new Set(asStringArray(newSchema.required));
|
|
252
|
+
const props = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]);
|
|
253
|
+
for (const property of props) {
|
|
254
|
+
const oldProp = oldProps[property];
|
|
255
|
+
const newProp = newProps[property];
|
|
256
|
+
if (!oldProp) {
|
|
257
|
+
const required = newRequired.has(property);
|
|
258
|
+
changes.push({
|
|
259
|
+
type: "PROPERTY_ADDED",
|
|
260
|
+
schema: name,
|
|
261
|
+
property,
|
|
262
|
+
location: propertyLocation(name, property),
|
|
263
|
+
required,
|
|
264
|
+
detail: `${name}.${property} was added${required ? " as required" : ""}.`,
|
|
265
|
+
breaking: required
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (!newProp) {
|
|
270
|
+
changes.push({
|
|
271
|
+
type: "PROPERTY_REMOVED",
|
|
272
|
+
schema: name,
|
|
273
|
+
property,
|
|
274
|
+
location: propertyLocation(name, property),
|
|
275
|
+
from: oldRequired.has(property) ? "required" : "optional",
|
|
276
|
+
detail: `${name}.${property} was removed.`,
|
|
277
|
+
breaking: true
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const oldType = schemaType(oldProp);
|
|
282
|
+
const newType = schemaType(newProp);
|
|
283
|
+
if (oldType !== newType) {
|
|
284
|
+
changes.push({
|
|
285
|
+
type: "PROPERTY_TYPE_CHANGED",
|
|
286
|
+
schema: name,
|
|
287
|
+
property,
|
|
288
|
+
location: propertyLocation(name, property),
|
|
289
|
+
from: oldType,
|
|
290
|
+
to: newType,
|
|
291
|
+
detail: `${name}.${property}: ${oldType} → ${newType}.`,
|
|
292
|
+
breaking: true
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
if (!oldRequired.has(property) && newRequired.has(property)) {
|
|
296
|
+
changes.push({
|
|
297
|
+
type: "PROPERTY_REQUIRED_CHANGED",
|
|
298
|
+
schema: name,
|
|
299
|
+
property,
|
|
300
|
+
location: propertyLocation(name, property),
|
|
301
|
+
from: "optional",
|
|
302
|
+
to: "required",
|
|
303
|
+
detail: `${name}.${property} changed from optional to required.`,
|
|
304
|
+
breaking: true
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const oldEnum = asStringArray(oldSchema.enum);
|
|
309
|
+
const newEnum = asStringArray(newSchema.enum);
|
|
310
|
+
for (const value of oldEnum) {
|
|
311
|
+
if (!newEnum.includes(value)) {
|
|
312
|
+
changes.push({
|
|
313
|
+
type: "ENUM_VALUE_REMOVED",
|
|
314
|
+
schema: name,
|
|
315
|
+
location: `${schemaLocation(name)}.enum`,
|
|
316
|
+
from: value,
|
|
317
|
+
detail: `${name} enum value ${JSON.stringify(value)} was removed.`,
|
|
318
|
+
breaking: true
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const value of newEnum) {
|
|
323
|
+
if (!oldEnum.includes(value)) {
|
|
324
|
+
changes.push({
|
|
325
|
+
type: "ENUM_VALUE_ADDED",
|
|
326
|
+
schema: name,
|
|
327
|
+
location: `${schemaLocation(name)}.enum`,
|
|
328
|
+
value,
|
|
329
|
+
detail: `${name} enum value ${JSON.stringify(value)} was added.`,
|
|
330
|
+
breaking: false
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
export function compareContracts(oldDocument, newDocument) {
|
|
337
|
+
const changes = [];
|
|
338
|
+
const oldPaths = oldDocument.paths ?? {};
|
|
339
|
+
const newPaths = newDocument.paths ?? {};
|
|
340
|
+
const paths = new Set([...Object.keys(oldPaths), ...Object.keys(newPaths)]);
|
|
341
|
+
for (const path of paths) {
|
|
342
|
+
const oldPath = oldPaths[path];
|
|
343
|
+
const newPath = newPaths[path];
|
|
344
|
+
if (!oldPath) {
|
|
345
|
+
for (const method of METHODS) {
|
|
346
|
+
if (newPath?.[method]) {
|
|
347
|
+
changes.push({
|
|
348
|
+
type: "ENDPOINT_ADDED",
|
|
349
|
+
path,
|
|
350
|
+
method: method.toUpperCase(),
|
|
351
|
+
location: operationLocation(path, method),
|
|
352
|
+
detail: `${method.toUpperCase()} ${path} was added.`,
|
|
353
|
+
breaking: false
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (!newPath) {
|
|
360
|
+
for (const method of METHODS) {
|
|
361
|
+
if (oldPath?.[method]) {
|
|
362
|
+
changes.push({
|
|
363
|
+
type: "ENDPOINT_REMOVED",
|
|
364
|
+
path,
|
|
365
|
+
method: method.toUpperCase(),
|
|
366
|
+
location: operationLocation(path, method),
|
|
367
|
+
detail: `${method.toUpperCase()} ${path} was removed.`,
|
|
368
|
+
breaking: true
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
for (const method of METHODS) {
|
|
375
|
+
const oldOperation = asRecord(oldPath?.[method]);
|
|
376
|
+
const newOperation = asRecord(newPath?.[method]);
|
|
377
|
+
if (!oldOperation && newOperation) {
|
|
378
|
+
changes.push({
|
|
379
|
+
type: "ENDPOINT_ADDED",
|
|
380
|
+
path,
|
|
381
|
+
method: method.toUpperCase(),
|
|
382
|
+
location: operationLocation(path, method),
|
|
383
|
+
detail: `${method.toUpperCase()} ${path} was added.`,
|
|
384
|
+
breaking: false
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
else if (oldOperation && !newOperation) {
|
|
388
|
+
changes.push({
|
|
389
|
+
type: "ENDPOINT_REMOVED",
|
|
390
|
+
path,
|
|
391
|
+
method: method.toUpperCase(),
|
|
392
|
+
location: operationLocation(path, method),
|
|
393
|
+
detail: `${method.toUpperCase()} ${path} was removed.`,
|
|
394
|
+
breaking: true
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
else if (oldOperation && newOperation) {
|
|
398
|
+
compareOperationDetails(path, method, oldOperation, newOperation, changes);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const oldComponents = asRecord(oldDocument.components) ?? {};
|
|
403
|
+
const newComponents = asRecord(newDocument.components) ?? {};
|
|
404
|
+
const oldSchemas = (asRecord(oldComponents.schemas) ?? {});
|
|
405
|
+
const newSchemas = (asRecord(newComponents.schemas) ?? {});
|
|
406
|
+
compareSchemas(oldSchemas, newSchemas, changes);
|
|
407
|
+
const added = changes.filter(c => c.type.endsWith("_ADDED")).length;
|
|
408
|
+
const removed = changes.filter(c => c.type.endsWith("_REMOVED")).length;
|
|
409
|
+
const modified = changes.filter(c => c.type.endsWith("_CHANGED") || c.type === "REQUEST_BODY_CHANGED" || c.type === "RESPONSE_CHANGED").length;
|
|
410
|
+
const breaking = changes.filter(c => c.breaking).length;
|
|
411
|
+
return {
|
|
412
|
+
version: "1",
|
|
413
|
+
summary: { added, removed, modified, breaking },
|
|
414
|
+
changes
|
|
415
|
+
};
|
|
416
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function formatDiff(result) {
|
|
2
|
+
const lines = [
|
|
3
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
4
|
+
"API CONTRACT DIFF",
|
|
5
|
+
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
|
|
6
|
+
"",
|
|
7
|
+
"Summary",
|
|
8
|
+
` Added: ${result.summary.added}`,
|
|
9
|
+
` Removed: ${result.summary.removed}`,
|
|
10
|
+
` Modified: ${result.summary.modified}`,
|
|
11
|
+
` Breaking: ${result.summary.breaking}`,
|
|
12
|
+
""
|
|
13
|
+
];
|
|
14
|
+
const breaking = result.changes.filter(c => c.breaking);
|
|
15
|
+
const nonBreaking = result.changes.filter(c => !c.breaking);
|
|
16
|
+
if (breaking.length) {
|
|
17
|
+
lines.push("BREAKING CHANGES", "──────────────────────────────────────", "");
|
|
18
|
+
for (const change of breaking) {
|
|
19
|
+
lines.push(`⚠ ${change.detail}`, "");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (nonBreaking.length) {
|
|
23
|
+
lines.push("NON-BREAKING CHANGES", "──────────────────────────────────────", "");
|
|
24
|
+
for (const change of nonBreaking) {
|
|
25
|
+
lines.push(`+ ${change.detail}`, "");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!result.changes.length) {
|
|
29
|
+
lines.push("No contract changes detected.", "");
|
|
30
|
+
}
|
|
31
|
+
return lines.join("\n");
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateApi(inputPath: string, outputPath: string, typesImport?: string): Promise<void>;
|