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
package/src/http/openapi.ts
CHANGED
|
@@ -3,8 +3,8 @@ Hand-built OpenAPI 3.1 document from exposed HTTP REST routes.
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { collectOptionDefs } from "~/core/parse.ts";
|
|
6
|
-
import type { CliHttpMethod, CliProgram } from "~/core/types.ts";
|
|
7
|
-
import { CliOptionKind, isJsonLeaf } from "~/core/types.ts";
|
|
6
|
+
import type { CliHttpMethod, CliNode, CliProgram } from "~/core/types.ts";
|
|
7
|
+
import { CliOptionKind, isCliLeaf, isJsonLeaf } from "~/core/types.ts";
|
|
8
8
|
import { collectHttpRoutes, defaultSuccessStatus } from "./routes.ts";
|
|
9
9
|
import { dereferenceJsonSchema } from "./schema-deref.ts";
|
|
10
10
|
|
|
@@ -106,15 +106,120 @@ function methodLower(method: CliHttpMethod): string {
|
|
|
106
106
|
return method.toLowerCase();
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const HEALTH_TAG = "health";
|
|
110
|
+
|
|
111
|
+
const livenessResponseSchema = {
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: { ok: { type: "boolean", const: true } },
|
|
114
|
+
required: ["ok"],
|
|
115
|
+
} as const;
|
|
116
|
+
|
|
117
|
+
const readinessCheckSchema = {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
ok: { type: "boolean" },
|
|
121
|
+
error: { type: "string" },
|
|
122
|
+
missing: { type: "array", items: { type: "string" } },
|
|
123
|
+
},
|
|
124
|
+
required: ["ok"],
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
const readinessResponseSchema = {
|
|
128
|
+
type: "object",
|
|
129
|
+
properties: {
|
|
130
|
+
ok: { type: "boolean" },
|
|
131
|
+
checks: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
config_file: readinessCheckSchema,
|
|
135
|
+
config_required: readinessCheckSchema,
|
|
136
|
+
custom: readinessCheckSchema,
|
|
137
|
+
},
|
|
138
|
+
required: ["config_file", "config_required", "custom"],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
required: ["ok", "checks"],
|
|
142
|
+
} as const;
|
|
143
|
+
|
|
144
|
+
function jsonResponseEntry(description: string, schema: Record<string, unknown>): Record<string, unknown> {
|
|
145
|
+
return {
|
|
146
|
+
description,
|
|
147
|
+
content: {
|
|
148
|
+
[JSON_CONTENT_TYPE]: { schema },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function livenessGetOp(operationId: string, summary: string): Record<string, unknown> {
|
|
154
|
+
return {
|
|
155
|
+
tags: [HEALTH_TAG],
|
|
156
|
+
operationId,
|
|
157
|
+
summary,
|
|
158
|
+
responses: {
|
|
159
|
+
"200": jsonResponseEntry("Server is listening", livenessResponseSchema),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Framework health probe paths served alongside user command routes. */
|
|
165
|
+
function buildHealthPaths(): Record<string, unknown> {
|
|
166
|
+
return {
|
|
167
|
+
"/health": {
|
|
168
|
+
get: livenessGetOp("health", "Liveness probe (alias of /health/liveness)"),
|
|
169
|
+
},
|
|
170
|
+
"/health/liveness": {
|
|
171
|
+
get: livenessGetOp("health_liveness", "Liveness probe"),
|
|
172
|
+
},
|
|
173
|
+
"/health/readiness": {
|
|
174
|
+
get: {
|
|
175
|
+
tags: [HEALTH_TAG],
|
|
176
|
+
operationId: "health_readiness",
|
|
177
|
+
summary: "Readiness probe",
|
|
178
|
+
description: "Config file, required app config, and optional program.readiness checks.",
|
|
179
|
+
responses: {
|
|
180
|
+
"200": jsonResponseEntry("Ready to serve traffic", readinessResponseSchema),
|
|
181
|
+
"503": jsonResponseEntry("Not ready", readinessResponseSchema),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type HttpRoute = ReturnType<typeof collectHttpRoutes>[number];
|
|
189
|
+
|
|
190
|
+
/** Top-level command key for OpenAPI grouping (first non-`:param` segment). */
|
|
191
|
+
function topLevelCommandKey(route: HttpRoute, program: CliProgram): string {
|
|
192
|
+
const key = route.commandPath.find((k) => !k.startsWith(":"));
|
|
193
|
+
return key ?? program.key;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findTopLevelCommand(program: CliProgram, key: string): CliNode | undefined {
|
|
197
|
+
if (isCliLeaf(program)) {
|
|
198
|
+
return program.key === key ? program : undefined;
|
|
199
|
+
}
|
|
200
|
+
return program.commands.find((c) => c.key === key);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** OpenAPI tags for user command routes, one per top-level command. */
|
|
204
|
+
function collectCommandTags(program: CliProgram, routes: HttpRoute[]): { name: string; description?: string }[] {
|
|
205
|
+
const names = [...new Set(routes.map((route) => topLevelCommandKey(route, program)))].sort();
|
|
206
|
+
return names.map((name) => {
|
|
207
|
+
const node = findTopLevelCommand(program, name);
|
|
208
|
+
return node?.description ? { name, description: node.description } : { name };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
109
212
|
/** Generates an OpenAPI 3.1 document for the program's HTTP routes. */
|
|
110
213
|
export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
111
214
|
const routes = collectHttpRoutes(program);
|
|
112
|
-
const paths: Record<string, unknown> = {};
|
|
215
|
+
const paths: Record<string, unknown> = program.httpServer?.enabled ? buildHealthPaths() : {};
|
|
216
|
+
const commandTags = collectCommandTags(program, routes);
|
|
113
217
|
|
|
114
218
|
for (const route of routes) {
|
|
115
219
|
const pathKey = route.openApiPath;
|
|
116
220
|
const existing = (paths[pathKey] as Record<string, unknown> | undefined) ?? {};
|
|
117
221
|
const op: Record<string, unknown> = {
|
|
222
|
+
tags: [topLevelCommandKey(route, program)],
|
|
118
223
|
operationId: route.openApiPath.replace(/\//g, "_").replace(/[{}]/g, ""),
|
|
119
224
|
summary: route.leaf.description ?? route.leaf.key,
|
|
120
225
|
responses: {
|
|
@@ -150,9 +255,9 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
150
255
|
description: opt.description,
|
|
151
256
|
})),
|
|
152
257
|
];
|
|
153
|
-
} else
|
|
258
|
+
} else {
|
|
154
259
|
op.requestBody = {
|
|
155
|
-
required:
|
|
260
|
+
required: isJsonLeaf(route.leaf),
|
|
156
261
|
content: {
|
|
157
262
|
[JSON_CONTENT_TYPE]: {
|
|
158
263
|
schema: dereferenceJsonSchema(buildInputSchema(program, route)),
|
|
@@ -172,6 +277,9 @@ export function generateOpenApi(program: CliProgram): Record<string, unknown> {
|
|
|
172
277
|
version: program.version,
|
|
173
278
|
description: program.description,
|
|
174
279
|
},
|
|
280
|
+
...(program.httpServer?.enabled
|
|
281
|
+
? { tags: [{ name: HEALTH_TAG, description: "Server health probes" }, ...commandTags] }
|
|
282
|
+
: {}),
|
|
175
283
|
paths,
|
|
176
284
|
};
|
|
177
285
|
}
|
|
@@ -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/result.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function apiErrorResponse(status: number, body: ApiToolCallErrorBody): Re
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
101
|
+
/** Swagger UI HTML served at GET /swagger. */
|
|
102
102
|
export function apiDocsHtml(): string {
|
|
103
103
|
return `<!doctype html>
|
|
104
104
|
<html lang="en">
|
|
@@ -106,15 +106,15 @@ export function apiDocsHtml(): string {
|
|
|
106
106
|
<meta charset="utf-8" />
|
|
107
107
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
108
108
|
<title>API Reference</title>
|
|
109
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
|
|
109
110
|
</head>
|
|
110
111
|
<body>
|
|
111
|
-
<div id="
|
|
112
|
-
<script src="https://cdn.jsdelivr.net/npm
|
|
112
|
+
<div id="swagger-ui"></div>
|
|
113
|
+
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
|
113
114
|
<script>
|
|
114
|
-
|
|
115
|
+
SwaggerUIBundle({
|
|
115
116
|
url: "/openapi.json",
|
|
116
|
-
|
|
117
|
-
orderRequiredPropertiesFirst: false,
|
|
117
|
+
dom_id: "#swagger-ui",
|
|
118
118
|
});
|
|
119
119
|
</script>
|
|
120
120
|
</body>
|
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" && (path === "/health" || path === "/health/
|
|
115
|
+
if (request.method === "GET" && (path === "/health" || 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 }));
|
|
@@ -129,7 +129,7 @@ export async function handleApiRequest(
|
|
|
129
129
|
return finish(jsonResponse(200, generateOpenApi(root)));
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
if (request.method === "GET" && path === "/
|
|
132
|
+
if (request.method === "GET" && path === "/swagger") {
|
|
133
133
|
return finish(
|
|
134
134
|
new Response(apiDocsHtml(), {
|
|
135
135
|
status: 200,
|
|
@@ -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
|
|