api-spec-cli 0.0.1 → 0.0.3

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.
@@ -0,0 +1,230 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import YAML from "yaml";
4
+ import { saveSpec } from "../store.js";
5
+ import { out, err } from "../output.js";
6
+
7
+ const INTROSPECTION_QUERY = `{
8
+ __schema {
9
+ queryType { name }
10
+ mutationType { name }
11
+ subscriptionType { name }
12
+ types {
13
+ name
14
+ kind
15
+ description
16
+ fields(includeDeprecated: true) {
17
+ name
18
+ description
19
+ isDeprecated
20
+ deprecationReason
21
+ args {
22
+ name
23
+ description
24
+ type { ...TypeRef }
25
+ defaultValue
26
+ }
27
+ type { ...TypeRef }
28
+ }
29
+ inputFields {
30
+ name
31
+ description
32
+ type { ...TypeRef }
33
+ defaultValue
34
+ }
35
+ enumValues(includeDeprecated: true) {
36
+ name
37
+ description
38
+ isDeprecated
39
+ deprecationReason
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ fragment TypeRef on __Type {
46
+ kind
47
+ name
48
+ ofType {
49
+ kind
50
+ name
51
+ ofType {
52
+ kind
53
+ name
54
+ ofType {
55
+ kind
56
+ name
57
+ }
58
+ }
59
+ }
60
+ }`;
61
+
62
+ export async function loadSpec(args) {
63
+ const source = args[0];
64
+ if (!source) throw new Error("Usage: spec load <file-or-url>");
65
+
66
+ // Detect if it's a URL or file
67
+ const isUrl = source.startsWith("http://") || source.startsWith("https://");
68
+
69
+ let spec;
70
+
71
+ if (isUrl) {
72
+ spec = await loadFromUrl(source);
73
+ } else {
74
+ spec = loadFromFile(source);
75
+ }
76
+
77
+ saveSpec(spec);
78
+ out({
79
+ ok: true,
80
+ type: spec.type,
81
+ title: spec.title || null,
82
+ operationCount: countOperations(spec),
83
+ source: source,
84
+ });
85
+ }
86
+
87
+ async function loadFromUrl(url) {
88
+ // Try GraphQL introspection first if URL doesn't end with known extensions
89
+ const lowerUrl = url.toLowerCase();
90
+ const isLikelyFile =
91
+ lowerUrl.endsWith(".json") ||
92
+ lowerUrl.endsWith(".yaml") ||
93
+ lowerUrl.endsWith(".yml");
94
+
95
+ if (!isLikelyFile) {
96
+ // Try GraphQL introspection
97
+ try {
98
+ return await loadGraphQL(url);
99
+ } catch (e) {
100
+ // Fall through to OpenAPI
101
+ }
102
+ }
103
+
104
+ // Fetch as OpenAPI
105
+ const res = await fetch(url);
106
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
107
+ const text = await res.text();
108
+ return parseOpenAPI(text, url);
109
+ }
110
+
111
+ function loadFromFile(path) {
112
+ const abs = resolve(path);
113
+ if (!existsSync(abs)) throw new Error(`File not found: ${abs}`);
114
+ const text = readFileSync(abs, "utf-8");
115
+ return parseOpenAPI(text, path);
116
+ }
117
+
118
+ async function loadGraphQL(url) {
119
+ const res = await fetch(url, {
120
+ method: "POST",
121
+ headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify({ query: INTROSPECTION_QUERY }),
123
+ });
124
+
125
+ if (!res.ok) throw new Error(`GraphQL introspection failed: HTTP ${res.status}`);
126
+
127
+ const json = await res.json();
128
+ if (json.errors && !json.data) {
129
+ throw new Error(`GraphQL error: ${json.errors[0].message}`);
130
+ }
131
+
132
+ const schema = json.data.__schema;
133
+
134
+ // Extract operations from query/mutation/subscription types
135
+ const operations = [];
136
+ const typeMap = {};
137
+ for (const t of schema.types) {
138
+ typeMap[t.name] = t;
139
+ }
140
+
141
+ for (const [kind, rootType] of [
142
+ ["query", schema.queryType],
143
+ ["mutation", schema.mutationType],
144
+ ["subscription", schema.subscriptionType],
145
+ ]) {
146
+ if (!rootType) continue;
147
+ const type = typeMap[rootType.name];
148
+ if (!type || !type.fields) continue;
149
+ for (const field of type.fields) {
150
+ operations.push({
151
+ kind,
152
+ name: field.name,
153
+ description: field.description,
154
+ args: field.args,
155
+ returnType: flattenType(field.type),
156
+ isDeprecated: field.isDeprecated,
157
+ deprecationReason: field.deprecationReason,
158
+ });
159
+ }
160
+ }
161
+
162
+ return {
163
+ type: "graphql",
164
+ title: null,
165
+ endpoint: url,
166
+ operations,
167
+ types: schema.types.filter((t) => !t.name.startsWith("__")),
168
+ raw: schema,
169
+ };
170
+ }
171
+
172
+ function parseOpenAPI(text, source) {
173
+ let doc;
174
+ try {
175
+ doc = JSON.parse(text);
176
+ } catch {
177
+ doc = YAML.parse(text);
178
+ }
179
+
180
+ // Detect OpenAPI vs Swagger
181
+ const version = doc.openapi || doc.swagger;
182
+ if (!version) throw new Error("Not a valid OpenAPI/Swagger spec");
183
+
184
+ const operations = [];
185
+
186
+ for (const [path, methods] of Object.entries(doc.paths || {})) {
187
+ for (const [method, op] of Object.entries(methods)) {
188
+ if (method.startsWith("x-") || method === "parameters") continue;
189
+ operations.push({
190
+ id: op.operationId || `${method.toUpperCase()} ${path}`,
191
+ method: method.toUpperCase(),
192
+ path,
193
+ summary: op.summary || null,
194
+ description: op.description || null,
195
+ parameters: op.parameters || [],
196
+ requestBody: op.requestBody || null,
197
+ responses: op.responses || {},
198
+ tags: op.tags || [],
199
+ deprecated: op.deprecated || false,
200
+ });
201
+ }
202
+ }
203
+
204
+ return {
205
+ type: "openapi",
206
+ version,
207
+ title: doc.info?.title || null,
208
+ description: doc.info?.description || null,
209
+ servers: doc.servers || (doc.host ? [{ url: `${doc.schemes?.[0] || "https"}://${doc.host}${doc.basePath || ""}` }] : []),
210
+ operations,
211
+ components: doc.components || doc.definitions || {},
212
+ raw: doc,
213
+ };
214
+ }
215
+
216
+ function flattenType(t) {
217
+ if (!t) return null;
218
+ if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
219
+ if (t.ofType) {
220
+ const inner = flattenType(t.ofType);
221
+ if (t.kind === "LIST") return `[${inner}]`;
222
+ if (t.kind === "NON_NULL") return `${inner}!`;
223
+ return inner;
224
+ }
225
+ return t.kind;
226
+ }
227
+
228
+ function countOperations(spec) {
229
+ return spec.operations?.length || 0;
230
+ }
@@ -0,0 +1,176 @@
1
+ import { getSpec } from "../store.js";
2
+ import { out } from "../output.js";
3
+
4
+ export async function showOperation(args) {
5
+ const target = args[0];
6
+ if (!target) throw new Error("Usage: spec show <operationId-or-path>");
7
+
8
+ const spec = getSpec();
9
+ if (!spec) throw new Error("No spec loaded. Run: spec load <file-or-url>");
10
+
11
+ if (spec.type === "openapi") {
12
+ showOpenAPI(spec, target);
13
+ } else {
14
+ showGraphQL(spec, target);
15
+ }
16
+ }
17
+
18
+ function showOpenAPI(spec, target) {
19
+ const lower = target.toLowerCase();
20
+
21
+ // Match by operationId, path, or "METHOD path"
22
+ const op = spec.operations.find((o) => {
23
+ return (
24
+ o.id.toLowerCase() === lower ||
25
+ o.path.toLowerCase() === lower ||
26
+ `${o.method.toLowerCase()} ${o.path.toLowerCase()}` === lower
27
+ );
28
+ });
29
+
30
+ if (!op) {
31
+ throw new Error(`Operation not found: ${target}. Run 'spec list' to see available operations.`);
32
+ }
33
+
34
+ const root = spec.raw || spec.components;
35
+
36
+ // Resolve $ref in parameters, requestBody, and responses
37
+ const resolved = {
38
+ ...op,
39
+ parameters: op.parameters.map((p) => resolveRef(p, root)),
40
+ requestBody: op.requestBody ? resolveRequestBody(op.requestBody, root) : null,
41
+ responses: resolveResponses(op.responses, root),
42
+ };
43
+
44
+ out(resolved);
45
+ }
46
+
47
+ function showGraphQL(spec, target) {
48
+ const lower = target.toLowerCase();
49
+
50
+ const op = spec.operations.find((o) => o.name.toLowerCase() === lower);
51
+
52
+ if (!op) {
53
+ throw new Error(`Operation not found: ${target}. Run 'spec list' to see available operations.`);
54
+ }
55
+
56
+ // Also find related types
57
+ const relatedTypes = findRelatedTypes(op, spec.types);
58
+
59
+ out({
60
+ ...op,
61
+ relatedTypes,
62
+ });
63
+ }
64
+
65
+ function resolveRef(obj, root) {
66
+ if (!obj || typeof obj !== "object") return obj;
67
+ if (obj.$ref) {
68
+ const path = obj.$ref.replace("#/", "").split("/");
69
+ let resolved = root;
70
+ for (const p of path) {
71
+ resolved = resolved?.[p];
72
+ }
73
+ return resolved || obj;
74
+ }
75
+ return obj;
76
+ }
77
+
78
+ function resolveRequestBody(body, root) {
79
+ if (!body) return null;
80
+ const resolved = resolveRef(body, root);
81
+ if (resolved?.content) {
82
+ const result = { ...resolved, content: {} };
83
+ for (const [mediaType, value] of Object.entries(resolved.content)) {
84
+ result.content[mediaType] = {
85
+ ...value,
86
+ schema: resolveSchema(value.schema, root),
87
+ };
88
+ }
89
+ return result;
90
+ }
91
+ return resolved;
92
+ }
93
+
94
+ function resolveResponses(responses, root) {
95
+ if (!responses) return responses;
96
+ const result = {};
97
+ for (const [code, resp] of Object.entries(responses)) {
98
+ const resolved = resolveRef(resp, root);
99
+ if (resolved?.content) {
100
+ result[code] = {
101
+ ...resolved,
102
+ content: {},
103
+ };
104
+ for (const [mediaType, value] of Object.entries(resolved.content)) {
105
+ result[code].content[mediaType] = {
106
+ ...value,
107
+ schema: resolveSchema(value.schema, root),
108
+ };
109
+ }
110
+ } else {
111
+ result[code] = resolved;
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+
117
+ function resolveSchema(schema, root, depth = 0) {
118
+ if (!schema || depth > 5) return schema;
119
+ if (schema.$ref) {
120
+ return resolveRef(schema, root);
121
+ }
122
+ if (schema.properties) {
123
+ const result = { ...schema, properties: {} };
124
+ for (const [key, val] of Object.entries(schema.properties)) {
125
+ result.properties[key] = resolveSchema(val, root, depth + 1);
126
+ }
127
+ return result;
128
+ }
129
+ if (schema.items) {
130
+ return { ...schema, items: resolveSchema(schema.items, root, depth + 1) };
131
+ }
132
+ return schema;
133
+ }
134
+
135
+ function findRelatedTypes(op, types) {
136
+ const names = new Set();
137
+
138
+ // Collect type names from args and return type
139
+ function extractTypeNames(typeStr) {
140
+ if (!typeStr) return;
141
+ const cleaned = typeStr.replace(/[[\]!]/g, "");
142
+ if (cleaned) names.add(cleaned);
143
+ }
144
+
145
+ extractTypeNames(op.returnType);
146
+ for (const arg of op.args || []) {
147
+ extractTypeNames(flattenType(arg.type));
148
+ }
149
+
150
+ // Filter out built-in scalar types
151
+ const scalars = new Set(["String", "Int", "Float", "Boolean", "ID"]);
152
+ return types
153
+ .filter((t) => names.has(t.name) && !scalars.has(t.name))
154
+ .map((t) => ({
155
+ name: t.name,
156
+ kind: t.kind,
157
+ fields: t.fields?.map((f) => ({
158
+ name: f.name,
159
+ type: flattenType(f.type),
160
+ description: f.description,
161
+ })),
162
+ enumValues: t.enumValues,
163
+ }));
164
+ }
165
+
166
+ function flattenType(t) {
167
+ if (!t) return null;
168
+ if (t.name) return t.kind === "NON_NULL" ? `${t.name}!` : t.name;
169
+ if (t.ofType) {
170
+ const inner = flattenType(t.ofType);
171
+ if (t.kind === "LIST") return `[${inner}]`;
172
+ if (t.kind === "NON_NULL") return `${inner}!`;
173
+ return inner;
174
+ }
175
+ return t.kind;
176
+ }
@@ -0,0 +1,269 @@
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({ path: "info.description", message: "Missing 'info.description' (recommended)" });
92
+ }
93
+ }
94
+
95
+ function validatePaths(doc, errors, warnings, isV3) {
96
+ if (!doc.paths) {
97
+ errors.push({ path: "paths", message: "Missing required 'paths' object" });
98
+ return;
99
+ }
100
+
101
+ if (Object.keys(doc.paths).length === 0) {
102
+ warnings.push({ path: "paths", message: "No paths defined" });
103
+ return;
104
+ }
105
+
106
+ const METHODS = new Set(["get", "post", "put", "patch", "delete", "options", "head", "trace"]);
107
+ const operationIds = new Set();
108
+
109
+ for (const [path, methods] of Object.entries(doc.paths)) {
110
+ // Path must start with /
111
+ if (!path.startsWith("/")) {
112
+ errors.push({ path: `paths.${path}`, message: `Path must start with '/'` });
113
+ }
114
+
115
+ // Check for unbalanced path params
116
+ const pathParams = (path.match(/\{([^}]+)\}/g) || []).map((p) => p.slice(1, -1));
117
+
118
+ if (typeof methods !== "object" || methods === null) continue;
119
+
120
+ for (const [method, op] of Object.entries(methods)) {
121
+ if (method.startsWith("x-") || method === "parameters" || method === "$ref") continue;
122
+
123
+ if (!METHODS.has(method)) {
124
+ warnings.push({ path: `paths.${path}.${method}`, message: `Unknown HTTP method '${method}'` });
125
+ continue;
126
+ }
127
+
128
+ if (typeof op !== "object" || op === null) continue;
129
+
130
+ const opPath = `paths.${path}.${method.toUpperCase()}`;
131
+
132
+ // operationId uniqueness
133
+ if (op.operationId) {
134
+ if (operationIds.has(op.operationId)) {
135
+ errors.push({ path: opPath, message: `Duplicate operationId '${op.operationId}'` });
136
+ }
137
+ operationIds.add(op.operationId);
138
+ } else {
139
+ warnings.push({ path: opPath, message: "Missing operationId (recommended for agent use)" });
140
+ }
141
+
142
+ // Responses required
143
+ if (!op.responses || Object.keys(op.responses).length === 0) {
144
+ errors.push({ path: opPath, message: "Missing or empty 'responses'" });
145
+ }
146
+
147
+ // Check path params are declared
148
+ const declaredParams = new Set(
149
+ (op.parameters || [])
150
+ .filter((p) => (p.in || p.$ref) && (p.in === "path" || !p.in))
151
+ .map((p) => p.name)
152
+ );
153
+
154
+ // Also include path-level parameters
155
+ const pathLevelParams = (methods.parameters || [])
156
+ .filter((p) => p.in === "path")
157
+ .map((p) => p.name);
158
+
159
+ for (const n of pathLevelParams) declaredParams.add(n);
160
+
161
+ for (const param of pathParams) {
162
+ if (!declaredParams.has(param)) {
163
+ // Only warn — the param might be declared via $ref
164
+ warnings.push({ path: opPath, message: `Path parameter '{${param}}' may not be declared in parameters` });
165
+ }
166
+ }
167
+
168
+ // Request body on GET/DELETE/HEAD
169
+ if (isV3 && op.requestBody && ["get", "delete", "head"].includes(method)) {
170
+ warnings.push({ path: opPath, message: `requestBody on ${method.toUpperCase()} is unusual` });
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ function validateComponentsV3(doc, errors, warnings) {
177
+ if (!doc.components) return;
178
+
179
+ // Check schemas have valid types
180
+ if (doc.components.schemas) {
181
+ for (const [name, schema] of Object.entries(doc.components.schemas)) {
182
+ validateSchema(schema, `components.schemas.${name}`, errors, warnings);
183
+ }
184
+ }
185
+ }
186
+
187
+ function validateDefinitionsV2(doc, errors, warnings) {
188
+ if (!doc.definitions) return;
189
+
190
+ for (const [name, schema] of Object.entries(doc.definitions)) {
191
+ validateSchema(schema, `definitions.${name}`, errors, warnings);
192
+ }
193
+ }
194
+
195
+ function validateSchema(schema, path, errors, warnings) {
196
+ if (!schema || typeof schema !== "object") return;
197
+ if (schema.$ref) return; // reference, skip
198
+
199
+ const VALID_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object", "null"]);
200
+
201
+ if (schema.type && !VALID_TYPES.has(schema.type)) {
202
+ errors.push({ path, message: `Invalid type '${schema.type}'` });
203
+ }
204
+
205
+ if (schema.type === "array" && !schema.items) {
206
+ errors.push({ path, message: "Array type must have 'items'" });
207
+ }
208
+
209
+ // Recurse into properties
210
+ if (schema.properties) {
211
+ for (const [key, val] of Object.entries(schema.properties)) {
212
+ validateSchema(val, `${path}.properties.${key}`, errors, warnings);
213
+ }
214
+ }
215
+ if (schema.items) {
216
+ validateSchema(schema.items, `${path}.items`, errors, warnings);
217
+ }
218
+ }
219
+
220
+ function validateServers(doc, errors, warnings, isV3) {
221
+ if (isV3) {
222
+ if (!doc.servers || doc.servers.length === 0) {
223
+ warnings.push({ path: "servers", message: "No servers defined — agent will need baseUrl configured" });
224
+ }
225
+ } else {
226
+ if (!doc.host) {
227
+ warnings.push({ path: "host", message: "No host defined — agent will need baseUrl configured" });
228
+ }
229
+ }
230
+ }
231
+
232
+ function validateRefs(doc, root, path, errors) {
233
+ if (!doc || typeof doc !== "object") return;
234
+
235
+ if (doc.$ref && typeof doc.$ref === "string") {
236
+ if (doc.$ref.startsWith("#/")) {
237
+ const parts = doc.$ref.slice(2).split("/");
238
+ let target = root;
239
+ for (const p of parts) {
240
+ target = target?.[p];
241
+ }
242
+ if (target === undefined) {
243
+ errors.push({ path: path || doc.$ref, message: `Broken $ref: '${doc.$ref}'` });
244
+ }
245
+ }
246
+ return; // Don't recurse into $ref
247
+ }
248
+
249
+ if (Array.isArray(doc)) {
250
+ for (let i = 0; i < doc.length; i++) {
251
+ validateRefs(doc[i], root, `${path}[${i}]`, errors);
252
+ }
253
+ } else {
254
+ for (const [key, val] of Object.entries(doc)) {
255
+ if (key === "raw") continue; // skip stored raw data
256
+ validateRefs(val, root, path ? `${path}.${key}` : key, errors);
257
+ }
258
+ }
259
+ }
260
+
261
+ function countOperations(doc) {
262
+ let count = 0;
263
+ for (const methods of Object.values(doc.paths || {})) {
264
+ for (const method of Object.keys(methods)) {
265
+ if (!method.startsWith("x-") && method !== "parameters" && method !== "$ref") count++;
266
+ }
267
+ }
268
+ return count;
269
+ }