api-spec-cli 0.2.4 → 0.2.5
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/README.md +48 -1
- package/package.json +5 -3
- package/src/cli.js +222 -67
- package/src/commands/add.js +8 -6
- package/src/commands/auth.js +32 -32
- package/src/commands/call.js +4 -0
- package/src/commands/fetch.js +344 -344
- package/src/commands/grep.js +67 -67
- package/src/commands/list.js +13 -2
- package/src/commands/show.js +224 -224
- package/src/commands/skill.js +40 -0
- package/src/commands/specs.js +82 -82
- package/src/commands/types.js +167 -167
- package/src/commands/usage.js +15 -0
- package/src/commands/validate.js +295 -295
- package/src/dotenv.js +38 -0
- package/src/glob.js +34 -34
- package/src/mcp-client.js +23 -20
- package/src/oauth/auth-flow.js +2 -2
- package/src/oauth/provider.js +3 -4
- package/src/oauth/tokens.js +6 -0
- package/src/output.js +65 -61
- package/src/registry.js +79 -79
- package/src/resolve.js +21 -19
- package/src/secrets.js +46 -0
- package/src/skill/SKILL.md +112 -0
- package/src/usage.js +62 -0
package/src/commands/validate.js
CHANGED
|
@@ -1,295 +1,295 @@
|
|
|
1
|
-
import { readFileSync, existsSync } from "fs";
|
|
2
|
-
import { resolve } from "path";
|
|
3
|
-
import YAML from "yaml";
|
|
4
|
-
import { out } from "../output.js";
|
|
5
|
-
import { parseArgs } from "../args.js";
|
|
6
|
-
|
|
7
|
-
export async function validateSpec(args) {
|
|
8
|
-
const { positional } = parseArgs(args);
|
|
9
|
-
const source = positional[0];
|
|
10
|
-
if (!source) throw new Error("Usage: spec validate <file-or-url>");
|
|
11
|
-
|
|
12
|
-
const isUrl = source.startsWith("http://") || source.startsWith("https://");
|
|
13
|
-
|
|
14
|
-
let text;
|
|
15
|
-
if (isUrl) {
|
|
16
|
-
const res = await fetch(source);
|
|
17
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
18
|
-
text = await res.text();
|
|
19
|
-
} else {
|
|
20
|
-
const abs = resolve(source);
|
|
21
|
-
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
22
|
-
text = readFileSync(abs, "utf-8");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
let doc;
|
|
26
|
-
try {
|
|
27
|
-
doc = JSON.parse(text);
|
|
28
|
-
} catch {
|
|
29
|
-
try {
|
|
30
|
-
doc = YAML.parse(text);
|
|
31
|
-
} catch (e) {
|
|
32
|
-
out({ valid: false, errors: [{ path: "", message: `Parse error: ${e.message}` }] });
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const errors = [];
|
|
38
|
-
const warnings = [];
|
|
39
|
-
|
|
40
|
-
// Detect spec version
|
|
41
|
-
const version = doc.openapi || doc.swagger;
|
|
42
|
-
if (!version) {
|
|
43
|
-
errors.push({ path: "", message: "Missing 'openapi' or 'swagger' version field" });
|
|
44
|
-
out({ valid: false, errors, warnings });
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const isV3 = version.startsWith("3");
|
|
49
|
-
|
|
50
|
-
// info object
|
|
51
|
-
validateInfo(doc, errors, warnings);
|
|
52
|
-
|
|
53
|
-
// paths
|
|
54
|
-
validatePaths(doc, errors, warnings, isV3);
|
|
55
|
-
|
|
56
|
-
// components / definitions
|
|
57
|
-
if (isV3) {
|
|
58
|
-
validateComponentsV3(doc, errors, warnings);
|
|
59
|
-
} else {
|
|
60
|
-
validateDefinitionsV2(doc, errors, warnings);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// servers (v3) or host (v2)
|
|
64
|
-
validateServers(doc, errors, warnings, isV3);
|
|
65
|
-
|
|
66
|
-
// Check for broken $ref
|
|
67
|
-
validateRefs(doc, doc, "", errors);
|
|
68
|
-
|
|
69
|
-
out({
|
|
70
|
-
valid: errors.length === 0,
|
|
71
|
-
version,
|
|
72
|
-
title: doc.info?.title || null,
|
|
73
|
-
operationCount: countOperations(doc),
|
|
74
|
-
errors,
|
|
75
|
-
warnings,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function validateInfo(doc, errors, warnings) {
|
|
80
|
-
if (!doc.info) {
|
|
81
|
-
errors.push({ path: "info", message: "Missing required 'info' object" });
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
if (!doc.info.title) {
|
|
85
|
-
errors.push({ path: "info.title", message: "Missing required 'info.title'" });
|
|
86
|
-
}
|
|
87
|
-
if (!doc.info.version) {
|
|
88
|
-
errors.push({ path: "info.version", message: "Missing required 'info.version'" });
|
|
89
|
-
}
|
|
90
|
-
if (!doc.info.description) {
|
|
91
|
-
warnings.push({
|
|
92
|
-
path: "info.description",
|
|
93
|
-
message: "Missing 'info.description' (recommended)",
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function validatePaths(doc, errors, warnings, isV3) {
|
|
99
|
-
if (!doc.paths) {
|
|
100
|
-
errors.push({ path: "paths", message: "Missing required 'paths' object" });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (Object.keys(doc.paths).length === 0) {
|
|
105
|
-
warnings.push({ path: "paths", message: "No paths defined" });
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
|
|
110
|
-
const operationIds = new Set();
|
|
111
|
-
|
|
112
|
-
for (const [path, methods] of Object.entries(doc.paths)) {
|
|
113
|
-
// Path must start with /
|
|
114
|
-
if (!path.startsWith("/")) {
|
|
115
|
-
errors.push({ path: `paths.${path}`, message: `Path must start with '/'` });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Check for unbalanced path params
|
|
119
|
-
const pathParams = (path.match(/\{([^}]+)\}/g) || []).map((p) => p.slice(1, -1));
|
|
120
|
-
|
|
121
|
-
if (typeof methods !== "object" || methods === null) continue;
|
|
122
|
-
|
|
123
|
-
for (const [method, op] of Object.entries(methods)) {
|
|
124
|
-
if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
|
|
125
|
-
|
|
126
|
-
if (!METHODS.has(method)) {
|
|
127
|
-
warnings.push({
|
|
128
|
-
path: `paths.${path}.${method}`,
|
|
129
|
-
message: `Unknown HTTP method '${method}'`,
|
|
130
|
-
});
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (typeof op !== "object" || op === null) continue;
|
|
135
|
-
|
|
136
|
-
const opPath = `paths.${path}.${method.toUpperCase()}`;
|
|
137
|
-
|
|
138
|
-
// operationId uniqueness
|
|
139
|
-
if (op.operationId) {
|
|
140
|
-
if (operationIds.has(op.operationId)) {
|
|
141
|
-
errors.push({ path: opPath, message: `Duplicate operationId '${op.operationId}'` });
|
|
142
|
-
}
|
|
143
|
-
operationIds.add(op.operationId);
|
|
144
|
-
} else {
|
|
145
|
-
warnings.push({ path: opPath, message: "Missing operationId (recommended for agent use)" });
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Responses required
|
|
149
|
-
if (!op.responses || Object.keys(op.responses).length === 0) {
|
|
150
|
-
errors.push({ path: opPath, message: "Missing or empty 'responses'" });
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Check path params are declared
|
|
154
|
-
const declaredParams = new Set(
|
|
155
|
-
(op.parameters || [])
|
|
156
|
-
.filter((p) => (p.in || p.$ref) && (p.in === "path" || !p.in))
|
|
157
|
-
.map((p) => p.name)
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
// Also include path-level parameters
|
|
161
|
-
const pathLevelParams = (methods.parameters || [])
|
|
162
|
-
.filter((p) => p.in === "path")
|
|
163
|
-
.map((p) => p.name);
|
|
164
|
-
|
|
165
|
-
for (const n of pathLevelParams) declaredParams.add(n);
|
|
166
|
-
|
|
167
|
-
for (const param of pathParams) {
|
|
168
|
-
if (!declaredParams.has(param)) {
|
|
169
|
-
// Only warn — the param might be declared via $ref
|
|
170
|
-
warnings.push({
|
|
171
|
-
path: opPath,
|
|
172
|
-
message: `Path parameter '{${param}}' may not be declared in parameters`,
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Request body on GET/DELETE/HEAD
|
|
178
|
-
if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
|
|
179
|
-
warnings.push({
|
|
180
|
-
path: opPath,
|
|
181
|
-
message: `requestBody on ${method.toUpperCase()} is unusual`,
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function validateComponentsV3(doc, errors, warnings) {
|
|
189
|
-
if (!doc.components) return;
|
|
190
|
-
|
|
191
|
-
// Check schemas have valid types
|
|
192
|
-
if (doc.components.schemas) {
|
|
193
|
-
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
194
|
-
validateSchema(schema, `components.schemas.${name}`, errors, warnings);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function validateDefinitionsV2(doc, errors, warnings) {
|
|
200
|
-
if (!doc.definitions) return;
|
|
201
|
-
|
|
202
|
-
for (const [name, schema] of Object.entries(doc.definitions)) {
|
|
203
|
-
validateSchema(schema, `definitions.${name}`, errors, warnings);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function validateSchema(schema, path, errors, warnings) {
|
|
208
|
-
if (!schema || typeof schema !== "object") return;
|
|
209
|
-
if (schema.$ref) return; // reference, skip
|
|
210
|
-
|
|
211
|
-
const VALID_TYPES = new Set([
|
|
212
|
-
"string",
|
|
213
|
-
"number",
|
|
214
|
-
"integer",
|
|
215
|
-
"boolean",
|
|
216
|
-
"array",
|
|
217
|
-
"object",
|
|
218
|
-
"null",
|
|
219
|
-
]);
|
|
220
|
-
|
|
221
|
-
if (schema.type && !VALID_TYPES.has(schema.type)) {
|
|
222
|
-
errors.push({ path, message: `Invalid type '${schema.type}'` });
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (schema.type === "array" && !schema.items) {
|
|
226
|
-
errors.push({ path, message: "Array type must have 'items'" });
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Recurse into properties
|
|
230
|
-
if (schema.properties) {
|
|
231
|
-
for (const [key, val] of Object.entries(schema.properties)) {
|
|
232
|
-
validateSchema(val, `${path}.properties.${key}`, errors, warnings);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (schema.items) {
|
|
236
|
-
validateSchema(schema.items, `${path}.items`, errors, warnings);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function validateServers(doc, errors, warnings, isV3) {
|
|
241
|
-
if (isV3) {
|
|
242
|
-
if (!doc.servers || doc.servers.length === 0) {
|
|
243
|
-
warnings.push({
|
|
244
|
-
path: "servers",
|
|
245
|
-
message: "No servers defined — agent will need baseUrl configured",
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
} else {
|
|
249
|
-
if (!doc.host) {
|
|
250
|
-
warnings.push({
|
|
251
|
-
path: "host",
|
|
252
|
-
message: "No host defined — agent will need baseUrl configured",
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function validateRefs(doc, root, path, errors) {
|
|
259
|
-
if (!doc || typeof doc !== "object") return;
|
|
260
|
-
|
|
261
|
-
if (doc.$ref && typeof doc.$ref === "string") {
|
|
262
|
-
if (doc.$ref.startsWith("#/")) {
|
|
263
|
-
const parts = doc.$ref.slice(2).split("/");
|
|
264
|
-
let target = root;
|
|
265
|
-
for (const p of parts) {
|
|
266
|
-
target = target?.[p];
|
|
267
|
-
}
|
|
268
|
-
if (target === undefined) {
|
|
269
|
-
errors.push({ path: path || doc.$ref, message: `Broken $ref: '${doc.$ref}'` });
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return; // Don't recurse into $ref
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (Array.isArray(doc)) {
|
|
276
|
-
for (let i = 0; i < doc.length; i++) {
|
|
277
|
-
validateRefs(doc[i], root, `${path}[${i}]`, errors);
|
|
278
|
-
}
|
|
279
|
-
} else {
|
|
280
|
-
for (const [key, val] of Object.entries(doc)) {
|
|
281
|
-
if (key === "raw") continue; // skip stored raw data
|
|
282
|
-
validateRefs(val, root, path ? `${path}.${key}` : key, errors);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function countOperations(doc) {
|
|
288
|
-
let count = 0;
|
|
289
|
-
for (const methods of Object.values(doc.paths || {})) {
|
|
290
|
-
for (const method of Object.keys(methods)) {
|
|
291
|
-
if (!method.startsWith("x-") && method !== "parameters" && method !== "$ref") count++;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return count;
|
|
295
|
-
}
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { out } from "../output.js";
|
|
5
|
+
import { parseArgs } from "../args.js";
|
|
6
|
+
|
|
7
|
+
export async function validateSpec(args) {
|
|
8
|
+
const { positional } = parseArgs(args);
|
|
9
|
+
const source = positional[0];
|
|
10
|
+
if (!source) throw new Error("Usage: spec validate <file-or-url>");
|
|
11
|
+
|
|
12
|
+
const isUrl = source.startsWith("http://") || source.startsWith("https://");
|
|
13
|
+
|
|
14
|
+
let text;
|
|
15
|
+
if (isUrl) {
|
|
16
|
+
const res = await fetch(source);
|
|
17
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
18
|
+
text = await res.text();
|
|
19
|
+
} else {
|
|
20
|
+
const abs = resolve(source);
|
|
21
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
|
|
22
|
+
text = readFileSync(abs, "utf-8");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let doc;
|
|
26
|
+
try {
|
|
27
|
+
doc = JSON.parse(text);
|
|
28
|
+
} catch {
|
|
29
|
+
try {
|
|
30
|
+
doc = YAML.parse(text);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
out({ valid: false, errors: [{ path: "", message: `Parse error: ${e.message}` }] });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const errors = [];
|
|
38
|
+
const warnings = [];
|
|
39
|
+
|
|
40
|
+
// Detect spec version
|
|
41
|
+
const version = doc.openapi || doc.swagger;
|
|
42
|
+
if (!version) {
|
|
43
|
+
errors.push({ path: "", message: "Missing 'openapi' or 'swagger' version field" });
|
|
44
|
+
out({ valid: false, errors, warnings });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isV3 = version.startsWith("3");
|
|
49
|
+
|
|
50
|
+
// info object
|
|
51
|
+
validateInfo(doc, errors, warnings);
|
|
52
|
+
|
|
53
|
+
// paths
|
|
54
|
+
validatePaths(doc, errors, warnings, isV3);
|
|
55
|
+
|
|
56
|
+
// components / definitions
|
|
57
|
+
if (isV3) {
|
|
58
|
+
validateComponentsV3(doc, errors, warnings);
|
|
59
|
+
} else {
|
|
60
|
+
validateDefinitionsV2(doc, errors, warnings);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// servers (v3) or host (v2)
|
|
64
|
+
validateServers(doc, errors, warnings, isV3);
|
|
65
|
+
|
|
66
|
+
// Check for broken $ref
|
|
67
|
+
validateRefs(doc, doc, "", errors);
|
|
68
|
+
|
|
69
|
+
out({
|
|
70
|
+
valid: errors.length === 0,
|
|
71
|
+
version,
|
|
72
|
+
title: doc.info?.title || null,
|
|
73
|
+
operationCount: countOperations(doc),
|
|
74
|
+
errors,
|
|
75
|
+
warnings,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validateInfo(doc, errors, warnings) {
|
|
80
|
+
if (!doc.info) {
|
|
81
|
+
errors.push({ path: "info", message: "Missing required 'info' object" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!doc.info.title) {
|
|
85
|
+
errors.push({ path: "info.title", message: "Missing required 'info.title'" });
|
|
86
|
+
}
|
|
87
|
+
if (!doc.info.version) {
|
|
88
|
+
errors.push({ path: "info.version", message: "Missing required 'info.version'" });
|
|
89
|
+
}
|
|
90
|
+
if (!doc.info.description) {
|
|
91
|
+
warnings.push({
|
|
92
|
+
path: "info.description",
|
|
93
|
+
message: "Missing 'info.description' (recommended)",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validatePaths(doc, errors, warnings, isV3) {
|
|
99
|
+
if (!doc.paths) {
|
|
100
|
+
errors.push({ path: "paths", message: "Missing required 'paths' object" });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (Object.keys(doc.paths).length === 0) {
|
|
105
|
+
warnings.push({ path: "paths", message: "No paths defined" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
|
|
110
|
+
const operationIds = new Set();
|
|
111
|
+
|
|
112
|
+
for (const [path, methods] of Object.entries(doc.paths)) {
|
|
113
|
+
// Path must start with /
|
|
114
|
+
if (!path.startsWith("/")) {
|
|
115
|
+
errors.push({ path: `paths.${path}`, message: `Path must start with '/'` });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Check for unbalanced path params
|
|
119
|
+
const pathParams = (path.match(/\{([^}]+)\}/g) || []).map((p) => p.slice(1, -1));
|
|
120
|
+
|
|
121
|
+
if (typeof methods !== "object" || methods === null) continue;
|
|
122
|
+
|
|
123
|
+
for (const [method, op] of Object.entries(methods)) {
|
|
124
|
+
if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
|
|
125
|
+
|
|
126
|
+
if (!METHODS.has(method)) {
|
|
127
|
+
warnings.push({
|
|
128
|
+
path: `paths.${path}.${method}`,
|
|
129
|
+
message: `Unknown HTTP method '${method}'`,
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof op !== "object" || op === null) continue;
|
|
135
|
+
|
|
136
|
+
const opPath = `paths.${path}.${method.toUpperCase()}`;
|
|
137
|
+
|
|
138
|
+
// operationId uniqueness
|
|
139
|
+
if (op.operationId) {
|
|
140
|
+
if (operationIds.has(op.operationId)) {
|
|
141
|
+
errors.push({ path: opPath, message: `Duplicate operationId '${op.operationId}'` });
|
|
142
|
+
}
|
|
143
|
+
operationIds.add(op.operationId);
|
|
144
|
+
} else {
|
|
145
|
+
warnings.push({ path: opPath, message: "Missing operationId (recommended for agent use)" });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Responses required
|
|
149
|
+
if (!op.responses || Object.keys(op.responses).length === 0) {
|
|
150
|
+
errors.push({ path: opPath, message: "Missing or empty 'responses'" });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check path params are declared
|
|
154
|
+
const declaredParams = new Set(
|
|
155
|
+
(op.parameters || [])
|
|
156
|
+
.filter((p) => (p.in || p.$ref) && (p.in === "path" || !p.in))
|
|
157
|
+
.map((p) => p.name)
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Also include path-level parameters
|
|
161
|
+
const pathLevelParams = (methods.parameters || [])
|
|
162
|
+
.filter((p) => p.in === "path")
|
|
163
|
+
.map((p) => p.name);
|
|
164
|
+
|
|
165
|
+
for (const n of pathLevelParams) declaredParams.add(n);
|
|
166
|
+
|
|
167
|
+
for (const param of pathParams) {
|
|
168
|
+
if (!declaredParams.has(param)) {
|
|
169
|
+
// Only warn — the param might be declared via $ref
|
|
170
|
+
warnings.push({
|
|
171
|
+
path: opPath,
|
|
172
|
+
message: `Path parameter '{${param}}' may not be declared in parameters`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Request body on GET/DELETE/HEAD
|
|
178
|
+
if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
|
|
179
|
+
warnings.push({
|
|
180
|
+
path: opPath,
|
|
181
|
+
message: `requestBody on ${method.toUpperCase()} is unusual`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function validateComponentsV3(doc, errors, warnings) {
|
|
189
|
+
if (!doc.components) return;
|
|
190
|
+
|
|
191
|
+
// Check schemas have valid types
|
|
192
|
+
if (doc.components.schemas) {
|
|
193
|
+
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
194
|
+
validateSchema(schema, `components.schemas.${name}`, errors, warnings);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateDefinitionsV2(doc, errors, warnings) {
|
|
200
|
+
if (!doc.definitions) return;
|
|
201
|
+
|
|
202
|
+
for (const [name, schema] of Object.entries(doc.definitions)) {
|
|
203
|
+
validateSchema(schema, `definitions.${name}`, errors, warnings);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validateSchema(schema, path, errors, warnings) {
|
|
208
|
+
if (!schema || typeof schema !== "object") return;
|
|
209
|
+
if (schema.$ref) return; // reference, skip
|
|
210
|
+
|
|
211
|
+
const VALID_TYPES = new Set([
|
|
212
|
+
"string",
|
|
213
|
+
"number",
|
|
214
|
+
"integer",
|
|
215
|
+
"boolean",
|
|
216
|
+
"array",
|
|
217
|
+
"object",
|
|
218
|
+
"null",
|
|
219
|
+
]);
|
|
220
|
+
|
|
221
|
+
if (schema.type && !VALID_TYPES.has(schema.type)) {
|
|
222
|
+
errors.push({ path, message: `Invalid type '${schema.type}'` });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (schema.type === "array" && !schema.items) {
|
|
226
|
+
errors.push({ path, message: "Array type must have 'items'" });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Recurse into properties
|
|
230
|
+
if (schema.properties) {
|
|
231
|
+
for (const [key, val] of Object.entries(schema.properties)) {
|
|
232
|
+
validateSchema(val, `${path}.properties.${key}`, errors, warnings);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (schema.items) {
|
|
236
|
+
validateSchema(schema.items, `${path}.items`, errors, warnings);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function validateServers(doc, errors, warnings, isV3) {
|
|
241
|
+
if (isV3) {
|
|
242
|
+
if (!doc.servers || doc.servers.length === 0) {
|
|
243
|
+
warnings.push({
|
|
244
|
+
path: "servers",
|
|
245
|
+
message: "No servers defined — agent will need baseUrl configured",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
if (!doc.host) {
|
|
250
|
+
warnings.push({
|
|
251
|
+
path: "host",
|
|
252
|
+
message: "No host defined — agent will need baseUrl configured",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function validateRefs(doc, root, path, errors) {
|
|
259
|
+
if (!doc || typeof doc !== "object") return;
|
|
260
|
+
|
|
261
|
+
if (doc.$ref && typeof doc.$ref === "string") {
|
|
262
|
+
if (doc.$ref.startsWith("#/")) {
|
|
263
|
+
const parts = doc.$ref.slice(2).split("/");
|
|
264
|
+
let target = root;
|
|
265
|
+
for (const p of parts) {
|
|
266
|
+
target = target?.[p];
|
|
267
|
+
}
|
|
268
|
+
if (target === undefined) {
|
|
269
|
+
errors.push({ path: path || doc.$ref, message: `Broken $ref: '${doc.$ref}'` });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return; // Don't recurse into $ref
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (Array.isArray(doc)) {
|
|
276
|
+
for (let i = 0; i < doc.length; i++) {
|
|
277
|
+
validateRefs(doc[i], root, `${path}[${i}]`, errors);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
for (const [key, val] of Object.entries(doc)) {
|
|
281
|
+
if (key === "raw") continue; // skip stored raw data
|
|
282
|
+
validateRefs(val, root, path ? `${path}.${key}` : key, errors);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function countOperations(doc) {
|
|
288
|
+
let count = 0;
|
|
289
|
+
for (const methods of Object.values(doc.paths || {})) {
|
|
290
|
+
for (const method of Object.keys(methods)) {
|
|
291
|
+
if (!method.startsWith("x-") && method !== "parameters" && method !== "$ref") count++;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return count;
|
|
295
|
+
}
|
package/src/dotenv.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
function parseEnv(text) {
|
|
5
|
+
const out = {};
|
|
6
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
7
|
+
const line = rawLine.trim();
|
|
8
|
+
if (!line || line.startsWith("#")) continue;
|
|
9
|
+
const eq = line.indexOf("=");
|
|
10
|
+
if (eq === -1) continue;
|
|
11
|
+
const key = line.slice(0, eq).trim();
|
|
12
|
+
if (!key) continue;
|
|
13
|
+
let value = line.slice(eq + 1).trim();
|
|
14
|
+
if (
|
|
15
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
16
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
17
|
+
) {
|
|
18
|
+
value = value.slice(1, -1);
|
|
19
|
+
}
|
|
20
|
+
out[key] = value;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadDotenv(dir = process.cwd()) {
|
|
26
|
+
if (process.env.SPEC_NO_DOTENV) return;
|
|
27
|
+
const file = join(dir, ".env");
|
|
28
|
+
if (!existsSync(file)) return;
|
|
29
|
+
let parsed;
|
|
30
|
+
try {
|
|
31
|
+
parsed = parseEnv(readFileSync(file, "utf-8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
36
|
+
if (!(key in process.env)) process.env[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|