@askills/openapi-explorer-cli 0.0.3 → 0.0.4
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/dist/index.d.mts +1 -0
- package/dist/index.mjs +589 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +4 -1
- package/CHANGELOG.md +0 -13
- package/src/cli/args.ts +0 -59
- package/src/cli/dispatch.ts +0 -80
- package/src/commands/endpoint.ts +0 -119
- package/src/commands/info.ts +0 -44
- package/src/commands/paths.ts +0 -43
- package/src/commands/schema.ts +0 -22
- package/src/commands/schemas.ts +0 -45
- package/src/commands/search.ts +0 -49
- package/src/commands/tags.ts +0 -11
- package/src/core/loader.ts +0 -38
- package/src/core/queries.ts +0 -165
- package/src/core/resolve.ts +0 -70
- package/src/formatters/endpoint.ts +0 -74
- package/src/formatters/schema.ts +0 -66
- package/src/index.ts +0 -12
- package/src/types.ts +0 -60
- package/test/openapi.json +0 -30965
- package/test/swagger.test.ts +0 -778
- package/tsconfig.json +0 -10
- package/tsdown.config.ts +0 -5
package/test/swagger.test.ts
DELETED
|
@@ -1,778 +0,0 @@
|
|
|
1
|
-
import { describe, it, before } from "node:test";
|
|
2
|
-
import assert from "node:assert";
|
|
3
|
-
import { resolve, dirname } from "node:path";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { writeFile, unlink } from "node:fs/promises";
|
|
6
|
-
|
|
7
|
-
import type { OpenAPISpec, FormattedEndpoint } from "../src/types.ts";
|
|
8
|
-
import { loadSpec, getServerUrl } from "../src/core/loader.ts";
|
|
9
|
-
import {
|
|
10
|
-
getEndpoints,
|
|
11
|
-
getTags,
|
|
12
|
-
getSchemas,
|
|
13
|
-
getSchemaDetail,
|
|
14
|
-
getPathParams,
|
|
15
|
-
searchSpec,
|
|
16
|
-
} from "../src/core/queries.ts";
|
|
17
|
-
import { resolveRef, deepResolve } from "../src/core/resolve.ts";
|
|
18
|
-
import { fmtSchema } from "../src/formatters/schema.ts";
|
|
19
|
-
import { fmtEndpoint } from "../src/formatters/endpoint.ts";
|
|
20
|
-
|
|
21
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
-
const SPEC_PATH = resolve(__dirname, "openapi.json");
|
|
23
|
-
let spec: OpenAPISpec;
|
|
24
|
-
|
|
25
|
-
describe("core/loader", () => {
|
|
26
|
-
before(async () => {
|
|
27
|
-
spec = await loadSpec(SPEC_PATH);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("loadSpec", () => {
|
|
31
|
-
it("should load a valid OpenAPI spec from file", async () => {
|
|
32
|
-
const s = await loadSpec(SPEC_PATH);
|
|
33
|
-
assert.equal(s.info.title, "opencode");
|
|
34
|
-
assert.equal(s.info.version, "1.0.0");
|
|
35
|
-
assert.equal(s.openapi, "3.1.0");
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("should throw for non-existent file", async () => {
|
|
39
|
-
await assert.rejects(
|
|
40
|
-
() => loadSpec("/nonexistent/path.json"),
|
|
41
|
-
/ENOENT|not found/,
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("should throw for invalid JSON", async () => {
|
|
46
|
-
const tmp = "/tmp/invalid-spec.json";
|
|
47
|
-
await writeFile(tmp, "not json");
|
|
48
|
-
await assert.rejects(() => loadSpec(tmp), /JSON|parse|Unexpected/);
|
|
49
|
-
await unlink(tmp);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("should throw for non-OpenAPI JSON", async () => {
|
|
53
|
-
const tmp = "/tmp/not-openapi.json";
|
|
54
|
-
await writeFile(tmp, JSON.stringify({ name: "foo" }));
|
|
55
|
-
await assert.rejects(() => loadSpec(tmp), /valid OpenAPI|Swagger/);
|
|
56
|
-
await unlink(tmp);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("should throw for spec missing info", async () => {
|
|
60
|
-
const tmp = "/tmp/no-info.json";
|
|
61
|
-
await writeFile(tmp, JSON.stringify({ openapi: "3.1.0", paths: {} }));
|
|
62
|
-
await assert.rejects(() => loadSpec(tmp), /missing 'info'/);
|
|
63
|
-
await unlink(tmp);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("should set defaults for missing title/version", async () => {
|
|
67
|
-
const tmp = "/tmp/minimal-spec.json";
|
|
68
|
-
await writeFile(
|
|
69
|
-
tmp,
|
|
70
|
-
JSON.stringify({ openapi: "3.1.0", info: {}, paths: {} }),
|
|
71
|
-
);
|
|
72
|
-
const s = await loadSpec(tmp);
|
|
73
|
-
assert.equal(s.info.title, "Untitled API");
|
|
74
|
-
assert.equal(s.info.version, "0.0.0");
|
|
75
|
-
await unlink(tmp);
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("getServerUrl", () => {
|
|
80
|
-
it("should extract OpenAPI v3 server URL", () => {
|
|
81
|
-
const s = {
|
|
82
|
-
...spec,
|
|
83
|
-
servers: [{ url: "https://api.example.com/v1" }],
|
|
84
|
-
} as OpenAPISpec;
|
|
85
|
-
assert.equal(getServerUrl(s), "https://api.example.com/v1");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("should strip trailing slash from server URL", () => {
|
|
89
|
-
const s = {
|
|
90
|
-
...spec,
|
|
91
|
-
servers: [{ url: "https://api.example.com/" }],
|
|
92
|
-
} as OpenAPISpec;
|
|
93
|
-
assert.equal(getServerUrl(s), "https://api.example.com");
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it("should construct URL from Swagger v2 fields", () => {
|
|
97
|
-
const s = {
|
|
98
|
-
...spec,
|
|
99
|
-
servers: undefined,
|
|
100
|
-
host: "api.example.com",
|
|
101
|
-
basePath: "/v2",
|
|
102
|
-
schemes: ["https"],
|
|
103
|
-
} as unknown as OpenAPISpec;
|
|
104
|
-
assert.equal(getServerUrl(s), "https://api.example.com/v2");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("should default to https for Swagger v2 without schemes", () => {
|
|
108
|
-
const s = {
|
|
109
|
-
...spec,
|
|
110
|
-
servers: undefined,
|
|
111
|
-
host: "api.example.com",
|
|
112
|
-
basePath: "/api",
|
|
113
|
-
schemes: undefined,
|
|
114
|
-
} as unknown as OpenAPISpec;
|
|
115
|
-
assert.equal(getServerUrl(s), "https://api.example.com/api");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("should return empty string when no server info", () => {
|
|
119
|
-
const s = {
|
|
120
|
-
...spec,
|
|
121
|
-
servers: undefined,
|
|
122
|
-
host: undefined,
|
|
123
|
-
} as unknown as OpenAPISpec;
|
|
124
|
-
assert.equal(getServerUrl(s), "");
|
|
125
|
-
});
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
describe("core/queries", () => {
|
|
130
|
-
before(async () => {
|
|
131
|
-
spec = await loadSpec(SPEC_PATH);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe("getEndpoints", () => {
|
|
135
|
-
it("should return all 165 endpoints", () => {
|
|
136
|
-
assert.equal(getEndpoints(spec).length, 165);
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it("should filter endpoints by tag", () => {
|
|
140
|
-
const eps = getEndpoints(spec, "global");
|
|
141
|
-
assert.ok(eps.length > 0);
|
|
142
|
-
for (const ep of eps) assert.ok(ep.tags.includes("global"));
|
|
143
|
-
assert.equal(eps.length, 6);
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("should return empty array for unknown tag", () => {
|
|
147
|
-
assert.deepEqual(getEndpoints(spec, "nonexistent-tag"), []);
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it("should sort by path then method priority", () => {
|
|
151
|
-
const eps = getEndpoints(spec);
|
|
152
|
-
for (let i = 1; i < eps.length; i++) {
|
|
153
|
-
const prev = eps[i - 1]!;
|
|
154
|
-
const curr = eps[i]!;
|
|
155
|
-
if (prev.path === curr.path) {
|
|
156
|
-
const order = [
|
|
157
|
-
"get",
|
|
158
|
-
"post",
|
|
159
|
-
"put",
|
|
160
|
-
"patch",
|
|
161
|
-
"delete",
|
|
162
|
-
"options",
|
|
163
|
-
"head",
|
|
164
|
-
];
|
|
165
|
-
assert.ok(order.indexOf(prev.method) <= order.indexOf(curr.method));
|
|
166
|
-
} else {
|
|
167
|
-
assert.ok(prev.path.localeCompare(curr.path) <= 0);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("should include global.health endpoint", () => {
|
|
173
|
-
const ep = getEndpoints(spec, "global").find(
|
|
174
|
-
(e) => e.operationId === "global.health",
|
|
175
|
-
);
|
|
176
|
-
assert.ok(ep);
|
|
177
|
-
assert.equal(ep?.method, "get");
|
|
178
|
-
assert.equal(ep?.path, "/global/health");
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it("should mark deprecated endpoints", () => {
|
|
182
|
-
const eps = getEndpoints(spec);
|
|
183
|
-
const deprecated = eps.filter((e) => e.deprecated);
|
|
184
|
-
assert.ok(
|
|
185
|
-
deprecated.length > 0,
|
|
186
|
-
"Expected at least one deprecated endpoint",
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("getTags", () => {
|
|
192
|
-
it("should return 32 tags", () => {
|
|
193
|
-
assert.equal(getTags(spec).length, 32);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("should include global with 6 endpoints", () => {
|
|
197
|
-
const t = getTags(spec).find((t) => t.name === "global");
|
|
198
|
-
assert.ok(t);
|
|
199
|
-
assert.equal(t?.count, 6);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("should be sorted alphabetically", () => {
|
|
203
|
-
const tags = getTags(spec);
|
|
204
|
-
for (let i = 1; i < tags.length; i++) {
|
|
205
|
-
assert.ok(tags[i - 1]!.name.localeCompare(tags[i]!.name) <= 0);
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe("getSchemas", () => {
|
|
211
|
-
it("should return all 342 schemas", () => {
|
|
212
|
-
assert.equal(getSchemas(spec).length, 342);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("should include Config schema with 35 properties", () => {
|
|
216
|
-
const s = getSchemas(spec).find((s) => s.name === "Config");
|
|
217
|
-
assert.ok(s);
|
|
218
|
-
assert.equal(s?.type, "object");
|
|
219
|
-
assert.equal(s?.properties, 35);
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it("should include string-type schemas", () => {
|
|
223
|
-
const s = getSchemas(spec).find((s) => s.name === "LayoutConfig");
|
|
224
|
-
assert.ok(s);
|
|
225
|
-
assert.equal(s?.type, "string");
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("should include array-type schemas", () => {
|
|
229
|
-
const s = getSchemas(spec).find((s) => s.name === "ToolList");
|
|
230
|
-
assert.ok(s);
|
|
231
|
-
assert.equal(s?.type, "array");
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
it("should be sorted alphabetically", () => {
|
|
235
|
-
const schemas = getSchemas(spec);
|
|
236
|
-
for (let i = 1; i < schemas.length; i++) {
|
|
237
|
-
assert.ok(schemas[i - 1]!.name.localeCompare(schemas[i]!.name) <= 0);
|
|
238
|
-
}
|
|
239
|
-
});
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
describe("getSchemaDetail", () => {
|
|
243
|
-
it("should return details for known schema", () => {
|
|
244
|
-
const s = getSchemaDetail(spec, "Config") as Record<string, unknown>;
|
|
245
|
-
assert.equal(s?.type, "object");
|
|
246
|
-
assert.ok(s?.properties);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it("should return null for unknown schema", () => {
|
|
250
|
-
assert.equal(getSchemaDetail(spec, "NonExistentSchema"), null);
|
|
251
|
-
});
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
describe("getPathParams", () => {
|
|
255
|
-
it("should extract single path param", () => {
|
|
256
|
-
assert.deepEqual(getPathParams("/auth/{providerID}"), ["providerID"]);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("should return empty for path without params", () => {
|
|
260
|
-
assert.deepEqual(getPathParams("/global/health"), []);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("should extract multiple params", () => {
|
|
264
|
-
assert.deepEqual(getPathParams("/users/{uid}/posts/{pid}"), [
|
|
265
|
-
"uid",
|
|
266
|
-
"pid",
|
|
267
|
-
]);
|
|
268
|
-
});
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
describe("searchSpec", () => {
|
|
272
|
-
it("should find endpoints by operationId", () => {
|
|
273
|
-
const r = searchSpec(spec, "global.health");
|
|
274
|
-
assert.ok(
|
|
275
|
-
r.some(
|
|
276
|
-
(x) =>
|
|
277
|
-
x.type === "endpoint" &&
|
|
278
|
-
x.path === "/global/health" &&
|
|
279
|
-
x.method === "get",
|
|
280
|
-
),
|
|
281
|
-
);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("should find endpoints by summary", () => {
|
|
285
|
-
const r = searchSpec(spec, "Get health");
|
|
286
|
-
assert.ok(
|
|
287
|
-
r.some((x) => x.type === "endpoint" && x.path === "/global/health"),
|
|
288
|
-
);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it("should find endpoints by path", () => {
|
|
292
|
-
const r = searchSpec(spec, "/global/health");
|
|
293
|
-
assert.ok(
|
|
294
|
-
r.some((x) => x.type === "endpoint" && x.path === "/global/health"),
|
|
295
|
-
);
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("should find schemas by name", () => {
|
|
299
|
-
const r = searchSpec(spec, "Config");
|
|
300
|
-
assert.ok(
|
|
301
|
-
r.some((x) => x.type === "schema" && x.schemaName === "Config"),
|
|
302
|
-
);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("should find properties by name", () => {
|
|
306
|
-
const r = searchSpec(spec, "sessionID");
|
|
307
|
-
assert.ok(
|
|
308
|
-
r.some((x) => x.type === "property" && x.propertyName === "sessionID"),
|
|
309
|
-
);
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("should be case-insensitive", () => {
|
|
313
|
-
assert.equal(
|
|
314
|
-
searchSpec(spec, "health").length,
|
|
315
|
-
searchSpec(spec, "HEALTH").length,
|
|
316
|
-
);
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("should return empty for no match", () => {
|
|
320
|
-
assert.deepEqual(searchSpec(spec, "xyznonexistent12345"), []);
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
describe("cross-field consistency", () => {
|
|
325
|
-
it("tag endpoints sum >= total (due to multi-tag)", () => {
|
|
326
|
-
const total = getEndpoints(spec).length;
|
|
327
|
-
const tagSum = getTags(spec).reduce((s, t) => s + t.count, 0);
|
|
328
|
-
assert.ok(tagSum >= total);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it("every listed schema has a detail", () => {
|
|
332
|
-
for (const s of getSchemas(spec)) {
|
|
333
|
-
assert.ok(
|
|
334
|
-
getSchemaDetail(spec, s.name),
|
|
335
|
-
`Missing detail for "${s.name}"`,
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
it("endpoints found via tag are also searchable", () => {
|
|
341
|
-
for (const ep of getEndpoints(spec, "experimental")) {
|
|
342
|
-
const r = searchSpec(spec, ep.operationId || ep.summary);
|
|
343
|
-
assert.ok(r.length > 0);
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
});
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
describe("core/resolve", () => {
|
|
350
|
-
before(async () => {
|
|
351
|
-
spec = await loadSpec(SPEC_PATH);
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
describe("resolveRef", () => {
|
|
355
|
-
it("should resolve valid $ref to schema", () => {
|
|
356
|
-
const resolved = resolveRef(
|
|
357
|
-
spec,
|
|
358
|
-
"#/components/schemas/Config",
|
|
359
|
-
) as Record<string, unknown>;
|
|
360
|
-
assert.ok(resolved);
|
|
361
|
-
assert.ok(resolved?.properties);
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
it("should return null for invalid $ref path", () => {
|
|
365
|
-
assert.equal(resolveRef(spec, "#/components/schemas/NonExistent"), null);
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
it("should return null for malformed $ref", () => {
|
|
369
|
-
assert.equal(resolveRef(spec, "invalid-ref"), null);
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
|
|
373
|
-
describe("deepResolve", () => {
|
|
374
|
-
it("should resolve a simple $ref", () => {
|
|
375
|
-
const resolved = deepResolve(
|
|
376
|
-
{ $ref: "#/components/schemas/Config" },
|
|
377
|
-
spec,
|
|
378
|
-
) as Record<string, unknown>;
|
|
379
|
-
assert.equal(resolved.type, "object");
|
|
380
|
-
assert.ok(resolved.properties);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("should deeply resolve nested $ref in properties", () => {
|
|
384
|
-
const schema = deepResolve(
|
|
385
|
-
{
|
|
386
|
-
type: "object",
|
|
387
|
-
properties: {
|
|
388
|
-
config: { $ref: "#/components/schemas/Config" },
|
|
389
|
-
items: {
|
|
390
|
-
type: "array",
|
|
391
|
-
items: { $ref: "#/components/schemas/ToolList" },
|
|
392
|
-
},
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
spec,
|
|
396
|
-
) as Record<string, unknown>;
|
|
397
|
-
const props = schema.properties as Record<string, unknown>;
|
|
398
|
-
assert.equal((props.config as Record<string, unknown>)?.type, "object");
|
|
399
|
-
assert.equal((props.items as Record<string, unknown>)?.type, "array");
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
it("should resolve $ref in oneOf / anyOf / allOf", () => {
|
|
403
|
-
const schema = deepResolve(
|
|
404
|
-
{
|
|
405
|
-
oneOf: [
|
|
406
|
-
{ $ref: "#/components/schemas/Config" },
|
|
407
|
-
{ $ref: "#/components/schemas/ToolList" },
|
|
408
|
-
],
|
|
409
|
-
},
|
|
410
|
-
spec,
|
|
411
|
-
) as Record<string, unknown>;
|
|
412
|
-
assert.ok(Array.isArray(schema.oneOf));
|
|
413
|
-
assert.equal(schema.oneOf?.length, 2);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
it("should merge sibling properties with $ref", () => {
|
|
417
|
-
const resolved = deepResolve(
|
|
418
|
-
{ $ref: "#/components/schemas/Config", description: "override" },
|
|
419
|
-
spec,
|
|
420
|
-
) as Record<string, unknown>;
|
|
421
|
-
assert.equal(resolved.description, "override");
|
|
422
|
-
assert.ok(resolved.properties);
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
it("should handle circular references without infinite loop", () => {
|
|
426
|
-
const schemas = spec.components?.schemas || {};
|
|
427
|
-
const circularKey = Object.keys(schemas).find((k) =>
|
|
428
|
-
JSON.stringify(schemas[k]).includes("#/components/schemas/" + k),
|
|
429
|
-
);
|
|
430
|
-
if (circularKey) {
|
|
431
|
-
const resolved = deepResolve(
|
|
432
|
-
{ $ref: `#/components/schemas/${circularKey}` },
|
|
433
|
-
spec,
|
|
434
|
-
);
|
|
435
|
-
assert.ok(resolved);
|
|
436
|
-
}
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
it("should resolve deeply (3+ levels of nesting)", () => {
|
|
440
|
-
const schema = deepResolve(
|
|
441
|
-
{
|
|
442
|
-
type: "object",
|
|
443
|
-
properties: {
|
|
444
|
-
deep: {
|
|
445
|
-
type: "object",
|
|
446
|
-
properties: { inner: { $ref: "#/components/schemas/Config" } },
|
|
447
|
-
},
|
|
448
|
-
},
|
|
449
|
-
},
|
|
450
|
-
spec,
|
|
451
|
-
) as Record<string, unknown>;
|
|
452
|
-
const props = schema.properties as Record<string, unknown>;
|
|
453
|
-
const deep = props.deep as Record<string, unknown>;
|
|
454
|
-
const innerProps = deep.properties as Record<string, unknown>;
|
|
455
|
-
assert.equal(
|
|
456
|
-
(innerProps.inner as Record<string, unknown>)?.type,
|
|
457
|
-
"object",
|
|
458
|
-
);
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
it("should return input as-is for schema without $ref", () => {
|
|
462
|
-
const resolved = deepResolve({ type: "string" }, spec) as Record<
|
|
463
|
-
string,
|
|
464
|
-
unknown
|
|
465
|
-
>;
|
|
466
|
-
assert.equal(resolved.type, "string");
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
it("should return unresolvable $ref as-is", () => {
|
|
470
|
-
const resolved = deepResolve(
|
|
471
|
-
{ $ref: "#/components/schemas/NonExistent" },
|
|
472
|
-
spec,
|
|
473
|
-
);
|
|
474
|
-
assert.deepEqual(resolved, { $ref: "#/components/schemas/NonExistent" });
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
describe("formatters/schema", () => {
|
|
480
|
-
describe("fmtSchema", () => {
|
|
481
|
-
it("should format simple type", () => {
|
|
482
|
-
assert.equal(fmtSchema({ type: "string" }), "type: string");
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
it("should format type with format", () => {
|
|
486
|
-
assert.equal(
|
|
487
|
-
fmtSchema({ type: "string", format: "date-time" }),
|
|
488
|
-
"type: string<date-time>",
|
|
489
|
-
);
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
it("should format nullable type", () => {
|
|
493
|
-
assert.equal(
|
|
494
|
-
fmtSchema({ type: "string", nullable: true }),
|
|
495
|
-
"type: string | null",
|
|
496
|
-
);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("should format enum", () => {
|
|
500
|
-
assert.equal(
|
|
501
|
-
fmtSchema({ type: "string", enum: ["a", "b", "c"] }),
|
|
502
|
-
'enum: ["a", "b", "c"]',
|
|
503
|
-
);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
it("should format $ref", () => {
|
|
507
|
-
assert.equal(
|
|
508
|
-
fmtSchema({ $ref: "#/components/schemas/Config" }),
|
|
509
|
-
'$ref: "Config"',
|
|
510
|
-
);
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
it("should format properties with required marker", () => {
|
|
514
|
-
const result = fmtSchema({
|
|
515
|
-
type: "object",
|
|
516
|
-
required: ["name"],
|
|
517
|
-
properties: { name: { type: "string" }, age: { type: "integer" } },
|
|
518
|
-
});
|
|
519
|
-
assert.match(result, /name \(required\):\n type: string/);
|
|
520
|
-
assert.match(result, /age:\n type: integer/);
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
it("should format nested objects", () => {
|
|
524
|
-
const result = fmtSchema({
|
|
525
|
-
type: "object",
|
|
526
|
-
properties: {
|
|
527
|
-
nested: { type: "object", properties: { x: { type: "number" } } },
|
|
528
|
-
},
|
|
529
|
-
});
|
|
530
|
-
assert.match(result, /nested:\n type: object\n x:\n type: number/);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
it("should format array items", () => {
|
|
534
|
-
const result = fmtSchema({ type: "array", items: { type: "string" } });
|
|
535
|
-
assert.equal(result, "type: array\nitems:\n type: string");
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it("should format anyOf with separators", () => {
|
|
539
|
-
const result = fmtSchema({
|
|
540
|
-
anyOf: [{ type: "string" }, { type: "integer" }],
|
|
541
|
-
});
|
|
542
|
-
assert.equal(result, "anyOf:\n type: string\n ---\n type: integer");
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
it("should format oneOf with separators", () => {
|
|
546
|
-
const result = fmtSchema({
|
|
547
|
-
oneOf: [
|
|
548
|
-
{ $ref: "#/components/schemas/Foo" },
|
|
549
|
-
{ $ref: "#/components/schemas/Bar" },
|
|
550
|
-
],
|
|
551
|
-
});
|
|
552
|
-
assert.equal(result, 'oneOf:\n $ref: "Foo"\n ---\n $ref: "Bar"');
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it("should format description as comment", () => {
|
|
556
|
-
const result = fmtSchema({ type: "string", description: "A name" });
|
|
557
|
-
assert.equal(result, "// A name\ntype: string");
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it("should format constraints", () => {
|
|
561
|
-
const result = fmtSchema({
|
|
562
|
-
type: "string",
|
|
563
|
-
minLength: 1,
|
|
564
|
-
maxLength: 100,
|
|
565
|
-
pattern: "^[a-z]+$",
|
|
566
|
-
});
|
|
567
|
-
assert.match(result, /minLength: 1/);
|
|
568
|
-
assert.match(result, /maxLength: 100/);
|
|
569
|
-
assert.match(result, /pattern: \^\[a-z\]\+\$/);
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
it("should format example and default", () => {
|
|
573
|
-
const result = fmtSchema({ type: "number", example: 42, default: 0 });
|
|
574
|
-
assert.match(result, /example: 42/);
|
|
575
|
-
assert.match(result, /default: 0/);
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
it("should format additionalProperties", () => {
|
|
579
|
-
const result = fmtSchema({
|
|
580
|
-
type: "object",
|
|
581
|
-
additionalProperties: { type: "string" },
|
|
582
|
-
});
|
|
583
|
-
assert.match(result, /additionalProperties:\n type: string/);
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
it("should handle null/undefined schema", () => {
|
|
587
|
-
assert.equal(fmtSchema(null), "null");
|
|
588
|
-
assert.equal(fmtSchema(undefined), "undefined");
|
|
589
|
-
});
|
|
590
|
-
|
|
591
|
-
it("should handle primitive schema", () => {
|
|
592
|
-
assert.equal(fmtSchema("hello"), "hello");
|
|
593
|
-
});
|
|
594
|
-
|
|
595
|
-
it("should default to 'any' when no type", () => {
|
|
596
|
-
assert.equal(fmtSchema({}), "type: any");
|
|
597
|
-
});
|
|
598
|
-
});
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
describe("formatters/endpoint", () => {
|
|
602
|
-
describe("fmtEndpoint", () => {
|
|
603
|
-
it("should render basic endpoint header", () => {
|
|
604
|
-
const ep: FormattedEndpoint = {
|
|
605
|
-
path: "/health",
|
|
606
|
-
method: "get",
|
|
607
|
-
tags: [],
|
|
608
|
-
deprecated: false,
|
|
609
|
-
};
|
|
610
|
-
const result = fmtEndpoint(ep);
|
|
611
|
-
assert.match(result, /^# GET \/health/);
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
it("should mark deprecated endpoints", () => {
|
|
615
|
-
const ep: FormattedEndpoint = {
|
|
616
|
-
path: "/old",
|
|
617
|
-
method: "delete",
|
|
618
|
-
tags: [],
|
|
619
|
-
deprecated: true,
|
|
620
|
-
};
|
|
621
|
-
const result = fmtEndpoint(ep);
|
|
622
|
-
assert.match(result, /DEPRECATED/);
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
it("should include summary, description, operationId, tags", () => {
|
|
626
|
-
const ep: FormattedEndpoint = {
|
|
627
|
-
path: "/users",
|
|
628
|
-
method: "get",
|
|
629
|
-
summary: "List users",
|
|
630
|
-
description: "Returns a list of all users",
|
|
631
|
-
operationId: "users.list",
|
|
632
|
-
tags: ["users"],
|
|
633
|
-
deprecated: false,
|
|
634
|
-
};
|
|
635
|
-
const result = fmtEndpoint(ep);
|
|
636
|
-
assert.match(result, /List users/);
|
|
637
|
-
assert.match(result, /Returns a list of all users/);
|
|
638
|
-
assert.match(result, /users\.list/);
|
|
639
|
-
assert.match(result, /`users`/);
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it("should render parameters table", () => {
|
|
643
|
-
const ep: FormattedEndpoint = {
|
|
644
|
-
path: "/users/{id}",
|
|
645
|
-
method: "get",
|
|
646
|
-
tags: [],
|
|
647
|
-
deprecated: false,
|
|
648
|
-
parameters: [
|
|
649
|
-
{
|
|
650
|
-
name: "id",
|
|
651
|
-
in: "path",
|
|
652
|
-
required: true,
|
|
653
|
-
schema: { type: "string" },
|
|
654
|
-
},
|
|
655
|
-
{
|
|
656
|
-
name: "limit",
|
|
657
|
-
in: "query",
|
|
658
|
-
required: false,
|
|
659
|
-
schema: { type: "integer" },
|
|
660
|
-
},
|
|
661
|
-
],
|
|
662
|
-
};
|
|
663
|
-
const result = fmtEndpoint(ep);
|
|
664
|
-
assert.match(result, /\| `id` | path | string | Yes |/);
|
|
665
|
-
assert.match(result, /\| `limit` | query | integer | No |/);
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
it("should render request body", () => {
|
|
669
|
-
const ep: FormattedEndpoint = {
|
|
670
|
-
path: "/users",
|
|
671
|
-
method: "post",
|
|
672
|
-
tags: [],
|
|
673
|
-
deprecated: false,
|
|
674
|
-
requestBody: {
|
|
675
|
-
required: true,
|
|
676
|
-
description: "User data",
|
|
677
|
-
content: {
|
|
678
|
-
"application/json": {
|
|
679
|
-
schema: {
|
|
680
|
-
type: "object",
|
|
681
|
-
properties: { name: { type: "string" } },
|
|
682
|
-
},
|
|
683
|
-
},
|
|
684
|
-
},
|
|
685
|
-
},
|
|
686
|
-
};
|
|
687
|
-
const result = fmtEndpoint(ep);
|
|
688
|
-
assert.match(result, /## Request Body/);
|
|
689
|
-
assert.match(result, /> Required/);
|
|
690
|
-
assert.match(result, /User data/);
|
|
691
|
-
assert.match(result, /type: object/);
|
|
692
|
-
});
|
|
693
|
-
|
|
694
|
-
it("should render responses", () => {
|
|
695
|
-
const ep: FormattedEndpoint = {
|
|
696
|
-
path: "/health",
|
|
697
|
-
method: "get",
|
|
698
|
-
tags: [],
|
|
699
|
-
deprecated: false,
|
|
700
|
-
responses: {
|
|
701
|
-
"200": {
|
|
702
|
-
description: "OK",
|
|
703
|
-
content: { "application/json": { schema: { type: "object" } } },
|
|
704
|
-
},
|
|
705
|
-
"404": { description: "Not found" },
|
|
706
|
-
},
|
|
707
|
-
};
|
|
708
|
-
const result = fmtEndpoint(ep);
|
|
709
|
-
assert.match(result, /## Responses/);
|
|
710
|
-
assert.match(result, /### 200/);
|
|
711
|
-
assert.match(result, /OK/);
|
|
712
|
-
assert.match(result, /### 404/);
|
|
713
|
-
assert.match(result, /Not found/);
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
it("should render security", () => {
|
|
717
|
-
const ep: FormattedEndpoint = {
|
|
718
|
-
path: "/admin",
|
|
719
|
-
method: "get",
|
|
720
|
-
tags: [],
|
|
721
|
-
deprecated: false,
|
|
722
|
-
security: [{ apiKey: [] }, { bearer: ["admin"] }],
|
|
723
|
-
};
|
|
724
|
-
const result = fmtEndpoint(ep);
|
|
725
|
-
assert.match(result, /## Security/);
|
|
726
|
-
assert.match(result, /`apiKey`/);
|
|
727
|
-
assert.match(result, /`bearer` \[admin\]/);
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
it("should render a full real endpoint from spec", async () => {
|
|
731
|
-
const s = await loadSpec(SPEC_PATH);
|
|
732
|
-
const op = s.paths["/global/health"]?.get;
|
|
733
|
-
assert.ok(op);
|
|
734
|
-
const ep: FormattedEndpoint = {
|
|
735
|
-
path: "/global/health",
|
|
736
|
-
method: "get",
|
|
737
|
-
summary: op.summary,
|
|
738
|
-
description: op.description,
|
|
739
|
-
operationId: op.operationId,
|
|
740
|
-
tags: op.tags || [],
|
|
741
|
-
deprecated: op.deprecated || false,
|
|
742
|
-
responses: Object.fromEntries(
|
|
743
|
-
Object.entries(op.responses).map(([code, resp]: [string, any]) => [
|
|
744
|
-
code,
|
|
745
|
-
resp,
|
|
746
|
-
]),
|
|
747
|
-
),
|
|
748
|
-
};
|
|
749
|
-
const result = fmtEndpoint(ep);
|
|
750
|
-
assert.match(result, /GET \/global\/health/);
|
|
751
|
-
assert.match(result, /global\.health/);
|
|
752
|
-
assert.match(result, /### 200/);
|
|
753
|
-
});
|
|
754
|
-
});
|
|
755
|
-
});
|
|
756
|
-
|
|
757
|
-
describe("error-handling: loadSpec", () => {
|
|
758
|
-
it("should throw clear error for invalid JSON", async () => {
|
|
759
|
-
const tmp = "/tmp/bad-json.json";
|
|
760
|
-
await writeFile(tmp, "{bad}");
|
|
761
|
-
await assert.rejects(() => loadSpec(tmp), /JSON|parse|Unexpected/);
|
|
762
|
-
await unlink(tmp);
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
it("should throw clear error for non-OpenAPI JSON", async () => {
|
|
766
|
-
const tmp = "/tmp/not-api.json";
|
|
767
|
-
await writeFile(tmp, JSON.stringify({ random: "data" }));
|
|
768
|
-
await assert.rejects(() => loadSpec(tmp), /valid OpenAPI|Swagger/);
|
|
769
|
-
await unlink(tmp);
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
it("should throw clear error for missing info", async () => {
|
|
773
|
-
const tmp = "/tmp/no-info.json";
|
|
774
|
-
await writeFile(tmp, JSON.stringify({ openapi: "3.1.0", paths: {} }));
|
|
775
|
-
await assert.rejects(() => loadSpec(tmp), /missing 'info'/);
|
|
776
|
-
await unlink(tmp);
|
|
777
|
-
});
|
|
778
|
-
});
|