argsbarg 6.1.3 → 6.1.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/CHANGELOG.md +19 -1
- package/README.md +1 -1
- package/docs/http-server.md +22 -17
- package/examples/full-example/docs/cli-schema.json +9 -9
- package/examples/full-example/docs/cli.md +9 -9
- package/examples/full-example/docs/http.md +20 -20
- package/examples/full-example/docs/openapi.json +334 -14
- package/examples/servers.ts +2 -2
- package/index.d.ts +6 -1
- package/package.json +1 -1
- package/src/builtins/http.ts +3 -1
- package/src/core/types.ts +6 -1
- package/src/core/validate.ts +43 -0
- package/src/docs/http-guide.ts +15 -11
- package/src/http/openapi.ts +113 -5
- package/src/http/paths.ts +39 -0
- package/src/http/readiness.ts +1 -1
- package/src/http/result.ts +6 -6
- package/src/http/routes.ts +22 -9
- package/src/http/server.ts +8 -12
- package/src/test/integration/http.test.ts +165 -28
|
@@ -178,20 +178,40 @@ describe("httpServer validation", () => {
|
|
|
178
178
|
} as unknown as import("~/core/types.ts").CliProgram;
|
|
179
179
|
expect(() => cliValidateProgram(root)).toThrow(/httpServer is only supported on the program root/);
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
test("rejects reserved top-level command when pathPrefix is empty", () => {
|
|
183
|
+
const root = testProgram({
|
|
184
|
+
key: "app",
|
|
185
|
+
description: "",
|
|
186
|
+
httpServer: { enabled: true },
|
|
187
|
+
commands: [{ key: "health", description: "user", handler: () => {} }],
|
|
188
|
+
});
|
|
189
|
+
expect(() => cliValidateProgram(root)).toThrow(/Reserved HTTP command name/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("rejects invalid pathPrefix", () => {
|
|
193
|
+
const root = testProgram({
|
|
194
|
+
key: "app",
|
|
195
|
+
description: "",
|
|
196
|
+
httpServer: { enabled: true, pathPrefix: "api" },
|
|
197
|
+
handler: () => {},
|
|
198
|
+
});
|
|
199
|
+
expect(() => cliValidateProgram(root)).toThrow(/pathPrefix must start with \//);
|
|
200
|
+
});
|
|
181
201
|
});
|
|
182
202
|
|
|
183
203
|
describe("HTTP API routes", () => {
|
|
184
204
|
const program = nestedApiFixture();
|
|
185
205
|
cliValidateProgram(program);
|
|
186
206
|
|
|
187
|
-
test("GET /health/
|
|
188
|
-
const res = await apiRequest(program, new Request("http://127.0.0.1/health/
|
|
207
|
+
test("GET /health/liveness returns ok", async () => {
|
|
208
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness"));
|
|
189
209
|
expect(res.status).toBe(200);
|
|
190
210
|
expect(await res.json()).toEqual({ ok: true });
|
|
191
211
|
});
|
|
192
212
|
|
|
193
|
-
test("GET /health/
|
|
194
|
-
const res = await apiRequest(program, new Request("http://127.0.0.1/health/
|
|
213
|
+
test("GET /health/readiness returns ok when healthy", async () => {
|
|
214
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/health/readiness"), { withServer: true });
|
|
195
215
|
expect(res.status).toBe(200);
|
|
196
216
|
const body = (await res.json()) as { ok: boolean; checks: Record<string, { ok: boolean }> };
|
|
197
217
|
expect(body.ok).toBe(true);
|
|
@@ -199,7 +219,7 @@ describe("HTTP API routes", () => {
|
|
|
199
219
|
expect(body.checks.config_required.ok).toBe(true);
|
|
200
220
|
});
|
|
201
221
|
|
|
202
|
-
test("GET /health/
|
|
222
|
+
test("GET /health/readiness returns 503 when custom readiness fails", async () => {
|
|
203
223
|
const failProgram = testProgram({
|
|
204
224
|
key: "app",
|
|
205
225
|
description: "Test",
|
|
@@ -209,7 +229,7 @@ describe("HTTP API routes", () => {
|
|
|
209
229
|
handler: () => ({ ok: true }),
|
|
210
230
|
});
|
|
211
231
|
cliValidateProgram(failProgram);
|
|
212
|
-
const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/
|
|
232
|
+
const res = await apiRequest(failProgram, new Request("http://127.0.0.1/health/readiness"), { withServer: true });
|
|
213
233
|
expect(res.status).toBe(503);
|
|
214
234
|
const body = (await res.json()) as { ok: boolean; checks: { custom: { ok: boolean } } };
|
|
215
235
|
expect(body.ok).toBe(false);
|
|
@@ -232,10 +252,7 @@ describe("HTTP API routes", () => {
|
|
|
232
252
|
],
|
|
233
253
|
});
|
|
234
254
|
cliValidateProgram(throwProgram);
|
|
235
|
-
const res = await apiRequest(
|
|
236
|
-
throwProgram,
|
|
237
|
-
new Request("http://127.0.0.1/api/boom", { method: "POST", body: "{}" }),
|
|
238
|
-
);
|
|
255
|
+
const res = await apiRequest(throwProgram, new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" }));
|
|
239
256
|
expect(res.status).toBe(500);
|
|
240
257
|
});
|
|
241
258
|
|
|
@@ -264,7 +281,7 @@ describe("HTTP API routes", () => {
|
|
|
264
281
|
};
|
|
265
282
|
const res = await handleApiRequest(
|
|
266
283
|
cli,
|
|
267
|
-
new Request("http://127.0.0.1/
|
|
284
|
+
new Request("http://127.0.0.1/boom", { method: "POST", body: "{}" }),
|
|
268
285
|
resolved,
|
|
269
286
|
);
|
|
270
287
|
expect(res.status).toBe(500);
|
|
@@ -280,7 +297,7 @@ describe("HTTP API routes", () => {
|
|
|
280
297
|
});
|
|
281
298
|
|
|
282
299
|
test("OPTIONS returns 204 with CORS headers", async () => {
|
|
283
|
-
const res = await apiRequest(program, new Request("http://127.0.0.1/
|
|
300
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/stat/owner/lookup", { method: "OPTIONS" }));
|
|
284
301
|
expect(res.status).toBe(204);
|
|
285
302
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
286
303
|
expect(res.headers.get("access-control-allow-methods")).toContain("POST");
|
|
@@ -290,7 +307,7 @@ describe("HTTP API routes", () => {
|
|
|
290
307
|
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
291
308
|
const res = await apiRequest(
|
|
292
309
|
program,
|
|
293
|
-
new Request("http://127.0.0.1/
|
|
310
|
+
new Request("http://127.0.0.1/stat/owner/lookup", {
|
|
294
311
|
method: "POST",
|
|
295
312
|
headers: { "content-type": "application/json" },
|
|
296
313
|
body: JSON.stringify({ "user-name": "alice", path: readme, json: true }),
|
|
@@ -305,7 +322,7 @@ describe("HTTP API routes", () => {
|
|
|
305
322
|
const readme = join(import.meta.dir, "..", "..", "..", "README.md");
|
|
306
323
|
const res = await apiRequest(
|
|
307
324
|
program,
|
|
308
|
-
new Request("http://127.0.0.1/
|
|
325
|
+
new Request("http://127.0.0.1/stat/owner/lookup", {
|
|
309
326
|
method: "POST",
|
|
310
327
|
headers: { "content-type": "application/json" },
|
|
311
328
|
body: JSON.stringify({ "user-name": "alice", path: readme }),
|
|
@@ -331,7 +348,7 @@ describe("HTTP API routes", () => {
|
|
|
331
348
|
test("POST /api/pdf returns PDF bytes with 201", async () => {
|
|
332
349
|
const res = await apiRequest(
|
|
333
350
|
program,
|
|
334
|
-
new Request("http://127.0.0.1/
|
|
351
|
+
new Request("http://127.0.0.1/pdf", {
|
|
335
352
|
method: "POST",
|
|
336
353
|
headers: { "content-type": "application/json" },
|
|
337
354
|
body: "{}",
|
|
@@ -346,7 +363,7 @@ describe("HTTP API routes", () => {
|
|
|
346
363
|
test("POST /api/html returns HTML with 201", async () => {
|
|
347
364
|
const res = await apiRequest(
|
|
348
365
|
program,
|
|
349
|
-
new Request("http://127.0.0.1/
|
|
366
|
+
new Request("http://127.0.0.1/html", {
|
|
350
367
|
method: "POST",
|
|
351
368
|
body: "{}",
|
|
352
369
|
}),
|
|
@@ -359,7 +376,7 @@ describe("HTTP API routes", () => {
|
|
|
359
376
|
test("POST /api/silent returns 500 when handler has no response", async () => {
|
|
360
377
|
const res = await apiRequest(
|
|
361
378
|
program,
|
|
362
|
-
new Request("http://127.0.0.1/
|
|
379
|
+
new Request("http://127.0.0.1/silent", {
|
|
363
380
|
method: "POST",
|
|
364
381
|
body: "{}",
|
|
365
382
|
}),
|
|
@@ -372,7 +389,7 @@ describe("HTTP API routes", () => {
|
|
|
372
389
|
test("POST /api returns 404 for unknown route", async () => {
|
|
373
390
|
const res = await apiRequest(
|
|
374
391
|
program,
|
|
375
|
-
new Request("http://127.0.0.1/
|
|
392
|
+
new Request("http://127.0.0.1/missing_tool", {
|
|
376
393
|
method: "POST",
|
|
377
394
|
headers: { "content-type": "application/json" },
|
|
378
395
|
body: "{}",
|
|
@@ -384,7 +401,7 @@ describe("HTTP API routes", () => {
|
|
|
384
401
|
test("POST /api returns 400 for bad args", async () => {
|
|
385
402
|
const res = await apiRequest(
|
|
386
403
|
program,
|
|
387
|
-
new Request("http://127.0.0.1/
|
|
404
|
+
new Request("http://127.0.0.1/stat/owner/lookup", {
|
|
388
405
|
method: "POST",
|
|
389
406
|
headers: { "content-type": "application/json" },
|
|
390
407
|
body: JSON.stringify({ "user-name": "alice" }),
|
|
@@ -415,7 +432,7 @@ describe("HTTP API routes", () => {
|
|
|
415
432
|
cliValidateProgram(failProgram);
|
|
416
433
|
const res = await apiRequest(
|
|
417
434
|
failProgram,
|
|
418
|
-
new Request("http://127.0.0.1/
|
|
435
|
+
new Request("http://127.0.0.1/fail", {
|
|
419
436
|
method: "POST",
|
|
420
437
|
headers: { "content-type": "application/json" },
|
|
421
438
|
body: "{}",
|
|
@@ -431,26 +448,100 @@ describe("HTTP API routes", () => {
|
|
|
431
448
|
expect(res.status).toBe(200);
|
|
432
449
|
const doc = (await res.json()) as { openapi: string; paths: Record<string, unknown> };
|
|
433
450
|
expect(doc.openapi).toBe("3.1.0");
|
|
434
|
-
expect(doc.paths["/
|
|
451
|
+
expect(doc.paths["/stat/owner/lookup"]).toBeDefined();
|
|
452
|
+
expect(doc.paths["/health"]).toBeDefined();
|
|
453
|
+
expect(doc.paths["/health/liveness"]).toBeDefined();
|
|
454
|
+
expect(doc.paths["/health/readiness"]).toBeDefined();
|
|
435
455
|
});
|
|
436
456
|
|
|
437
|
-
test("GET /
|
|
438
|
-
const res = await apiRequest(program, new Request("http://127.0.0.1/
|
|
457
|
+
test("GET /swagger returns Swagger UI HTML", async () => {
|
|
458
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/swagger"));
|
|
439
459
|
expect(res.status).toBe(200);
|
|
440
460
|
expect(res.headers.get("content-type")).toContain("text/html");
|
|
441
461
|
const html = await res.text();
|
|
442
|
-
expect(html).toContain("
|
|
443
|
-
expect(html).toContain('
|
|
444
|
-
expect(html).toContain(
|
|
462
|
+
expect(html).toContain("swagger-ui-dist");
|
|
463
|
+
expect(html).toContain('url: "/openapi.json"');
|
|
464
|
+
expect(html).toContain('dom_id: "#swagger-ui"');
|
|
445
465
|
});
|
|
446
466
|
});
|
|
447
467
|
|
|
468
|
+
test("generateOpenApi includes health probe paths", () => {
|
|
469
|
+
const program = testProgram({
|
|
470
|
+
key: "app",
|
|
471
|
+
description: "Test app",
|
|
472
|
+
httpServer: { enabled: true },
|
|
473
|
+
handler: () => ({ ok: true }),
|
|
474
|
+
});
|
|
475
|
+
cliValidateProgram(program);
|
|
476
|
+
const doc = generateOpenApi(program) as {
|
|
477
|
+
tags: { name: string }[];
|
|
478
|
+
paths: Record<
|
|
479
|
+
string,
|
|
480
|
+
{
|
|
481
|
+
get: {
|
|
482
|
+
tags: string[];
|
|
483
|
+
responses: Record<string, { content: Record<string, { schema: Record<string, unknown> }> }>;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
>;
|
|
487
|
+
};
|
|
488
|
+
expect(doc.tags.some((t) => t.name === "health")).toBe(true);
|
|
489
|
+
expect(doc.paths["/health"]?.get.tags).toContain("health");
|
|
490
|
+
expect(doc.paths["/health/liveness"]?.get.responses["200"]).toBeDefined();
|
|
491
|
+
expect(doc.paths["/health/readiness"]?.get.responses["200"]).toBeDefined();
|
|
492
|
+
expect(doc.paths["/health/readiness"]?.get.responses["503"]).toBeDefined();
|
|
493
|
+
const readySchema = doc.paths["/health/readiness"]?.get.responses["200"].content["application/json; charset=utf-8"]
|
|
494
|
+
.schema as { properties?: { checks?: unknown } };
|
|
495
|
+
expect(readySchema.properties?.checks).toBeDefined();
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test("generateOpenApi omits health paths when httpServer disabled", () => {
|
|
499
|
+
const program = testProgram({
|
|
500
|
+
key: "app",
|
|
501
|
+
description: "Test app",
|
|
502
|
+
handler: () => ({ ok: true }),
|
|
503
|
+
});
|
|
504
|
+
cliValidateProgram(program);
|
|
505
|
+
const doc = generateOpenApi(program) as { paths: Record<string, unknown> };
|
|
506
|
+
expect(doc.paths["/health"]).toBeUndefined();
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("generateOpenApi groups routes by top-level command tag", () => {
|
|
510
|
+
const program = nestedApiFixture();
|
|
511
|
+
cliValidateProgram(program);
|
|
512
|
+
const doc = generateOpenApi(program) as {
|
|
513
|
+
tags: { name: string; description?: string }[];
|
|
514
|
+
paths: Record<string, { post?: { tags: string[] }; get?: { tags: string[] } }>;
|
|
515
|
+
};
|
|
516
|
+
const tagNames = doc.tags.map((t) => t.name);
|
|
517
|
+
expect(tagNames).toContain("health");
|
|
518
|
+
expect(tagNames).toContain("stat");
|
|
519
|
+
expect(tagNames).toContain("pdf");
|
|
520
|
+
expect(doc.tags.find((t) => t.name === "stat")?.description).toBe("File metadata.");
|
|
521
|
+
expect(doc.paths["/stat/owner/lookup"]?.post?.tags).toEqual(["stat"]);
|
|
522
|
+
expect(doc.paths["/pdf"]?.post?.tags).toEqual(["pdf"]);
|
|
523
|
+
expect(doc.paths["/read"]?.post?.tags).toEqual(["read"]);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("generateOpenApi honors httpServer.pathPrefix", () => {
|
|
527
|
+
const program = testProgram({
|
|
528
|
+
key: "app",
|
|
529
|
+
description: "Test app",
|
|
530
|
+
httpServer: { enabled: true, pathPrefix: "/api" },
|
|
531
|
+
commands: [{ key: "echo", description: "Echo.", handler: () => ({ ok: true }) }],
|
|
532
|
+
});
|
|
533
|
+
cliValidateProgram(program);
|
|
534
|
+
const doc = generateOpenApi(program) as { paths: Record<string, unknown> };
|
|
535
|
+
expect(doc.paths["/api/echo"]).toBeDefined();
|
|
536
|
+
expect(doc.paths["/echo"]).toBeUndefined();
|
|
537
|
+
});
|
|
538
|
+
|
|
448
539
|
test("generateOpenApi maps binary content types", () => {
|
|
449
540
|
const program = nestedApiFixture();
|
|
450
541
|
const doc = generateOpenApi(program) as {
|
|
451
542
|
paths: Record<string, { post: { responses: { "201": { content: Record<string, unknown> } } } }>;
|
|
452
543
|
};
|
|
453
|
-
const pdf = doc.paths["/
|
|
544
|
+
const pdf = doc.paths["/pdf"]?.post.responses["201"].content["application/pdf"] as {
|
|
454
545
|
schema: { format: string };
|
|
455
546
|
};
|
|
456
547
|
expect(pdf.schema.format).toBe("binary");
|
|
@@ -497,7 +588,7 @@ test("generateOpenApi dereferences nested inputSchema definitions", () => {
|
|
|
497
588
|
}
|
|
498
589
|
>;
|
|
499
590
|
};
|
|
500
|
-
const schema = doc.paths["/
|
|
591
|
+
const schema = doc.paths["/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
|
|
501
592
|
expect(schema.properties.invoice).toEqual({
|
|
502
593
|
type: "object",
|
|
503
594
|
properties: { id: { type: "string" } },
|
|
@@ -505,6 +596,52 @@ test("generateOpenApi dereferences nested inputSchema definitions", () => {
|
|
|
505
596
|
});
|
|
506
597
|
});
|
|
507
598
|
|
|
599
|
+
test("generateOpenApi generates requestBody for kind: json leaves", () => {
|
|
600
|
+
const program = testProgram({
|
|
601
|
+
key: "app",
|
|
602
|
+
description: "Test app",
|
|
603
|
+
httpServer: { enabled: true },
|
|
604
|
+
commands: [
|
|
605
|
+
{
|
|
606
|
+
key: "render-invoice",
|
|
607
|
+
description: "Render an invoice.",
|
|
608
|
+
kind: "json",
|
|
609
|
+
inputSchema: {
|
|
610
|
+
type: "object",
|
|
611
|
+
properties: {
|
|
612
|
+
id: { type: "string" },
|
|
613
|
+
},
|
|
614
|
+
required: ["id"],
|
|
615
|
+
},
|
|
616
|
+
handler: () => ({ ok: true }),
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
});
|
|
620
|
+
cliValidateProgram(program);
|
|
621
|
+
const doc = generateOpenApi(program) as {
|
|
622
|
+
paths: Record<
|
|
623
|
+
string,
|
|
624
|
+
{
|
|
625
|
+
post: {
|
|
626
|
+
requestBody: {
|
|
627
|
+
required: boolean;
|
|
628
|
+
content: Record<string, { schema: Record<string, unknown> }>;
|
|
629
|
+
};
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
>;
|
|
633
|
+
};
|
|
634
|
+
const op = doc.paths["/render-invoice"]?.post;
|
|
635
|
+
expect(op).toBeDefined();
|
|
636
|
+
expect(op.requestBody).toBeDefined();
|
|
637
|
+
expect(op.requestBody.required).toBe(true);
|
|
638
|
+
expect(op.requestBody.content["application/json; charset=utf-8"].schema).toEqual({
|
|
639
|
+
type: "object",
|
|
640
|
+
properties: { id: { type: "string" } },
|
|
641
|
+
required: ["id"],
|
|
642
|
+
});
|
|
643
|
+
});
|
|
644
|
+
|
|
508
645
|
test("ctx.respond throws when called twice", () => {
|
|
509
646
|
const program = testProgram({
|
|
510
647
|
key: "app",
|