@thenavidm/slipway 0.1.0
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 +21 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +757 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +136 -0
- package/dist/app.d.ts +210 -0
- package/dist/app.js +364 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.js +178 -0
- package/dist/check.d.ts +52 -0
- package/dist/check.js +313 -0
- package/dist/cli/completion.d.ts +5 -0
- package/dist/cli/completion.js +72 -0
- package/dist/cli/context.d.ts +97 -0
- package/dist/cli/context.js +94 -0
- package/dist/cli/data.d.ts +17 -0
- package/dist/cli/data.js +120 -0
- package/dist/cli/flags.d.ts +35 -0
- package/dist/cli/flags.js +208 -0
- package/dist/cli/help.d.ts +15 -0
- package/dist/cli/help.js +213 -0
- package/dist/cli/install.d.ts +10 -0
- package/dist/cli/install.js +65 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.js +159 -0
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.js +343 -0
- package/dist/confirm.d.ts +90 -0
- package/dist/confirm.js +166 -0
- package/dist/data.d.ts +109 -0
- package/dist/data.js +324 -0
- package/dist/docs.d.ts +13 -0
- package/dist/docs.js +66 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +100 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +34 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.js +153 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +89 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/install.d.ts +98 -0
- package/dist/install.js +325 -0
- package/dist/jobs.d.ts +143 -0
- package/dist/jobs.js +247 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.js +549 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +61 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +74 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +60 -0
- package/dist/result.d.ts +44 -0
- package/dist/result.js +74 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +120 -0
- package/dist/schema.d.ts +99 -0
- package/dist/schema.js +192 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.js +119 -0
- package/dist/serve.d.ts +30 -0
- package/dist/serve.js +149 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +119 -0
- package/dist/testing.d.ts +35 -0
- package/dist/testing.js +41 -0
- package/dist/tool.d.ts +194 -0
- package/dist/tool.js +151 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +35 -0
- package/package.json +89 -0
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools from an OpenAPI document.
|
|
3
|
+
*
|
|
4
|
+
* An API that publishes OpenAPI already says what every operation takes. This
|
|
5
|
+
* turns each operation into a tool with that input, the risk its HTTP method
|
|
6
|
+
* implies, and its tags as toolsets, so a large API becomes a server and a CLI
|
|
7
|
+
* without a hand-written tool per endpoint. The tools join the same list as
|
|
8
|
+
* hand-written ones and go through the same guard.
|
|
9
|
+
*
|
|
10
|
+
* A generated tool is only as trustworthy as the document it came from, so a
|
|
11
|
+
* document can be pinned by hash: a changed document refuses to build until
|
|
12
|
+
* someone has looked at what changed and updated the pin.
|
|
13
|
+
*/
|
|
14
|
+
import { UsageError, httpError } from "./errors.js";
|
|
15
|
+
import { CONTROL_NAMES, jsonSchema } from "./schema.js";
|
|
16
|
+
import { defineTool } from "./tool.js";
|
|
17
|
+
import { sha256, stableJson } from "./util.js";
|
|
18
|
+
const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
19
|
+
const READS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
|
|
20
|
+
/** Headers the transport sets itself, which a tool never takes as arguments. */
|
|
21
|
+
const TRANSPORT_HEADERS = new Set(["authorization", "content-type", "accept", "content-length", "user-agent", "host", "cookie"]);
|
|
22
|
+
/** Formats the validator understands. Any other format is left out, since an unknown one prints a warning every time the schema loads. */
|
|
23
|
+
const KNOWN_FORMATS = new Set([
|
|
24
|
+
"int32", "int64", "float", "double", "byte", "binary", "password", "date", "date-time", "time", "duration", "uuid", "email",
|
|
25
|
+
"uri", "uri-reference", "uri-template", "url", "hostname", "ipv4", "ipv6", "regex", "json-pointer", "relative-json-pointer",
|
|
26
|
+
]);
|
|
27
|
+
const PROPERTY = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
28
|
+
/** Names an API's own arguments cannot take: Slipway's controls, and the property that carries a body that is not spread. */
|
|
29
|
+
const RESERVED = new Set([...CONTROL_NAMES, "body"]);
|
|
30
|
+
const MAX_DEPTH = 40;
|
|
31
|
+
/**
|
|
32
|
+
* How many references deep an object schema is expanded. Large APIs refer
|
|
33
|
+
* from object to object to object, and expanding every path in full grows
|
|
34
|
+
* without bound. Below this depth an object is described rather than spelled
|
|
35
|
+
* out, and the API itself checks it.
|
|
36
|
+
*/
|
|
37
|
+
const MAX_REF_DEPTH = 3;
|
|
38
|
+
const MAX_DESCRIPTION = 1_000;
|
|
39
|
+
/** The hash to pin a document by: the same for the same content, however its keys were ordered. */
|
|
40
|
+
export function openapiHash(document) {
|
|
41
|
+
return sha256(stableJson(document));
|
|
42
|
+
}
|
|
43
|
+
function isRecord(value) {
|
|
44
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
function pointer(document, ref) {
|
|
47
|
+
if (!ref.startsWith("#/")) {
|
|
48
|
+
throw new UsageError(`Only references inside the document are supported, not '${ref}'. Bundle the document into one file first.`);
|
|
49
|
+
}
|
|
50
|
+
let node = document;
|
|
51
|
+
for (const raw of ref.slice(2).split("/")) {
|
|
52
|
+
const part = decodeURIComponent(raw).replace(/~1/g, "/").replace(/~0/g, "~");
|
|
53
|
+
if (!isRecord(node) && !Array.isArray(node))
|
|
54
|
+
return undefined;
|
|
55
|
+
node = node[part];
|
|
56
|
+
}
|
|
57
|
+
if (node === undefined)
|
|
58
|
+
throw new UsageError(`The reference '${ref}' points at nothing in the document.`);
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
/** A schema small enough to inline at any depth: a plain value, a list of choices. */
|
|
62
|
+
function isSmall(node) {
|
|
63
|
+
if (!isRecord(node))
|
|
64
|
+
return true;
|
|
65
|
+
if (node.$ref !== undefined || node.properties !== undefined || node.items !== undefined || node.allOf || node.anyOf || node.oneOf || isRecord(node.additionalProperties))
|
|
66
|
+
return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Inline `$ref`s. A schema that refers to itself, such as a tree of comments,
|
|
71
|
+
* is cut at the second visit, and an object more than a few references deep
|
|
72
|
+
* is described rather than spelled out, so a large API's schemas stay a size
|
|
73
|
+
* a model can read. Results are memoized per reference and depth, since large
|
|
74
|
+
* documents refer to the same schemas thousands of times.
|
|
75
|
+
*/
|
|
76
|
+
function resolver(document) {
|
|
77
|
+
const memo = new Map();
|
|
78
|
+
const resolve = (node, seen = [], depth = 0) => {
|
|
79
|
+
if (depth > MAX_DEPTH)
|
|
80
|
+
return {};
|
|
81
|
+
if (Array.isArray(node))
|
|
82
|
+
return node.map((item) => resolve(item, seen, depth + 1));
|
|
83
|
+
if (!isRecord(node))
|
|
84
|
+
return node;
|
|
85
|
+
if (typeof node.$ref === "string") {
|
|
86
|
+
const ref = node.$ref;
|
|
87
|
+
const { $ref: _ref, ...siblings } = node;
|
|
88
|
+
const name = ref.split("/").pop();
|
|
89
|
+
const raw = pointer(document, ref);
|
|
90
|
+
const extra = resolve(siblings, seen, depth + 1);
|
|
91
|
+
if (seen.includes(ref) || (seen.length >= MAX_REF_DEPTH && !isSmall(raw))) {
|
|
92
|
+
const about = isRecord(raw) && typeof raw.description === "string" ? ` ${clip(raw.description, 200)}` : "";
|
|
93
|
+
return { ...extra, description: extra.description ?? `A ${name}, not spelled out here: the API checks its fields.${about}` };
|
|
94
|
+
}
|
|
95
|
+
const key = `${ref}\0${seen.length}`;
|
|
96
|
+
if (!memo.has(key))
|
|
97
|
+
memo.set(key, resolve(raw, [...seen, ref], depth + 1));
|
|
98
|
+
const target = memo.get(key);
|
|
99
|
+
return isRecord(target) ? { ...target, ...extra } : target;
|
|
100
|
+
}
|
|
101
|
+
return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, resolve(value, seen, depth + 1)]));
|
|
102
|
+
};
|
|
103
|
+
return resolve;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* An OpenAPI schema as JSON Schema 2020-12: `nullable` becomes a null type,
|
|
107
|
+
* 3.0's boolean exclusive bounds become numbers, `example` becomes
|
|
108
|
+
* `examples`, and keys that only mean something to OpenAPI are dropped.
|
|
109
|
+
* Properties the server sets itself (`readOnly`) leave a request's input.
|
|
110
|
+
*/
|
|
111
|
+
export function toJsonSchema(node, forInput) {
|
|
112
|
+
if (Array.isArray(node))
|
|
113
|
+
return node.map((item) => toJsonSchema(item, forInput));
|
|
114
|
+
if (!isRecord(node))
|
|
115
|
+
return node;
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [key, value] of Object.entries(node)) {
|
|
118
|
+
if (key.startsWith("x-") || ["discriminator", "xml", "externalDocs", "nullable", "example", "$id", "$schema", "$anchor", "deprecated"].includes(key))
|
|
119
|
+
continue;
|
|
120
|
+
if (key === "format" && (typeof value !== "string" || !KNOWN_FORMATS.has(value)))
|
|
121
|
+
continue;
|
|
122
|
+
if (key === "exclusiveMinimum" && typeof value === "boolean") {
|
|
123
|
+
if (value && typeof node.minimum === "number")
|
|
124
|
+
out.exclusiveMinimum = node.minimum;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (key === "exclusiveMaximum" && typeof value === "boolean") {
|
|
128
|
+
if (value && typeof node.maximum === "number")
|
|
129
|
+
out.exclusiveMaximum = node.maximum;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if ((key === "minimum" && node.exclusiveMinimum === true) || (key === "maximum" && node.exclusiveMaximum === true))
|
|
133
|
+
continue;
|
|
134
|
+
if (key === "properties" && isRecord(value)) {
|
|
135
|
+
const properties = {};
|
|
136
|
+
for (const [name, schema] of Object.entries(value)) {
|
|
137
|
+
if (forInput && isRecord(schema) && schema.readOnly === true)
|
|
138
|
+
continue;
|
|
139
|
+
properties[name] = toJsonSchema(schema, forInput);
|
|
140
|
+
}
|
|
141
|
+
out.properties = properties;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Values are data, not schemas, so they pass through untouched.
|
|
145
|
+
out[key] = ["default", "const", "enum", "examples"].includes(key) ? value : isRecord(value) || Array.isArray(value) ? toJsonSchema(value, forInput) : value;
|
|
146
|
+
}
|
|
147
|
+
if (forInput && Array.isArray(out.required) && isRecord(out.properties)) {
|
|
148
|
+
const kept = out.required.filter((name) => typeof name === "string" && name in out.properties);
|
|
149
|
+
if (kept.length)
|
|
150
|
+
out.required = kept;
|
|
151
|
+
else
|
|
152
|
+
delete out.required;
|
|
153
|
+
}
|
|
154
|
+
if (node.example !== undefined && out.examples === undefined)
|
|
155
|
+
out.examples = [toJsonSchema(node.example, false)];
|
|
156
|
+
if (node.nullable === true) {
|
|
157
|
+
if (typeof out.type === "string")
|
|
158
|
+
out.type = [out.type, "null"];
|
|
159
|
+
else if (Array.isArray(out.type) && !out.type.includes("null"))
|
|
160
|
+
out.type = [...out.type, "null"];
|
|
161
|
+
else if (Array.isArray(out.enum) && !out.enum.includes(null))
|
|
162
|
+
out.enum = [...out.enum, null];
|
|
163
|
+
else if (out.type === undefined)
|
|
164
|
+
return { anyOf: [out, { type: "null" }] };
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* operationId as a tool name: listPets and pets.list become list_pets and
|
|
170
|
+
* pets_list. A name past the 64 characters clients allow keeps its start and
|
|
171
|
+
* ends in a short hash of the id, so two long ids that begin alike stay
|
|
172
|
+
* two names, and the same id always gets the same one.
|
|
173
|
+
*/
|
|
174
|
+
export function toolName(id) {
|
|
175
|
+
const snake = id
|
|
176
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
177
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
|
|
178
|
+
.toLowerCase()
|
|
179
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
180
|
+
.replace(/^_+|_+$/g, "")
|
|
181
|
+
.replace(/_+/g, "_");
|
|
182
|
+
const name = /^[a-z]/.test(snake) ? snake : `op_${snake}`;
|
|
183
|
+
if (name.length <= 64)
|
|
184
|
+
return name;
|
|
185
|
+
return `${name.slice(0, 55).replace(/_+$/, "")}_${sha256(id).slice(0, 8)}`;
|
|
186
|
+
}
|
|
187
|
+
function slug(tag) {
|
|
188
|
+
return tag.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
189
|
+
}
|
|
190
|
+
function words(id) {
|
|
191
|
+
const spaced = id.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/[_.-]+/g, " ").trim().toLowerCase();
|
|
192
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
193
|
+
}
|
|
194
|
+
function clip(text, max) {
|
|
195
|
+
const clean = text.trim();
|
|
196
|
+
return clean.length <= max ? clean : `${clean.slice(0, max - 1).trimEnd()}…`;
|
|
197
|
+
}
|
|
198
|
+
/** The media type a body is sent as: JSON first, then a form. Anything else cannot be built from arguments. */
|
|
199
|
+
function bodyType(content) {
|
|
200
|
+
const types = Object.keys(content);
|
|
201
|
+
return (types.find((type) => /^application\/json\b/i.test(type)) ??
|
|
202
|
+
types.find((type) => /\+json\b/i.test(type)) ??
|
|
203
|
+
types.find((type) => /^application\/x-www-form-urlencoded\b/i.test(type)));
|
|
204
|
+
}
|
|
205
|
+
/** Every operation in a document, as tools would see it, and the ones that cannot become tools, with why. */
|
|
206
|
+
export function readOperations(document, options = {}) {
|
|
207
|
+
if (!isRecord(document))
|
|
208
|
+
throw new UsageError("An OpenAPI document is a JSON object.");
|
|
209
|
+
if (typeof document.swagger === "string")
|
|
210
|
+
throw new UsageError("This is a Swagger 2.0 document. Convert it to OpenAPI 3 first.");
|
|
211
|
+
if (typeof document.openapi !== "string" || !/^3\./.test(document.openapi))
|
|
212
|
+
throw new UsageError("This is not an OpenAPI 3 document: it has no openapi: 3.x field.");
|
|
213
|
+
const paths = isRecord(document.paths) ? document.paths : {};
|
|
214
|
+
const operations = [];
|
|
215
|
+
const skipped = [];
|
|
216
|
+
const resolve = resolver(document);
|
|
217
|
+
for (const [path, rawItem] of Object.entries(paths)) {
|
|
218
|
+
// Only what a tool's input needs is resolved: parameters and the body, and responses only for typed output.
|
|
219
|
+
const item = (isRecord(rawItem) && typeof rawItem.$ref === "string" ? pointer(document, rawItem.$ref) : rawItem);
|
|
220
|
+
if (!isRecord(item))
|
|
221
|
+
continue;
|
|
222
|
+
const shared = Array.isArray(item.parameters) ? resolve(item.parameters) : [];
|
|
223
|
+
for (const method of METHODS) {
|
|
224
|
+
const rawOp = item[method];
|
|
225
|
+
if (!isRecord(rawOp))
|
|
226
|
+
continue;
|
|
227
|
+
const op = {
|
|
228
|
+
...rawOp,
|
|
229
|
+
...(rawOp.parameters ? { parameters: resolve(rawOp.parameters) } : {}),
|
|
230
|
+
...(rawOp.requestBody ? { requestBody: resolve(rawOp.requestBody) } : {}),
|
|
231
|
+
...(options.typedOutput && rawOp.responses ? { responses: resolve(rawOp.responses) } : {}),
|
|
232
|
+
};
|
|
233
|
+
const verb = method.toUpperCase();
|
|
234
|
+
const operationId = typeof op.operationId === "string" && op.operationId ? op.operationId : `${method}_${path}`;
|
|
235
|
+
const skip = (reason) => skipped.push({ method: verb, path, ...(typeof op.operationId === "string" ? { operationId: op.operationId } : {}), reason });
|
|
236
|
+
// An operation's own parameter replaces a path-level one with the same name and location.
|
|
237
|
+
const own = Array.isArray(op.parameters) ? op.parameters : [];
|
|
238
|
+
const merged = [...shared.filter((p) => !own.some((o) => o.name === p.name && o.in === p.in)), ...own].filter(isRecord);
|
|
239
|
+
const properties = {};
|
|
240
|
+
const required = [];
|
|
241
|
+
const parameters = [];
|
|
242
|
+
let problem;
|
|
243
|
+
for (const parameter of merged) {
|
|
244
|
+
const location = parameter.in;
|
|
245
|
+
const name = String(parameter.name ?? "");
|
|
246
|
+
if (location === "cookie")
|
|
247
|
+
continue;
|
|
248
|
+
if (location === "header" && TRANSPORT_HEADERS.has(name.toLowerCase()))
|
|
249
|
+
continue;
|
|
250
|
+
if (location !== "path" && location !== "query" && location !== "header")
|
|
251
|
+
continue;
|
|
252
|
+
let property = name;
|
|
253
|
+
if (!PROPERTY.test(property) || property in properties || RESERVED.has(property))
|
|
254
|
+
property = `${location}_${name}`.replace(/[^A-Za-z0-9_.-]+/g, "_").slice(0, 64);
|
|
255
|
+
if (!PROPERTY.test(property) || property in properties) {
|
|
256
|
+
problem = `parameter '${name}' cannot become an argument name`;
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
const schema = toJsonSchema(isRecord(parameter.schema) ? parameter.schema : { type: "string" }, true);
|
|
260
|
+
const description = typeof parameter.description === "string" ? parameter.description : schema.description;
|
|
261
|
+
properties[property] = { ...schema, ...(description ? { description: clip(String(description), MAX_DESCRIPTION) } : {}) };
|
|
262
|
+
const isRequired = location === "path" || parameter.required === true;
|
|
263
|
+
if (isRequired)
|
|
264
|
+
required.push(property);
|
|
265
|
+
const style = typeof parameter.style === "string" ? parameter.style : location === "query" ? "form" : "simple";
|
|
266
|
+
const explode = typeof parameter.explode === "boolean" ? parameter.explode : style === "form";
|
|
267
|
+
parameters.push({ name, in: location, required: isRequired, property, style, explode });
|
|
268
|
+
}
|
|
269
|
+
if (problem) {
|
|
270
|
+
skip(problem);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
let body;
|
|
274
|
+
const requestBody = isRecord(op.requestBody) ? op.requestBody : undefined;
|
|
275
|
+
if (requestBody && isRecord(requestBody.content)) {
|
|
276
|
+
const contentType = bodyType(requestBody.content);
|
|
277
|
+
if (!contentType) {
|
|
278
|
+
skip(`its body is ${Object.keys(requestBody.content).join(" or ")}, which arguments cannot carry`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const media = requestBody.content[contentType];
|
|
282
|
+
const schema = toJsonSchema(isRecord(media?.schema) ? media.schema : { type: "object" }, true);
|
|
283
|
+
const bodyRequired = requestBody.required === true;
|
|
284
|
+
const own = isRecord(schema.properties) ? schema.properties : undefined;
|
|
285
|
+
// Spread a plain object's fields into the input, so a model writes { name, tag } rather than
|
|
286
|
+
// { body: { name, tag } }. A field whose name is taken, by a parameter or by Slipway, is renamed body_<field>.
|
|
287
|
+
const fields = {};
|
|
288
|
+
const plain = own !== undefined && (schema.type === "object" || schema.type === undefined) && !schema.allOf && !schema.anyOf && !schema.oneOf && !isRecord(schema.additionalProperties);
|
|
289
|
+
let spreadable = plain;
|
|
290
|
+
for (const field of plain ? Object.keys(own) : []) {
|
|
291
|
+
let property = field;
|
|
292
|
+
if (!PROPERTY.test(property) || property in properties || RESERVED.has(property))
|
|
293
|
+
property = `body_${field}`;
|
|
294
|
+
if (!PROPERTY.test(property) || property in properties || property in fields) {
|
|
295
|
+
spreadable = false;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
fields[property] = field;
|
|
299
|
+
}
|
|
300
|
+
if (spreadable) {
|
|
301
|
+
for (const [property, field] of Object.entries(fields))
|
|
302
|
+
properties[property] = own[field];
|
|
303
|
+
if (bodyRequired && Array.isArray(schema.required)) {
|
|
304
|
+
for (const [property, field] of Object.entries(fields))
|
|
305
|
+
if (schema.required.includes(field))
|
|
306
|
+
required.push(property);
|
|
307
|
+
}
|
|
308
|
+
body = { contentType, required: bodyRequired, spread: true, fields };
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
if ("body" in properties) {
|
|
312
|
+
skip("a parameter is already named body");
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
properties.body = { ...schema, description: clip(String(schema.description ?? requestBody.description ?? "The request body."), MAX_DESCRIPTION) };
|
|
316
|
+
if (bodyRequired)
|
|
317
|
+
required.push("body");
|
|
318
|
+
body = { contentType, required: bodyRequired, spread: false, fields: { body: "body" } };
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
let output;
|
|
322
|
+
if (options.typedOutput && isRecord(op.responses)) {
|
|
323
|
+
const success = Object.entries(op.responses).find(([status]) => /^2(\d\d|XX)$/i.test(status));
|
|
324
|
+
const content = success && isRecord(success[1]) && isRecord(success[1].content) ? success[1].content : undefined;
|
|
325
|
+
const type = content ? Object.keys(content).find((name) => /json/i.test(name)) : undefined;
|
|
326
|
+
const schema = type && isRecord(content[type]) ? content[type].schema : undefined;
|
|
327
|
+
if (isRecord(schema))
|
|
328
|
+
output = toJsonSchema(schema, false);
|
|
329
|
+
}
|
|
330
|
+
operations.push({
|
|
331
|
+
operationId,
|
|
332
|
+
method: verb,
|
|
333
|
+
path,
|
|
334
|
+
...(typeof op.summary === "string" ? { summary: op.summary } : {}),
|
|
335
|
+
...(typeof op.description === "string" ? { description: op.description } : {}),
|
|
336
|
+
tags: Array.isArray(op.tags) ? op.tags.filter((tag) => typeof tag === "string") : [],
|
|
337
|
+
deprecated: op.deprecated === true,
|
|
338
|
+
parameters,
|
|
339
|
+
...(body ? { body } : {}),
|
|
340
|
+
input: { type: "object", properties, ...(required.length ? { required: [...new Set(required)] } : {}), additionalProperties: false },
|
|
341
|
+
...(output ? { output } : {}),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return { operations, skipped };
|
|
346
|
+
}
|
|
347
|
+
/** Split a tool's arguments back into what goes in the path, the query, the headers and the body. */
|
|
348
|
+
export function splitInput(operation, args) {
|
|
349
|
+
const input = { path: {}, query: {}, headers: {} };
|
|
350
|
+
for (const parameter of operation.parameters) {
|
|
351
|
+
const value = args[parameter.property];
|
|
352
|
+
if (value === undefined)
|
|
353
|
+
continue;
|
|
354
|
+
if (parameter.in === "header")
|
|
355
|
+
input.headers[parameter.name] = simple(value, parameter.explode);
|
|
356
|
+
else
|
|
357
|
+
input[parameter.in][parameter.name] = value;
|
|
358
|
+
}
|
|
359
|
+
if (operation.body) {
|
|
360
|
+
if (operation.body.spread) {
|
|
361
|
+
const body = {};
|
|
362
|
+
for (const [property, field] of Object.entries(operation.body.fields))
|
|
363
|
+
if (args[property] !== undefined)
|
|
364
|
+
body[field] = args[property];
|
|
365
|
+
if (Object.keys(body).length || operation.body.required)
|
|
366
|
+
input.body = body;
|
|
367
|
+
}
|
|
368
|
+
else if (args.body !== undefined)
|
|
369
|
+
input.body = args.body;
|
|
370
|
+
}
|
|
371
|
+
return input;
|
|
372
|
+
}
|
|
373
|
+
/** Build a tool for every operation in an OpenAPI 3 document. Throws on a pinned document that changed. */
|
|
374
|
+
export function fromOpenAPI(document, options) {
|
|
375
|
+
if (options.pin) {
|
|
376
|
+
const actual = openapiHash(document);
|
|
377
|
+
if (actual !== options.pin.sha256) {
|
|
378
|
+
throw new Error(`The OpenAPI document changed since it was pinned: expected sha256 ${options.pin.sha256}, got ${actual}. Review what changed, then update pin.sha256.`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
const { operations } = readOperations(document, { typedOutput: options.typedOutput === true });
|
|
382
|
+
const include = options.include;
|
|
383
|
+
const chosen = operations.filter((operation) => include === undefined ? true : typeof include === "function" ? include(operation) : include.includes(operation.operationId));
|
|
384
|
+
if (Array.isArray(include)) {
|
|
385
|
+
const missing = include.filter((id) => !operations.some((operation) => operation.operationId === id));
|
|
386
|
+
if (missing.length)
|
|
387
|
+
throw new Error(`No operations in the document with these ids: ${missing.join(", ")}.`);
|
|
388
|
+
}
|
|
389
|
+
const byName = new Map();
|
|
390
|
+
const tools = chosen.map((operation) => {
|
|
391
|
+
const name = options.names?.[operation.operationId] ?? toolName(`${options.prefix ?? ""}${options.prefix ? "_" : ""}${operation.operationId}`);
|
|
392
|
+
const clash = byName.get(name);
|
|
393
|
+
if (clash) {
|
|
394
|
+
throw new Error(`Operations ${clash.operationId} and ${operation.operationId} both become the tool ${name}. Name one of them with options.names.`);
|
|
395
|
+
}
|
|
396
|
+
byName.set(name, operation);
|
|
397
|
+
const risk = options.risk?.[operation.operationId] ?? (READS.has(operation.method) ? "read" : operation.method === "DELETE" ? "destructive" : "write");
|
|
398
|
+
const summary = operation.summary?.trim();
|
|
399
|
+
const about = [operation.deprecated ? "Deprecated." : "", summary ?? "", operation.description && operation.description.trim() !== summary ? operation.description : ""]
|
|
400
|
+
.filter(Boolean)
|
|
401
|
+
.join(" ");
|
|
402
|
+
return defineTool({
|
|
403
|
+
name,
|
|
404
|
+
title: clip(summary || words(operation.operationId), 60),
|
|
405
|
+
description: clip(about || `${operation.method} ${operation.path}`, MAX_DESCRIPTION),
|
|
406
|
+
input: jsonSchema(operation.input),
|
|
407
|
+
...(operation.output ? { output: jsonSchema(operation.output) } : {}),
|
|
408
|
+
risk,
|
|
409
|
+
tags: [...new Set(operation.tags.map(slug).filter((tag) => /^[a-z0-9][a-z0-9-]*$/.test(tag)))],
|
|
410
|
+
summary: (args) => {
|
|
411
|
+
const shown = operation.parameters.filter((parameter) => parameter.in === "path").map((parameter) => String(args[parameter.property]));
|
|
412
|
+
return `${operation.method} ${operation.path}${shown.length ? ` (${shown.join(", ")})` : ""}`;
|
|
413
|
+
},
|
|
414
|
+
handler: (args, ctx) => options.execute(operation, splitInput(operation, args), ctx),
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
return tools;
|
|
418
|
+
}
|
|
419
|
+
const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
420
|
+
/**
|
|
421
|
+
* A query parameter written the way its document says. OpenAPI's default,
|
|
422
|
+
* form with explode, repeats a key for each list item and spreads an object's
|
|
423
|
+
* fields into parameters of their own; deepObject writes `key[field]`.
|
|
424
|
+
*/
|
|
425
|
+
function appendQuery(params, parameter, value) {
|
|
426
|
+
if (value === undefined || value === null)
|
|
427
|
+
return;
|
|
428
|
+
const key = parameter.name;
|
|
429
|
+
if (Array.isArray(value)) {
|
|
430
|
+
if (parameter.explode)
|
|
431
|
+
for (const item of value)
|
|
432
|
+
params.append(key, String(item));
|
|
433
|
+
else
|
|
434
|
+
params.append(key, value.map(String).join(parameter.style === "spaceDelimited" ? " " : parameter.style === "pipeDelimited" ? "|" : ","));
|
|
435
|
+
}
|
|
436
|
+
else if (typeof value === "object") {
|
|
437
|
+
const entries = Object.entries(value).filter(([, inner]) => inner !== undefined && inner !== null);
|
|
438
|
+
if (parameter.style === "deepObject")
|
|
439
|
+
for (const [field, inner] of entries)
|
|
440
|
+
params.append(`${key}[${field}]`, typeof inner === "object" ? JSON.stringify(inner) : String(inner));
|
|
441
|
+
else if (parameter.explode)
|
|
442
|
+
for (const [field, inner] of entries)
|
|
443
|
+
params.append(field, String(inner));
|
|
444
|
+
else
|
|
445
|
+
params.append(key, entries.flatMap(([field, inner]) => [field, String(inner)]).join(","));
|
|
446
|
+
}
|
|
447
|
+
else
|
|
448
|
+
params.append(key, String(value));
|
|
449
|
+
}
|
|
450
|
+
/** A path or header value in OpenAPI's simple style: lists and objects joined with commas. */
|
|
451
|
+
function simple(value, explode) {
|
|
452
|
+
if (Array.isArray(value))
|
|
453
|
+
return value.map(String).join(",");
|
|
454
|
+
if (value !== null && typeof value === "object") {
|
|
455
|
+
return Object.entries(value).map(([field, inner]) => (explode ? `${field}=${String(inner)}` : `${field},${String(inner)}`)).join(",");
|
|
456
|
+
}
|
|
457
|
+
return String(value);
|
|
458
|
+
}
|
|
459
|
+
/** A form body, with nested objects and lists in the bracket style form APIs read: `metadata[plan]=pro`, `items[0][price]=...`. */
|
|
460
|
+
export function formEncode(body) {
|
|
461
|
+
const params = new URLSearchParams();
|
|
462
|
+
const visit = (prefix, value) => {
|
|
463
|
+
if (value === undefined || value === null)
|
|
464
|
+
return;
|
|
465
|
+
if (Array.isArray(value))
|
|
466
|
+
value.forEach((item, i) => visit(`${prefix}[${i}]`, item));
|
|
467
|
+
else if (typeof value === "object")
|
|
468
|
+
for (const [key, inner] of Object.entries(value))
|
|
469
|
+
visit(prefix ? `${prefix}[${key}]` : key, inner);
|
|
470
|
+
else
|
|
471
|
+
params.append(prefix, String(value));
|
|
472
|
+
};
|
|
473
|
+
visit("", body);
|
|
474
|
+
return params.toString();
|
|
475
|
+
}
|
|
476
|
+
/** What an API said went wrong, from the fields error bodies usually carry. */
|
|
477
|
+
function errorMessage(data) {
|
|
478
|
+
if (typeof data === "string")
|
|
479
|
+
return data.trim().slice(0, 500) || undefined;
|
|
480
|
+
if (!isRecord(data))
|
|
481
|
+
return undefined;
|
|
482
|
+
const error = data.error;
|
|
483
|
+
const candidates = [
|
|
484
|
+
data.message,
|
|
485
|
+
isRecord(error) ? error.message : error,
|
|
486
|
+
data.detail,
|
|
487
|
+
data.title,
|
|
488
|
+
Array.isArray(data.errors) && isRecord(data.errors[0]) ? data.errors[0].message : undefined,
|
|
489
|
+
];
|
|
490
|
+
const found = candidates.find((candidate) => typeof candidate === "string" && candidate.trim());
|
|
491
|
+
return found ? String(found).slice(0, 500) : undefined;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Calls the API over HTTP. Errors come back as Slipway errors with the API's
|
|
495
|
+
* own message and status, so a model can act on a 404 or a 429 like any other.
|
|
496
|
+
*/
|
|
497
|
+
export function httpExecutor(options) {
|
|
498
|
+
return async (operation, input, ctx) => {
|
|
499
|
+
const base = typeof options.baseUrl === "function" ? options.baseUrl(ctx) : options.baseUrl;
|
|
500
|
+
const root = new URL(base);
|
|
501
|
+
if (root.protocol !== "https:" && !(root.protocol === "http:" && LOOPBACK.has(root.hostname))) {
|
|
502
|
+
throw new UsageError(`Refusing to call ${root.origin}: only https, or http to this machine, carries credentials safely.`);
|
|
503
|
+
}
|
|
504
|
+
const byName = new Map(operation.parameters.map((parameter) => [`${parameter.in}:${parameter.name}`, parameter]));
|
|
505
|
+
const path = operation.path.replace(/\{([^}]+)\}/g, (_match, name) => {
|
|
506
|
+
const value = input.path[name];
|
|
507
|
+
if (value === undefined || value === null || value === "")
|
|
508
|
+
throw new UsageError(`${operation.operationId} needs the path parameter ${name}.`);
|
|
509
|
+
return simple(value, byName.get(`path:${name}`)?.explode ?? false).split(",").map(encodeURIComponent).join(",");
|
|
510
|
+
});
|
|
511
|
+
const url = new URL(`${root.href.replace(/\/+$/, "")}${path}`);
|
|
512
|
+
for (const [name, value] of Object.entries(input.query)) {
|
|
513
|
+
appendQuery(url.searchParams, byName.get(`query:${name}`) ?? { name, in: "query", required: false, property: name, style: "form", explode: true }, value);
|
|
514
|
+
}
|
|
515
|
+
const headers = new Headers({ accept: "application/json" });
|
|
516
|
+
for (const [key, value] of Object.entries(input.headers))
|
|
517
|
+
headers.set(key, value);
|
|
518
|
+
for (const [key, value] of Object.entries((await options.headers?.(ctx)) ?? {}))
|
|
519
|
+
if (value !== undefined)
|
|
520
|
+
headers.set(key, value);
|
|
521
|
+
let body;
|
|
522
|
+
if (input.body !== undefined) {
|
|
523
|
+
const form = /x-www-form-urlencoded/i.test(operation.body?.contentType ?? "");
|
|
524
|
+
body = form ? formEncode(input.body) : JSON.stringify(input.body);
|
|
525
|
+
headers.set("content-type", form ? "application/x-www-form-urlencoded" : "application/json");
|
|
526
|
+
}
|
|
527
|
+
const response = await (options.fetch ?? fetch)(url, { method: operation.method, headers, ...(body === undefined ? {} : { body }), signal: ctx.signal });
|
|
528
|
+
const text = await response.text();
|
|
529
|
+
let data = text;
|
|
530
|
+
if (/json/i.test(response.headers.get("content-type") ?? "") || /^[[{]/.test(text.trim())) {
|
|
531
|
+
try {
|
|
532
|
+
data = JSON.parse(text);
|
|
533
|
+
}
|
|
534
|
+
catch {
|
|
535
|
+
data = text;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (!response.ok) {
|
|
539
|
+
const retry = Number(response.headers.get("retry-after"));
|
|
540
|
+
throw httpError(response.status, errorMessage(data) ?? `${operation.method} ${operation.path} failed: ${response.status} ${response.statusText}`.trim(), {
|
|
541
|
+
details: data,
|
|
542
|
+
...(Number.isFinite(retry) && retry > 0 ? { retryAfterSeconds: retry } : {}),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
if (text.trim() === "")
|
|
546
|
+
return { ok: true, status: response.status };
|
|
547
|
+
return data;
|
|
548
|
+
};
|
|
549
|
+
}
|
package/dist/pages.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following a list to its end.
|
|
3
|
+
*
|
|
4
|
+
* A cursor-paginated tool says where its cursor and its items are, so the CLI's
|
|
5
|
+
* `--all` and `data sync` can walk every page the same way, instead of each
|
|
6
|
+
* leaving the loop to a script or a model.
|
|
7
|
+
*/
|
|
8
|
+
import type { App, InvokeOptions } from "./app.js";
|
|
9
|
+
import type { Tool } from "./tool.js";
|
|
10
|
+
export declare function getPath(data: unknown, path: string): unknown;
|
|
11
|
+
export type PagesRun = {
|
|
12
|
+
count: number;
|
|
13
|
+
pages: number;
|
|
14
|
+
next_cursor: unknown;
|
|
15
|
+
complete: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Call a tool page after page, handing each page's items to `onPage`, until the
|
|
19
|
+
* pages run out or `max` items arrived. A tool that does not page is called
|
|
20
|
+
* once. `complete` is true when the list ended, not the item limit.
|
|
21
|
+
*/
|
|
22
|
+
export declare function eachPage(app: App, tool: Tool, args: Record<string, unknown>, options: InvokeOptions, itemsPath: string, max: number, onPage: (items: unknown[]) => void | Promise<void>): Promise<PagesRun>;
|
package/dist/pages.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Following a list to its end.
|
|
3
|
+
*
|
|
4
|
+
* A cursor-paginated tool says where its cursor and its items are, so the CLI's
|
|
5
|
+
* `--all` and `data sync` can walk every page the same way, instead of each
|
|
6
|
+
* leaving the loop to a script or a model.
|
|
7
|
+
*/
|
|
8
|
+
import { EXIT, SlipwayError } from "./errors.js";
|
|
9
|
+
import { isContentResult } from "./result.js";
|
|
10
|
+
export function getPath(data, path) {
|
|
11
|
+
let current = data;
|
|
12
|
+
for (const part of path.split(".").filter(Boolean)) {
|
|
13
|
+
if (current === null || typeof current !== "object")
|
|
14
|
+
return undefined;
|
|
15
|
+
current = current[part];
|
|
16
|
+
}
|
|
17
|
+
return current;
|
|
18
|
+
}
|
|
19
|
+
/** A hard ceiling, so an API that keeps returning a new cursor forever cannot spin forever. */
|
|
20
|
+
const MAX_PAGES = 10_000;
|
|
21
|
+
/**
|
|
22
|
+
* Call a tool page after page, handing each page's items to `onPage`, until the
|
|
23
|
+
* pages run out or `max` items arrived. A tool that does not page is called
|
|
24
|
+
* once. `complete` is true when the list ended, not the item limit.
|
|
25
|
+
*/
|
|
26
|
+
export async function eachPage(app, tool, args, options, itemsPath, max, onPage) {
|
|
27
|
+
const paginate = tool.paginate;
|
|
28
|
+
let count = 0;
|
|
29
|
+
let pages = 0;
|
|
30
|
+
let cursor = paginate ? args[paginate.cursorArg] : undefined;
|
|
31
|
+
for (;;) {
|
|
32
|
+
const pageArgs = { ...args };
|
|
33
|
+
if (paginate) {
|
|
34
|
+
if (cursor !== undefined && cursor !== null && cursor !== "")
|
|
35
|
+
pageArgs[paginate.cursorArg] = cursor;
|
|
36
|
+
else
|
|
37
|
+
delete pageArgs[paginate.cursorArg];
|
|
38
|
+
if (paginate.limitArg && paginate.maxLimit && pageArgs[paginate.limitArg] === undefined) {
|
|
39
|
+
pageArgs[paginate.limitArg] = Math.max(1, Math.min(paginate.maxLimit, max - count));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const value = await app.invoke(tool.name, pageArgs, options);
|
|
43
|
+
const data = isContentResult(value) ? value.data : value;
|
|
44
|
+
const page = getPath(data, itemsPath);
|
|
45
|
+
if (!Array.isArray(page))
|
|
46
|
+
throw new SlipwayError(`${tool.name} returned no list at '${itemsPath}'.`, "internal", EXIT.error);
|
|
47
|
+
const kept = page.slice(0, Math.max(0, max - count));
|
|
48
|
+
await onPage(kept);
|
|
49
|
+
count += kept.length;
|
|
50
|
+
pages++;
|
|
51
|
+
if (!paginate)
|
|
52
|
+
return { count, pages, next_cursor: null, complete: kept.length === page.length };
|
|
53
|
+
const next = getPath(data, paginate.nextCursor);
|
|
54
|
+
const ended = next === undefined || next === null || next === "" || next === cursor;
|
|
55
|
+
if (ended)
|
|
56
|
+
return { count, pages, next_cursor: null, complete: true };
|
|
57
|
+
cursor = next;
|
|
58
|
+
if (count >= max || pages >= MAX_PAGES)
|
|
59
|
+
return { count, pages, next_cursor: cursor ?? null, complete: false };
|
|
60
|
+
}
|
|
61
|
+
}
|