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.
@@ -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/live returns ok", async () => {
188
- const res = await apiRequest(program, new Request("http://127.0.0.1/health/live"));
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/ready returns ok when healthy", async () => {
194
- const res = await apiRequest(program, new Request("http://127.0.0.1/health/ready"), { withServer: true });
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/ready returns 503 when custom readiness fails", async () => {
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/ready"), { withServer: true });
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/api/boom", { method: "POST", body: "{}" }),
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/api/stat/owner/lookup", { method: "OPTIONS" }));
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/api/stat/owner/lookup", {
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/api/stat/owner/lookup", {
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/api/pdf", {
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/api/html", {
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/api/silent", {
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/api/missing_tool", {
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/api/stat/owner/lookup", {
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/api/fail", {
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["/api/stat/owner/lookup"]).toBeDefined();
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 /openapi-browser returns Scalar HTML", async () => {
438
- const res = await apiRequest(program, new Request("http://127.0.0.1/openapi-browser"));
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("@scalar/api-reference");
443
- expect(html).toContain('orderSchemaPropertiesBy: "preserve"');
444
- expect(html).toContain("orderRequiredPropertiesFirst: false");
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["/api/pdf"]?.post.responses["201"].content["application/pdf"] as {
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["/api/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
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",