argsbarg 6.1.4 → 6.1.6
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 +16 -2
- package/README.md +1 -1
- package/docs/http-server.md +21 -16
- 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 +18 -18
- package/examples/full-example/docs/openapi.json +284 -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 +13 -9
- package/src/http/openapi.ts +25 -17
- package/src/http/paths.ts +39 -0
- package/src/http/readiness.ts +1 -1
- package/src/http/routes.ts +22 -9
- package/src/http/server.ts +7 -11
- package/src/test/integration/http.test.ts +71 -37
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
HTTP path prefix helpers and framework route guards.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CliProgram } from "~/core/types.ts";
|
|
6
|
+
|
|
7
|
+
/** Top-level segments reserved for framework routes when `pathPrefix` is empty. */
|
|
8
|
+
export const HTTP_RESERVED_TOP_LEVEL_SEGMENTS = new Set(["health", "openapi.json", "swagger", "tools"]);
|
|
9
|
+
|
|
10
|
+
/** Resolved path prefix for user HTTP routes (`""` by default, or e.g. `"/api"`). */
|
|
11
|
+
export function resolveHttpPathPrefix(program: CliProgram): string {
|
|
12
|
+
const raw = program.httpServer?.pathPrefix;
|
|
13
|
+
if (raw === undefined || raw === "") {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
return raw;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** OpenAPI / route path for a user command from URL segments. */
|
|
20
|
+
export function buildHttpUserPath(prefix: string, urlSegments: string[]): string {
|
|
21
|
+
const tail = urlSegments.map((s) => (s.startsWith(":") ? `{${s.slice(1)}}` : s)).join("/");
|
|
22
|
+
if (!prefix) {
|
|
23
|
+
return tail ? `/${tail}` : "/";
|
|
24
|
+
}
|
|
25
|
+
return tail ? `${prefix}/${tail}` : prefix;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Regex-safe path prefix for route matching. */
|
|
29
|
+
export function httpUserPathRegexPrefix(prefix: string): string {
|
|
30
|
+
if (!prefix) {
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
return prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Wildcard label for docs (e.g. `/api/*` or `/*`). */
|
|
37
|
+
export function httpUserPathGlob(prefix: string): string {
|
|
38
|
+
return prefix ? `${prefix}/*` : "/*";
|
|
39
|
+
}
|
package/src/http/readiness.ts
CHANGED
package/src/http/routes.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
} from "~/core/types.ts";
|
|
15
15
|
import { formatMcpOptionValue } from "~/mcp/tools.ts";
|
|
16
16
|
import { isHttpDisabled, isHttpHidden } from "~/runtime/exposure.ts";
|
|
17
|
+
import { buildHttpUserPath, httpUserPathRegexPrefix, resolveHttpPathPrefix } from "./paths.ts";
|
|
17
18
|
|
|
18
19
|
const VERB_KEYS = new Set(["get", "post", "put", "patch", "delete"]);
|
|
19
20
|
|
|
20
21
|
/** One HTTP route derived from a user leaf command. */
|
|
21
22
|
export interface HttpRouteDef {
|
|
22
23
|
method: CliHttpMethod;
|
|
23
|
-
/** OpenAPI-style path e.g. `/api/workspaces/{id}`. */
|
|
24
|
+
/** OpenAPI-style path e.g. `/workspaces/{id}` or `/api/workspaces/{id}`. */
|
|
24
25
|
openApiPath: string;
|
|
25
26
|
/** Regex matching pathname (no query). */
|
|
26
27
|
pathPattern: RegExp;
|
|
@@ -67,7 +68,7 @@ type WalkState = {
|
|
|
67
68
|
paramNames: string[];
|
|
68
69
|
};
|
|
69
70
|
|
|
70
|
-
function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): void {
|
|
71
|
+
function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState, pathPrefix: string): void {
|
|
71
72
|
const urlSegments = [...state.urlSegments];
|
|
72
73
|
const commandPath = [...state.commandPath];
|
|
73
74
|
if (!isVerbLeaf(leaf)) {
|
|
@@ -79,9 +80,11 @@ function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): voi
|
|
|
79
80
|
commandPath.push(leaf.key);
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
|
-
const openApiPath =
|
|
83
|
+
const openApiPath = buildHttpUserPath(pathPrefix, urlSegments);
|
|
83
84
|
const patternParts = urlSegments.map((s) => (s.startsWith(":") ? "([^/]+)" : escapeRegex(s)));
|
|
84
|
-
const
|
|
85
|
+
const regexPrefix = httpUserPathRegexPrefix(pathPrefix);
|
|
86
|
+
const tail = patternParts.length > 0 ? `/${patternParts.join("/")}` : "";
|
|
87
|
+
const pathPattern = new RegExp(`^${regexPrefix}${tail}/?$`);
|
|
85
88
|
routes.push({
|
|
86
89
|
method: inferHttpMethod(leaf),
|
|
87
90
|
openApiPath,
|
|
@@ -92,14 +95,14 @@ function pushRoute(routes: HttpRouteDef[], leaf: CliLeaf, state: WalkState): voi
|
|
|
92
95
|
});
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
|
|
98
|
+
function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[], pathPrefix: string): void {
|
|
96
99
|
if (isHttpDisabled(node) || isHttpHidden(node)) {
|
|
97
100
|
return;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
if (isCliLeaf(node)) {
|
|
101
104
|
if (leafHttpExposed(node)) {
|
|
102
|
-
pushRoute(routes, node, state);
|
|
105
|
+
pushRoute(routes, node, state, pathPrefix);
|
|
103
106
|
}
|
|
104
107
|
return;
|
|
105
108
|
}
|
|
@@ -115,6 +118,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
|
|
|
115
118
|
paramNames: [...state.paramNames, paramName],
|
|
116
119
|
},
|
|
117
120
|
routes,
|
|
121
|
+
pathPrefix,
|
|
118
122
|
);
|
|
119
123
|
continue;
|
|
120
124
|
}
|
|
@@ -127,6 +131,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
|
|
|
127
131
|
paramNames: state.paramNames,
|
|
128
132
|
},
|
|
129
133
|
routes,
|
|
134
|
+
pathPrefix,
|
|
130
135
|
);
|
|
131
136
|
continue;
|
|
132
137
|
}
|
|
@@ -139,6 +144,7 @@ function walk(node: CliNode, state: WalkState, routes: HttpRouteDef[]): void {
|
|
|
139
144
|
paramNames: state.paramNames,
|
|
140
145
|
},
|
|
141
146
|
routes,
|
|
147
|
+
pathPrefix,
|
|
142
148
|
);
|
|
143
149
|
}
|
|
144
150
|
}
|
|
@@ -154,8 +160,10 @@ export function collectHttpRoutes(program: CliProgram): HttpRouteDef[] {
|
|
|
154
160
|
return routes;
|
|
155
161
|
}
|
|
156
162
|
|
|
163
|
+
const pathPrefix = resolveHttpPathPrefix(program);
|
|
164
|
+
|
|
157
165
|
if (isCliLeaf(program)) {
|
|
158
|
-
walk(program, { urlSegments: [], commandPath: [], paramNames: [] }, routes);
|
|
166
|
+
walk(program, { urlSegments: [], commandPath: [], paramNames: [] }, routes, pathPrefix);
|
|
159
167
|
return routes;
|
|
160
168
|
}
|
|
161
169
|
|
|
@@ -171,9 +179,14 @@ export function collectHttpRoutes(program: CliProgram): HttpRouteDef[] {
|
|
|
171
179
|
continue;
|
|
172
180
|
}
|
|
173
181
|
if (isCliLeaf(child)) {
|
|
174
|
-
walk(child, { urlSegments: [], commandPath: [child.key], paramNames: [] }, routes);
|
|
182
|
+
walk(child, { urlSegments: [], commandPath: [child.key], paramNames: [] }, routes, pathPrefix);
|
|
175
183
|
} else {
|
|
176
|
-
walk(
|
|
184
|
+
walk(
|
|
185
|
+
child,
|
|
186
|
+
{ urlSegments: [segmentForNode(child)], commandPath: [child.key], paramNames: [] },
|
|
187
|
+
routes,
|
|
188
|
+
pathPrefix,
|
|
189
|
+
);
|
|
177
190
|
}
|
|
178
191
|
}
|
|
179
192
|
|
package/src/http/server.ts
CHANGED
|
@@ -112,11 +112,11 @@ export async function handleApiRequest(
|
|
|
112
112
|
const root = cli.program;
|
|
113
113
|
const path = url.pathname;
|
|
114
114
|
|
|
115
|
-
if (request.method === "GET" &&
|
|
115
|
+
if (request.method === "GET" && path === "/health/liveness") {
|
|
116
116
|
return finish(jsonResponse(200, { ok: true }));
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
if (request.method === "GET" && path === "/health/
|
|
119
|
+
if (request.method === "GET" && path === "/health/readiness") {
|
|
120
120
|
const runtime = cli.server?.runtime;
|
|
121
121
|
if (!runtime) {
|
|
122
122
|
return finish(jsonResponse(200, { ok: true }));
|
|
@@ -141,12 +141,12 @@ export async function handleApiRequest(
|
|
|
141
141
|
);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
if (path.startsWith("/
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return finish(apiErrorResponse(404, { error: "Not found" }));
|
|
148
|
-
}
|
|
144
|
+
if (path.startsWith("/tools")) {
|
|
145
|
+
return finish(apiErrorResponse(404, { error: "Not found" }));
|
|
146
|
+
}
|
|
149
147
|
|
|
148
|
+
const match = matchHttpRoute(root, request.method, path);
|
|
149
|
+
if (match.ok) {
|
|
150
150
|
let body: Record<string, unknown> = {};
|
|
151
151
|
if (request.method === "POST" || request.method === "PUT" || request.method === "PATCH") {
|
|
152
152
|
const rawBody = await request.text();
|
|
@@ -181,10 +181,6 @@ export async function handleApiRequest(
|
|
|
181
181
|
return finish(headlessFailureToHttpResponse(result, obscure), result.invokeResult?.failureKind, result.message);
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
if (path.startsWith("/tools")) {
|
|
185
|
-
return finish(apiErrorResponse(404, { error: "Not found" }));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
184
|
return finish(apiErrorResponse(404, { error: "Not found" }));
|
|
189
185
|
}
|
|
190
186
|
|
|
@@ -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);
|
|
@@ -272,15 +289,15 @@ describe("HTTP API routes", () => {
|
|
|
272
289
|
expect(body.error).toBe("An unexpected error occurred.");
|
|
273
290
|
});
|
|
274
291
|
|
|
275
|
-
test("GET /health includes CORS headers", async () => {
|
|
276
|
-
const res = await apiRequest(program, new Request("http://127.0.0.1/health"));
|
|
292
|
+
test("GET /health/liveness includes CORS headers", async () => {
|
|
293
|
+
const res = await apiRequest(program, new Request("http://127.0.0.1/health/liveness"));
|
|
277
294
|
expect(res.status).toBe(200);
|
|
278
295
|
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
279
296
|
expect(await res.json()).toEqual({ ok: true });
|
|
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,10 +448,10 @@ 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["/
|
|
435
|
-
expect(doc.paths["/health"]).toBeDefined();
|
|
436
|
-
expect(doc.paths["/health/
|
|
437
|
-
expect(doc.paths["/health
|
|
451
|
+
expect(doc.paths["/stat/owner/lookup"]).toBeDefined();
|
|
452
|
+
expect(doc.paths["/health/liveness"]).toBeDefined();
|
|
453
|
+
expect(doc.paths["/health/readiness"]).toBeDefined();
|
|
454
|
+
expect(doc.paths["/health"]).toBeUndefined();
|
|
438
455
|
});
|
|
439
456
|
|
|
440
457
|
test("GET /swagger returns Swagger UI HTML", async () => {
|
|
@@ -463,17 +480,21 @@ test("generateOpenApi includes health probe paths", () => {
|
|
|
463
480
|
{
|
|
464
481
|
get: {
|
|
465
482
|
tags: string[];
|
|
483
|
+
summary: string;
|
|
466
484
|
responses: Record<string, { content: Record<string, { schema: Record<string, unknown> }> }>;
|
|
467
485
|
};
|
|
468
486
|
}
|
|
469
487
|
>;
|
|
470
488
|
};
|
|
471
489
|
expect(doc.tags.some((t) => t.name === "health")).toBe(true);
|
|
472
|
-
expect(doc.paths["/health"]
|
|
473
|
-
expect(doc.paths["/health/
|
|
474
|
-
expect(doc.paths["/health/
|
|
475
|
-
expect(doc.paths["/health/
|
|
476
|
-
|
|
490
|
+
expect(doc.paths["/health"]).toBeUndefined();
|
|
491
|
+
expect(doc.paths["/health/liveness"]?.get.tags).toContain("health");
|
|
492
|
+
expect(doc.paths["/health/liveness"]?.get.summary).toBe("Liveness probe");
|
|
493
|
+
expect(doc.paths["/health/readiness"]?.get.summary).toBe("Readiness probe");
|
|
494
|
+
expect(doc.paths["/health/liveness"]?.get.responses["200"]).toBeDefined();
|
|
495
|
+
expect(doc.paths["/health/readiness"]?.get.responses["200"]).toBeDefined();
|
|
496
|
+
expect(doc.paths["/health/readiness"]?.get.responses["503"]).toBeDefined();
|
|
497
|
+
const readySchema = doc.paths["/health/readiness"]?.get.responses["200"].content["application/json; charset=utf-8"]
|
|
477
498
|
.schema as { properties?: { checks?: unknown } };
|
|
478
499
|
expect(readySchema.properties?.checks).toBeDefined();
|
|
479
500
|
});
|
|
@@ -501,9 +522,22 @@ test("generateOpenApi groups routes by top-level command tag", () => {
|
|
|
501
522
|
expect(tagNames).toContain("stat");
|
|
502
523
|
expect(tagNames).toContain("pdf");
|
|
503
524
|
expect(doc.tags.find((t) => t.name === "stat")?.description).toBe("File metadata.");
|
|
504
|
-
expect(doc.paths["/
|
|
505
|
-
expect(doc.paths["/
|
|
506
|
-
expect(doc.paths["/
|
|
525
|
+
expect(doc.paths["/stat/owner/lookup"]?.post?.tags).toEqual(["stat"]);
|
|
526
|
+
expect(doc.paths["/pdf"]?.post?.tags).toEqual(["pdf"]);
|
|
527
|
+
expect(doc.paths["/read"]?.post?.tags).toEqual(["read"]);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("generateOpenApi honors httpServer.pathPrefix", () => {
|
|
531
|
+
const program = testProgram({
|
|
532
|
+
key: "app",
|
|
533
|
+
description: "Test app",
|
|
534
|
+
httpServer: { enabled: true, pathPrefix: "/api" },
|
|
535
|
+
commands: [{ key: "echo", description: "Echo.", handler: () => ({ ok: true }) }],
|
|
536
|
+
});
|
|
537
|
+
cliValidateProgram(program);
|
|
538
|
+
const doc = generateOpenApi(program) as { paths: Record<string, unknown> };
|
|
539
|
+
expect(doc.paths["/api/echo"]).toBeDefined();
|
|
540
|
+
expect(doc.paths["/echo"]).toBeUndefined();
|
|
507
541
|
});
|
|
508
542
|
|
|
509
543
|
test("generateOpenApi maps binary content types", () => {
|
|
@@ -511,7 +545,7 @@ test("generateOpenApi maps binary content types", () => {
|
|
|
511
545
|
const doc = generateOpenApi(program) as {
|
|
512
546
|
paths: Record<string, { post: { responses: { "201": { content: Record<string, unknown> } } } }>;
|
|
513
547
|
};
|
|
514
|
-
const pdf = doc.paths["/
|
|
548
|
+
const pdf = doc.paths["/pdf"]?.post.responses["201"].content["application/pdf"] as {
|
|
515
549
|
schema: { format: string };
|
|
516
550
|
};
|
|
517
551
|
expect(pdf.schema.format).toBe("binary");
|
|
@@ -558,7 +592,7 @@ test("generateOpenApi dereferences nested inputSchema definitions", () => {
|
|
|
558
592
|
}
|
|
559
593
|
>;
|
|
560
594
|
};
|
|
561
|
-
const schema = doc.paths["/
|
|
595
|
+
const schema = doc.paths["/render"]?.post.requestBody.content["application/json; charset=utf-8"].schema;
|
|
562
596
|
expect(schema.properties.invoice).toEqual({
|
|
563
597
|
type: "object",
|
|
564
598
|
properties: { id: { type: "string" } },
|
|
@@ -601,7 +635,7 @@ test("generateOpenApi generates requestBody for kind: json leaves", () => {
|
|
|
601
635
|
}
|
|
602
636
|
>;
|
|
603
637
|
};
|
|
604
|
-
const op = doc.paths["/
|
|
638
|
+
const op = doc.paths["/render-invoice"]?.post;
|
|
605
639
|
expect(op).toBeDefined();
|
|
606
640
|
expect(op.requestBody).toBeDefined();
|
|
607
641
|
expect(op.requestBody.required).toBe(true);
|