toolcraft-openapi 0.0.138 → 0.0.140
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/README.md +42 -0
- package/dist/composition.json +2 -2
- package/dist/generate.d.ts +1 -0
- package/dist/generate.js +9 -9
- package/dist/http.js +14 -10
- package/dist/index.d.ts +1 -0
- package/dist/network-error.js +6 -0
- package/dist/normalize-swagger.js +23 -7
- package/dist/runtime.d.ts +12 -3
- package/dist/runtime.js +91 -21
- package/dist/spec-cache.d.ts +41 -0
- package/dist/spec-cache.js +376 -0
- package/dist/spec-source.d.ts +26 -0
- package/dist/spec-source.js +117 -11
- package/node_modules/toolcraft-schema/package.json +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -50,6 +50,45 @@ export const client = defineGeneratedClient({
|
|
|
50
50
|
Generated lower-level group and operation exports remain available when callers
|
|
51
51
|
want a curated command surface with `defineClient()`.
|
|
52
52
|
|
|
53
|
+
## Live runtime clients
|
|
54
|
+
|
|
55
|
+
`defineClientFromSpec()` materializes the same OpenAPI command model at process startup without
|
|
56
|
+
writing generated source files:
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const client = await defineClientFromSpec("https://api.example.com/openapi.json", {
|
|
60
|
+
name: "internal-agent",
|
|
61
|
+
baseUrl: "https://api.example.com",
|
|
62
|
+
auth,
|
|
63
|
+
cache: {
|
|
64
|
+
onFallback: (message) => process.stderr.write(`${message}\n`)
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
HTTP sources use a cross-process disk cache by default when the built-in `fetch` is used.
|
|
70
|
+
Successful documents remain fresh for the server's `Cache-Control: max-age`, or five
|
|
71
|
+
minutes when the server does not provide one. Stale entries revalidate with `If-None-Match` when
|
|
72
|
+
an ETag is available. Fetching and reading the response have a three-second timeout; a transport
|
|
73
|
+
failure or timeout uses the last successfully materialized document. HTTP errors and invalid live
|
|
74
|
+
documents still fail visibly.
|
|
75
|
+
|
|
76
|
+
Runtime source options:
|
|
77
|
+
|
|
78
|
+
- `cache: false` - disables HTTP source caching and offline fallback.
|
|
79
|
+
- `cache.directory` - absolute cache directory override.
|
|
80
|
+
- `cache.maxAgeMs` - fallback freshness when the response does not define cache behavior.
|
|
81
|
+
- `cache.onFallback(message)` - reports when a stale document is used after a transport failure.
|
|
82
|
+
- `onTimeout({ source, timeoutMs, usingCachedDocument })` - application hook for presenting
|
|
83
|
+
network-access guidance without coupling the library to a specific network or VPN.
|
|
84
|
+
- `timeoutMs` - total HTTP fetch/body timeout. Defaults to `3000`; `0` disables it.
|
|
85
|
+
|
|
86
|
+
Supplying a custom `fetch` disables automatic caching unless `cache` is explicitly provided. Use a
|
|
87
|
+
different `cache.directory` for each identity when explicitly caching authenticated custom fetches.
|
|
88
|
+
A custom filesystem participates when it provides `realpath` and the cache write operations;
|
|
89
|
+
read-only filesystems still load the live document without persisting it. New cache entries are
|
|
90
|
+
committed only after the command tree materializes successfully.
|
|
91
|
+
|
|
53
92
|
### CI drift check
|
|
54
93
|
|
|
55
94
|
```sh
|
|
@@ -79,6 +118,9 @@ toolcraft-openapi-generate --check
|
|
|
79
118
|
- `AUTH_BACKEND` - forwarded to `auth-store` to select `file` or `keychain` storage.
|
|
80
119
|
- `TOOLCRAFT_OPENAPI_ENV` - selects a configured OpenAPI environment when using
|
|
81
120
|
`resolveOpenApiBaseUrl`.
|
|
121
|
+
- `TOOLCRAFT_OPENAPI_CACHE_DIR` - absolute directory for live HTTP OpenAPI cache entries.
|
|
122
|
+
- `TOOLCRAFT_OPENAPI_CACHE` - set to `0` or `false` to disable the default live HTTP cache.
|
|
123
|
+
- `XDG_CACHE_HOME` - cache root fallback; defaults to `~/.cache`.
|
|
82
124
|
|
|
83
125
|
## Configuration
|
|
84
126
|
|
package/dist/composition.json
CHANGED
package/dist/generate.d.ts
CHANGED
|
@@ -269,4 +269,5 @@ export declare function generateSkill(document: OpenApiDocument, options?: {
|
|
|
269
269
|
export declare function collectGeneratedCommands(document: OpenApiDocument, config?: ToolcraftConfig): GeneratedCommand[];
|
|
270
270
|
export declare function collectGeneratedCommand(document: OpenApiDocument, path: string, method: HttpMethod): GeneratedCommand;
|
|
271
271
|
export declare function collectSchemaOptionEntries(param: RenderSchemaOptionsInput): SchemaOptionEntry[];
|
|
272
|
+
export declare function collectTagDescriptions(document: OpenApiDocument): Map<string, string>;
|
|
272
273
|
export {};
|
package/dist/generate.js
CHANGED
|
@@ -227,9 +227,7 @@ function refreshGeneratedCommandNames(command) {
|
|
|
227
227
|
command.exportName = command.topLevel
|
|
228
228
|
? `${toCamelCase(command.verb)}Command`
|
|
229
229
|
: `${toCamelCase(command.noun)}${toPascalCase(command.verb)}Command`;
|
|
230
|
-
command.filePath = command.topLevel
|
|
231
|
-
? `${command.verb}.ts`
|
|
232
|
-
: `${command.noun}/${command.verb}.ts`;
|
|
230
|
+
command.filePath = command.topLevel ? `${command.verb}.ts` : `${command.noun}/${command.verb}.ts`;
|
|
233
231
|
}
|
|
234
232
|
export function collectGeneratedCommand(document, path, method) {
|
|
235
233
|
const normalizedDocument = normalizeOpenApiDocument(document);
|
|
@@ -616,10 +614,12 @@ function collectRequestBodyParams(document, operation, operationId, method) {
|
|
|
616
614
|
function resolveSuccessResponse(document, operation, operationId) {
|
|
617
615
|
let textualMediaType;
|
|
618
616
|
let binaryMediaType;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
617
|
+
const responses = Object.entries(operation.responses ?? {});
|
|
618
|
+
const explicitSuccessResponses = responses.filter(([statusCode]) => isSuccessStatusCode(statusCode));
|
|
619
|
+
const candidateResponses = explicitSuccessResponses.length > 0
|
|
620
|
+
? explicitSuccessResponses
|
|
621
|
+
: responses.filter(([statusCode]) => statusCode === "default");
|
|
622
|
+
for (const [statusCode, response] of candidateResponses) {
|
|
623
623
|
const resolvedResponse = expectResponse(document, response, operationId, statusCode);
|
|
624
624
|
const declaredMediaTypes = Object.entries(resolvedResponse.content ?? {})
|
|
625
625
|
.filter(([, mediaType]) => mediaType !== undefined)
|
|
@@ -1163,7 +1163,7 @@ function collectPathPlaceholders(path) {
|
|
|
1163
1163
|
return placeholders;
|
|
1164
1164
|
}
|
|
1165
1165
|
function isSuccessStatusCode(statusCode) {
|
|
1166
|
-
if (statusCode === "
|
|
1166
|
+
if (statusCode === "2XX") {
|
|
1167
1167
|
return true;
|
|
1168
1168
|
}
|
|
1169
1169
|
return (statusCode.length === 3 &&
|
|
@@ -1858,7 +1858,7 @@ function resolveQueryObjectSerialization(parameter, operationId) {
|
|
|
1858
1858
|
}
|
|
1859
1859
|
throw new UserError(`Operation ${JSON.stringify(operationId)} uses unsupported query-object serialization for parameter ${JSON.stringify(parameter.name)}. Supported in v1: deepObject with explode true.`);
|
|
1860
1860
|
}
|
|
1861
|
-
function collectTagDescriptions(document) {
|
|
1861
|
+
export function collectTagDescriptions(document) {
|
|
1862
1862
|
const descriptions = new Map();
|
|
1863
1863
|
for (const tag of document.tags ?? []) {
|
|
1864
1864
|
if (typeof tag.name !== "string" || tag.name.length === 0) {
|
package/dist/http.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { text as designText } from "toolcraft-design";
|
|
4
|
-
import { HttpError, UserError, createHttpError } from "toolcraft";
|
|
4
|
+
import { HttpError, UserError, createHttpError, shouldEmitDiagnostic } from "toolcraft";
|
|
5
5
|
import { classifyNetworkError } from "./network-error.js";
|
|
6
6
|
import { redactHeaders, redactHeaderValue, redactSensitiveQueryValues } from "./redaction.js";
|
|
7
7
|
export { HttpError };
|
|
@@ -29,7 +29,7 @@ export async function requestJson(options) {
|
|
|
29
29
|
: {};
|
|
30
30
|
const headers = createHeaders(token, hasBody, { ...options.headers, ...idempotencyHeader }, options.accept, options.bodyMode, options.contentType);
|
|
31
31
|
emitHttpDebug(options, `${method} ${url}`, { method, url });
|
|
32
|
-
emitHttpTrace(options, "HTTP request transcript", formatVerboseRequestTranscript(method, url, headers, options.body));
|
|
32
|
+
emitHttpTrace(options, "HTTP request transcript", () => formatVerboseRequestTranscript(method, url, headers, options.body));
|
|
33
33
|
const response = await fetchWithRetries(options, url, {
|
|
34
34
|
method,
|
|
35
35
|
headers,
|
|
@@ -42,7 +42,7 @@ export async function requestJson(options) {
|
|
|
42
42
|
if (response.ok && options.responseMode === "binary") {
|
|
43
43
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
44
44
|
if (bytes.byteLength === 0) {
|
|
45
|
-
emitHttpTrace(options, "HTTP response transcript", formatVerboseResponseTranscript(response, responseHeaders));
|
|
45
|
+
emitHttpTrace(options, "HTTP response transcript", () => formatVerboseResponseTranscript(response, responseHeaders));
|
|
46
46
|
return formatRawResponseResult(undefined, response, options.rawResponse);
|
|
47
47
|
}
|
|
48
48
|
const body = {
|
|
@@ -51,17 +51,17 @@ export async function requestJson(options) {
|
|
|
51
51
|
byteLength: bytes.byteLength,
|
|
52
52
|
data: Buffer.from(bytes).toString("base64")
|
|
53
53
|
};
|
|
54
|
-
emitHttpTrace(options, "HTTP response transcript", formatVerboseResponseTranscript(response, responseHeaders, body));
|
|
54
|
+
emitHttpTrace(options, "HTTP response transcript", () => formatVerboseResponseTranscript(response, responseHeaders, body));
|
|
55
55
|
return formatRawResponseResult(body, response, options.rawResponse);
|
|
56
56
|
}
|
|
57
57
|
const text = await response.text();
|
|
58
58
|
if (response.ok) {
|
|
59
59
|
if (text.length === 0) {
|
|
60
|
-
emitHttpTrace(options, "HTTP response transcript", formatVerboseResponseTranscript(response, responseHeaders));
|
|
60
|
+
emitHttpTrace(options, "HTTP response transcript", () => formatVerboseResponseTranscript(response, responseHeaders));
|
|
61
61
|
return formatRawResponseResult(undefined, response, options.rawResponse);
|
|
62
62
|
}
|
|
63
63
|
if (options.responseMode === "text") {
|
|
64
|
-
emitHttpTrace(options, "HTTP response transcript", formatVerboseResponseTranscript(response, responseHeaders, text));
|
|
64
|
+
emitHttpTrace(options, "HTTP response transcript", () => formatVerboseResponseTranscript(response, responseHeaders, text));
|
|
65
65
|
return formatRawResponseResult(text, response, options.rawResponse);
|
|
66
66
|
}
|
|
67
67
|
if (!isJsonContentType(contentType)) {
|
|
@@ -92,7 +92,7 @@ export async function requestJson(options) {
|
|
|
92
92
|
message: "Expected a valid JSON response body but received malformed JSON."
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
|
-
emitHttpTrace(options, "HTTP response transcript", formatVerboseResponseTranscript(response, responseHeaders, body));
|
|
95
|
+
emitHttpTrace(options, "HTTP response transcript", () => formatVerboseResponseTranscript(response, responseHeaders, body));
|
|
96
96
|
return formatRawResponseResult(body, response, options.rawResponse);
|
|
97
97
|
}
|
|
98
98
|
if (response.status === 401 && options.auth === "required") {
|
|
@@ -153,13 +153,17 @@ function emitHttpDebug(options, message, data) {
|
|
|
153
153
|
data
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
-
function emitHttpTrace(options, message,
|
|
157
|
-
options.diagnostics
|
|
156
|
+
function emitHttpTrace(options, message, createLines) {
|
|
157
|
+
const diagnostics = options.diagnostics;
|
|
158
|
+
if (diagnostics === undefined || !shouldEmitDiagnostic("trace", diagnostics.level)) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
diagnostics.emit({
|
|
158
162
|
level: "trace",
|
|
159
163
|
message,
|
|
160
164
|
category: "http",
|
|
161
165
|
data: {
|
|
162
|
-
transcript: formatTranscriptLines(
|
|
166
|
+
transcript: formatTranscriptLines(createLines())
|
|
163
167
|
}
|
|
164
168
|
});
|
|
165
169
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type { InspectOpenApiSourceOptions, OpenApiInspectionSource } from "./ins
|
|
|
14
14
|
export { renderOpenApiInspection } from "./render-inspection.js";
|
|
15
15
|
export { commandsFromSpec, defineClientFromSpec, resolveOpenApiBaseUrl } from "./runtime.js";
|
|
16
16
|
export type { CommandsFromSpecOptions, DefineClientFromSpecOptions, OpenApiDocumentSource } from "./runtime.js";
|
|
17
|
+
export type { OpenApiSpecCacheFileSystem, OpenApiSpecCacheOptions, OpenApiTimeoutContext } from "./spec-cache.js";
|
|
17
18
|
export type { DefineClientOptions, DefinedClient, OpenApiClientServices } from "./define-client.js";
|
|
18
19
|
export type { AuthProvider, CommandContributor, TokenSource } from "./auth/types.js";
|
|
19
20
|
export { bearerTokenAuth } from "./auth/bearer-token-auth.js";
|
package/dist/network-error.js
CHANGED
|
@@ -24,6 +24,12 @@ export function classifyNetworkError(error, url) {
|
|
|
24
24
|
return new UserError(`Temporary DNS failure for ${host}. Network may be down.`, {
|
|
25
25
|
cause: error
|
|
26
26
|
});
|
|
27
|
+
case "UND_ERR_SOCKET":
|
|
28
|
+
return new UserError(`Network connection failed: ${redactedUrl}.`, { cause: error });
|
|
29
|
+
case "UND_ERR_CONNECT_TIMEOUT":
|
|
30
|
+
case "UND_ERR_HEADERS_TIMEOUT":
|
|
31
|
+
case "UND_ERR_BODY_TIMEOUT":
|
|
32
|
+
return new UserError(`Request timed out: ${redactedUrl}.`, { cause: error });
|
|
27
33
|
}
|
|
28
34
|
if (findAbortError(error) !== null) {
|
|
29
35
|
return new UserError(`Request aborted: ${redactedUrl}.`, { cause: error });
|
|
@@ -20,6 +20,8 @@ export function normalizeOpenApiDocument(document) {
|
|
|
20
20
|
}
|
|
21
21
|
const consumes = readStringArray(source.consumes);
|
|
22
22
|
const produces = readStringArray(source.produces);
|
|
23
|
+
const { swagger: _swagger, ...sourceWithoutSwagger } = source;
|
|
24
|
+
void _swagger;
|
|
23
25
|
const paths = Object.fromEntries(Object.entries(source.paths ?? {}).map(([path, pathItem]) => [
|
|
24
26
|
path,
|
|
25
27
|
pathItem === undefined
|
|
@@ -36,7 +38,7 @@ export function normalizeOpenApiDocument(document) {
|
|
|
36
38
|
: { securitySchemes: rewriteReferences(source.securityDefinitions) })
|
|
37
39
|
};
|
|
38
40
|
return rewriteReferences({
|
|
39
|
-
...
|
|
41
|
+
...sourceWithoutSwagger,
|
|
40
42
|
openapi: "3.0.3",
|
|
41
43
|
paths,
|
|
42
44
|
components
|
|
@@ -47,7 +49,11 @@ function normalizePathItem(pathItem, documentConsumes, documentProduces, reusabl
|
|
|
47
49
|
...pathItem,
|
|
48
50
|
...(pathItem.parameters === undefined
|
|
49
51
|
? {}
|
|
50
|
-
: {
|
|
52
|
+
: {
|
|
53
|
+
parameters: pathItem.parameters
|
|
54
|
+
.map((parameter) => normalizeParameter(resolveReusableParameter(parameter, reusableParameters)))
|
|
55
|
+
.filter(isOpenApiParameter)
|
|
56
|
+
})
|
|
51
57
|
};
|
|
52
58
|
for (const method of HTTP_METHODS) {
|
|
53
59
|
const operation = pathItem[method];
|
|
@@ -136,7 +142,9 @@ function normalizeResponse(response, produces) {
|
|
|
136
142
|
...response,
|
|
137
143
|
content: Object.fromEntries(mediaTypes.map((mediaType) => [
|
|
138
144
|
mediaType,
|
|
139
|
-
{
|
|
145
|
+
{
|
|
146
|
+
schema: rewriteReferences(responseRecord.schema)
|
|
147
|
+
}
|
|
140
148
|
]))
|
|
141
149
|
};
|
|
142
150
|
}
|
|
@@ -147,7 +155,9 @@ function isSwaggerFormParameter(parameter) {
|
|
|
147
155
|
return !isReferenceObject(parameter) && parameter.in === "formData";
|
|
148
156
|
}
|
|
149
157
|
function normalizeFormParameters(parameters, consumes) {
|
|
150
|
-
const required = parameters
|
|
158
|
+
const required = parameters
|
|
159
|
+
.filter((parameter) => parameter.required === true)
|
|
160
|
+
.map((parameter) => parameter.name);
|
|
151
161
|
const mediaType = consumes.find((value) => value.toLowerCase() === "multipart/form-data") ??
|
|
152
162
|
"application/x-www-form-urlencoded";
|
|
153
163
|
return {
|
|
@@ -161,7 +171,9 @@ function normalizeFormParameters(parameters, consumes) {
|
|
|
161
171
|
parameter.name,
|
|
162
172
|
{
|
|
163
173
|
...parameter.schema,
|
|
164
|
-
...(parameter.description === undefined
|
|
174
|
+
...(parameter.description === undefined
|
|
175
|
+
? {}
|
|
176
|
+
: { description: parameter.description })
|
|
165
177
|
}
|
|
166
178
|
]))
|
|
167
179
|
}
|
|
@@ -184,7 +196,9 @@ function rewriteReferences(value) {
|
|
|
184
196
|
}
|
|
185
197
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [
|
|
186
198
|
key,
|
|
187
|
-
key === "$ref" && typeof entry === "string"
|
|
199
|
+
key === "$ref" && typeof entry === "string"
|
|
200
|
+
? rewriteReference(entry)
|
|
201
|
+
: rewriteReferences(entry)
|
|
188
202
|
]));
|
|
189
203
|
}
|
|
190
204
|
function rewriteReference(reference) {
|
|
@@ -194,7 +208,9 @@ function rewriteReference(reference) {
|
|
|
194
208
|
.replace("#/responses/", "#/components/responses/");
|
|
195
209
|
}
|
|
196
210
|
function readStringArray(value) {
|
|
197
|
-
return Array.isArray(value)
|
|
211
|
+
return Array.isArray(value)
|
|
212
|
+
? value.filter((entry) => typeof entry === "string")
|
|
213
|
+
: [];
|
|
198
214
|
}
|
|
199
215
|
function isRecord(value) {
|
|
200
216
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
package/dist/runtime.d.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { type CommandNode } from "toolcraft";
|
|
2
2
|
import { type DefineClientOptions, type DefinedClient, type OpenApiClientServices } from "./define-client.js";
|
|
3
3
|
import { type OpenApiDocument } from "./generate.js";
|
|
4
|
-
import {
|
|
4
|
+
import type { ToolcraftConfig } from "./config.js";
|
|
5
|
+
import { type OpenApiSpecCacheFileSystem, type OpenApiSpecCacheOptions, type OpenApiTimeoutContext } from "./spec-cache.js";
|
|
5
6
|
export type OpenApiDocumentSource = OpenApiDocument | string | URL;
|
|
6
7
|
export interface CommandsFromSpecOptions {
|
|
7
8
|
cwd?: string;
|
|
8
9
|
fetch?: typeof globalThis.fetch;
|
|
9
|
-
fs?:
|
|
10
|
+
fs?: OpenApiSpecCacheFileSystem;
|
|
11
|
+
cache?: false | OpenApiSpecCacheOptions;
|
|
12
|
+
onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
config?: ToolcraftConfig;
|
|
10
15
|
}
|
|
11
|
-
export type DefineClientFromSpecOptions<TServices extends object = Record<string, never>> = Omit<DefineClientOptions<TServices>, "commands"> & CommandsFromSpecOptions
|
|
16
|
+
export type DefineClientFromSpecOptions<TServices extends object = Record<string, never>> = Omit<DefineClientOptions<TServices>, "baseUrl" | "commands"> & CommandsFromSpecOptions & {
|
|
17
|
+
baseUrl?: string;
|
|
18
|
+
environment?: string;
|
|
19
|
+
env?: Record<string, string | undefined>;
|
|
20
|
+
};
|
|
12
21
|
export interface ResolveOpenApiBaseUrlOptions {
|
|
13
22
|
document: OpenApiDocument;
|
|
14
23
|
environments?: Record<string, string>;
|
package/dist/runtime.js
CHANGED
|
@@ -1,35 +1,87 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import { UserError, defineCommand, defineGroup, S } from "toolcraft";
|
|
3
3
|
import { defineClient } from "./define-client.js";
|
|
4
|
-
import { collectSchemaOptionEntries, collectGeneratedCommands } from "./generate.js";
|
|
4
|
+
import { collectTagDescriptions, collectSchemaOptionEntries, collectGeneratedCommands } from "./generate.js";
|
|
5
5
|
import { groupByNoun } from "./group-by-noun.js";
|
|
6
6
|
import { prepareMultipartFileInputs, requestJson, writeBinaryResponseOutput } from "./http.js";
|
|
7
7
|
import { buildRequestShape, executePreflightBlocks } from "./interpreter.js";
|
|
8
|
+
import { DEFAULT_OPENAPI_FETCH_TIMEOUT_MS, loadCachedOpenApiSource } from "./spec-cache.js";
|
|
8
9
|
import { parseOpenApiDocument, readOpenApiSourceText } from "./spec-source.js";
|
|
9
10
|
const RUNTIME_COMMAND_SCOPE = ["cli", "mcp", "sdk"];
|
|
10
11
|
export async function commandsFromSpec(source, options = {}) {
|
|
11
|
-
const
|
|
12
|
-
|
|
12
|
+
const resolved = await resolveDocument(source, options);
|
|
13
|
+
const commands = createRuntimeNodes(resolved.document, options.config);
|
|
14
|
+
await resolved.commit?.();
|
|
15
|
+
return commands;
|
|
13
16
|
}
|
|
14
17
|
export async function defineClientFromSpec(spec, options) {
|
|
15
|
-
const { cwd, fetch, fs: specFs, ...clientOptions } = options;
|
|
16
|
-
const
|
|
18
|
+
const { cwd, fetch, fs: specFs, cache, onTimeout, timeoutMs, config, baseUrl, environment, env, ...clientOptions } = options;
|
|
19
|
+
const resolved = await resolveDocument(spec, {
|
|
17
20
|
cwd,
|
|
18
21
|
fetch,
|
|
19
|
-
fs: specFs
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
fs: specFs,
|
|
23
|
+
cache,
|
|
24
|
+
onTimeout,
|
|
25
|
+
timeoutMs,
|
|
26
|
+
config
|
|
27
|
+
});
|
|
28
|
+
const document = resolved.document;
|
|
29
|
+
const resolvedBaseUrl = baseUrl ??
|
|
30
|
+
resolveOpenApiBaseUrl({
|
|
31
|
+
document,
|
|
32
|
+
environments: config?.environments,
|
|
33
|
+
environment,
|
|
34
|
+
env: env ?? process.env
|
|
35
|
+
});
|
|
36
|
+
if (resolvedBaseUrl === undefined) {
|
|
37
|
+
throw new UserError("defineClientFromSpec could not resolve a base URL. Pass baseUrl, configure an environment, or define OpenAPI servers[0].url.");
|
|
38
|
+
}
|
|
39
|
+
const commands = createRuntimeNodes(document, config);
|
|
40
|
+
const client = defineClient({ ...clientOptions, baseUrl: resolvedBaseUrl, commands });
|
|
41
|
+
await resolved.commit?.();
|
|
42
|
+
return client;
|
|
22
43
|
}
|
|
23
44
|
async function resolveDocument(source, options) {
|
|
24
45
|
if (typeof source !== "string" && !(source instanceof URL)) {
|
|
25
|
-
return source;
|
|
46
|
+
return { document: source };
|
|
47
|
+
}
|
|
48
|
+
const sourceUrl = source instanceof URL ? source : tryParseUrl(source);
|
|
49
|
+
if (sourceUrl !== null && (sourceUrl.protocol === "http:" || sourceUrl.protocol === "https:")) {
|
|
50
|
+
const loaded = await loadCachedOpenApiSource(sourceUrl, {
|
|
51
|
+
cache: options.cache ?? resolveDefaultCache(options),
|
|
52
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
53
|
+
fs: options.fs ?? fs,
|
|
54
|
+
onTimeout: options.onTimeout,
|
|
55
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_OPENAPI_FETCH_TIMEOUT_MS
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
document: loaded.document,
|
|
59
|
+
...(loaded.commit === undefined ? {} : { commit: loaded.commit })
|
|
60
|
+
};
|
|
26
61
|
}
|
|
27
62
|
const sourceText = await readOpenApiSourceText(source, {
|
|
28
63
|
cwd: options.cwd ?? process.cwd(),
|
|
29
64
|
fetch: options.fetch ?? globalThis.fetch,
|
|
30
65
|
fs: options.fs ?? fs
|
|
31
66
|
});
|
|
32
|
-
return parseOpenApiDocument(sourceText, source);
|
|
67
|
+
return { document: parseOpenApiDocument(sourceText, source) };
|
|
68
|
+
}
|
|
69
|
+
function resolveDefaultCache(options) {
|
|
70
|
+
if (options.fetch !== undefined) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const configured = Object.prototype.hasOwnProperty.call(process.env, "TOOLCRAFT_OPENAPI_CACHE")
|
|
74
|
+
? process.env.TOOLCRAFT_OPENAPI_CACHE?.trim().toLowerCase()
|
|
75
|
+
: undefined;
|
|
76
|
+
return configured === "0" || configured === "false" ? false : {};
|
|
77
|
+
}
|
|
78
|
+
function tryParseUrl(value) {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(value);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
33
85
|
}
|
|
34
86
|
export function resolveOpenApiBaseUrl(options) {
|
|
35
87
|
const environments = options.environments;
|
|
@@ -49,17 +101,24 @@ export function resolveOpenApiBaseUrl(options) {
|
|
|
49
101
|
function normalizeBaseUrl(value) {
|
|
50
102
|
return new URL(value).toString().replace(/\/$/, "");
|
|
51
103
|
}
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
104
|
+
function createRuntimeNodes(document, config) {
|
|
105
|
+
const commands = collectGeneratedCommands(document, config);
|
|
106
|
+
const tagDescriptions = collectTagDescriptions(document);
|
|
107
|
+
return [
|
|
108
|
+
...commands.filter((command) => command.topLevel).map(createRuntimeCommand),
|
|
109
|
+
...groupByNoun(commands).map(({ noun, commands: nounCommands }) => defineGroup({
|
|
110
|
+
name: noun,
|
|
111
|
+
description: tagDescriptions.get(noun),
|
|
112
|
+
children: nounCommands.map(createRuntimeCommand)
|
|
113
|
+
}))
|
|
114
|
+
];
|
|
57
115
|
}
|
|
58
116
|
function createRuntimeCommand(command) {
|
|
59
117
|
const paramsSchema = S.Object(Object.fromEntries(command.params.map((param) => [param.paramName, createRuntimeParamSchema(param)])), command.paramsSchemaOptions);
|
|
60
118
|
return defineCommand({
|
|
61
119
|
name: command.verb,
|
|
62
120
|
...(command.description === undefined ? {} : { description: command.description }),
|
|
121
|
+
...(command.examples === undefined ? {} : { examples: command.examples }),
|
|
63
122
|
scope: RUNTIME_COMMAND_SCOPE,
|
|
64
123
|
...(command.confirm ? { confirm: true } : {}),
|
|
65
124
|
...(command.positional.length > 0 ? { positional: command.positional } : {}),
|
|
@@ -90,22 +149,32 @@ function createRuntimeHandler(command) {
|
|
|
90
149
|
tokenSource,
|
|
91
150
|
fetch,
|
|
92
151
|
diagnostics,
|
|
152
|
+
...(command.rawResponse === true ? { rawResponse: params.rawResponse } : {}),
|
|
153
|
+
...(command.idempotencyHeader === undefined
|
|
154
|
+
? {}
|
|
155
|
+
: {
|
|
156
|
+
idempotency: {
|
|
157
|
+
header: command.idempotencyHeader,
|
|
158
|
+
enabled: true,
|
|
159
|
+
key: params.idempotencyKey
|
|
160
|
+
}
|
|
161
|
+
}),
|
|
93
162
|
...preparedRequestShape
|
|
94
163
|
});
|
|
95
164
|
return writeBinaryResponseOutput(result, command.responseMode === "binary" ? params.output : undefined, { fs, env });
|
|
96
165
|
};
|
|
97
166
|
}
|
|
98
167
|
function createRuntimeParamSchema(param) {
|
|
99
|
-
const definition = createRuntimeDefinition(param.definition, param.description, param.shortFlag, param.scope, param.global);
|
|
168
|
+
const definition = createRuntimeDefinition(param.definition, param.description, param.shortFlag, param.longAliases, param.scope, param.global);
|
|
100
169
|
return param.optional ? S.Optional(definition) : definition;
|
|
101
170
|
}
|
|
102
|
-
function createRuntimeDefinition(definition, description, shortFlag, scope, global) {
|
|
103
|
-
const options = createRuntimeSchemaOptions(definition, description, shortFlag, scope, global);
|
|
171
|
+
function createRuntimeDefinition(definition, description, shortFlag, longAliases, scope, global) {
|
|
172
|
+
const options = createRuntimeSchemaOptions(definition, description, shortFlag, longAliases, scope, global);
|
|
104
173
|
return RUNTIME_DEFINITION_BUILDERS[definition.kind](definition, options);
|
|
105
174
|
}
|
|
106
175
|
const RUNTIME_DEFINITION_BUILDERS = {
|
|
107
176
|
array: (definition, options) => {
|
|
108
|
-
const itemDefinition = createRuntimeDefinition(definition.itemDefinition, undefined, undefined, undefined);
|
|
177
|
+
const itemDefinition = createRuntimeDefinition(definition.itemDefinition, undefined, undefined, undefined, undefined);
|
|
109
178
|
return options === undefined ? S.Array(itemDefinition) : S.Array(itemDefinition, options);
|
|
110
179
|
},
|
|
111
180
|
boolean: (_definition, options) => (options === undefined ? S.Boolean() : S.Boolean(options)),
|
|
@@ -114,18 +183,19 @@ const RUNTIME_DEFINITION_BUILDERS = {
|
|
|
114
183
|
number: (_definition, options) => (options === undefined ? S.Number() : S.Number(options)),
|
|
115
184
|
object: (definition, options) => {
|
|
116
185
|
const shape = Object.fromEntries(definition.properties.map((property) => {
|
|
117
|
-
const propertySchema = createRuntimeDefinition(property.definition, undefined, undefined, undefined);
|
|
186
|
+
const propertySchema = createRuntimeDefinition(property.definition, undefined, undefined, undefined, undefined);
|
|
118
187
|
return [property.name, property.optional ? S.Optional(propertySchema) : propertySchema];
|
|
119
188
|
}));
|
|
120
189
|
return options === undefined ? S.Object(shape) : S.Object(shape, options);
|
|
121
190
|
},
|
|
122
191
|
string: (_definition, options) => (options === undefined ? S.String() : S.String(options))
|
|
123
192
|
};
|
|
124
|
-
function createRuntimeSchemaOptions(definition, description, shortFlag, scope, global) {
|
|
193
|
+
function createRuntimeSchemaOptions(definition, description, shortFlag, longAliases, scope, global) {
|
|
125
194
|
const options = Object.fromEntries(collectSchemaOptionEntries({
|
|
126
195
|
definition,
|
|
127
196
|
description,
|
|
128
197
|
shortFlag,
|
|
198
|
+
longAliases,
|
|
129
199
|
scope,
|
|
130
200
|
global
|
|
131
201
|
}).map(({ key, value }) => [key, Array.isArray(value) ? [...value] : value]));
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type OpenApiSourceFileSystem } from "./spec-source.js";
|
|
2
|
+
import type { OpenApiDocument } from "./generate.js";
|
|
3
|
+
export declare const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3000;
|
|
4
|
+
export declare const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS: number;
|
|
5
|
+
export interface OpenApiSpecCacheOptions {
|
|
6
|
+
directory?: string;
|
|
7
|
+
maxAgeMs?: number;
|
|
8
|
+
onFallback?: (message: string) => void | Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
export interface LoadCachedOpenApiSourceOptions {
|
|
11
|
+
cache: false | OpenApiSpecCacheOptions;
|
|
12
|
+
fetch: typeof globalThis.fetch;
|
|
13
|
+
fs: OpenApiSpecCacheFileSystem;
|
|
14
|
+
onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface OpenApiTimeoutContext {
|
|
18
|
+
source: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
usingCachedDocument: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface OpenApiSpecCacheFileSystem extends OpenApiSourceFileSystem {
|
|
23
|
+
writeFile?(filePath: string, contents: string, options?: {
|
|
24
|
+
encoding?: BufferEncoding;
|
|
25
|
+
flag?: string;
|
|
26
|
+
mode?: number;
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
rename?(fromPath: string, toPath: string): Promise<void>;
|
|
29
|
+
mkdir?(directoryPath: string, options?: {
|
|
30
|
+
recursive?: boolean;
|
|
31
|
+
mode?: number;
|
|
32
|
+
}): Promise<unknown>;
|
|
33
|
+
unlink?(filePath: string): Promise<void>;
|
|
34
|
+
realpath?(filePath: string): Promise<string>;
|
|
35
|
+
}
|
|
36
|
+
export interface LoadedOpenApiSource {
|
|
37
|
+
sourceText: string;
|
|
38
|
+
document: OpenApiDocument;
|
|
39
|
+
commit?: () => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export declare function loadCachedOpenApiSource(inputUrl: URL, options: LoadCachedOpenApiSourceOptions): Promise<LoadedOpenApiSource>;
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { UserError } from "toolcraft";
|
|
5
|
+
import { hasOwnErrorCode } from "./error-codes.js";
|
|
6
|
+
import { fetchOpenApiHttpSource, OpenApiTimeoutError, OpenApiTransportError, parseOpenApiDocument } from "./spec-source.js";
|
|
7
|
+
import { redactSensitiveQueryValues } from "./redaction.js";
|
|
8
|
+
export const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3_000;
|
|
9
|
+
export const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS = 5 * 60_000;
|
|
10
|
+
export async function loadCachedOpenApiSource(inputUrl, options) {
|
|
11
|
+
validateTimeout(options.timeoutMs);
|
|
12
|
+
if (options.cache === false) {
|
|
13
|
+
let result;
|
|
14
|
+
try {
|
|
15
|
+
result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
|
|
16
|
+
timeoutMs: options.timeoutMs
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
notifyTimeout(options, inputUrl, error, false);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (result.status === "not-modified") {
|
|
24
|
+
throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
sourceText: result.sourceText,
|
|
28
|
+
document: parseOpenApiDocument(result.sourceText, inputUrl)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
validateMaxAge(options.cache.maxAgeMs);
|
|
32
|
+
const directory = resolveCacheDirectory(options.cache.directory);
|
|
33
|
+
const cachePath = resolveCachePath(inputUrl, directory);
|
|
34
|
+
const cached = await readCacheEntry(cachePath, options.fs);
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
const freshnessMs = cached?.entry.maxAgeMs;
|
|
37
|
+
if (cached !== null &&
|
|
38
|
+
cached.entry.validatedAt <= now &&
|
|
39
|
+
freshnessMs !== undefined &&
|
|
40
|
+
now - cached.entry.validatedAt < freshnessMs) {
|
|
41
|
+
return {
|
|
42
|
+
sourceText: cached.entry.sourceText,
|
|
43
|
+
document: cached.document
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
let result;
|
|
47
|
+
try {
|
|
48
|
+
result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
|
|
49
|
+
...(cached?.entry.etag === undefined ? {} : { etag: cached.entry.etag }),
|
|
50
|
+
timeoutMs: options.timeoutMs
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
notifyTimeout(options, inputUrl, error, cached !== null);
|
|
55
|
+
if (!(error instanceof OpenApiTransportError) || cached === null) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
notifyFallback(options.cache, inputUrl, error);
|
|
59
|
+
return {
|
|
60
|
+
sourceText: cached.entry.sourceText,
|
|
61
|
+
document: cached.document
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const writableFs = getWritableFileSystem(options.fs);
|
|
65
|
+
if (result.status === "not-modified") {
|
|
66
|
+
if (cached === null) {
|
|
67
|
+
throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
|
|
68
|
+
}
|
|
69
|
+
if (cached.entry.etag === undefined) {
|
|
70
|
+
throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached validator.`);
|
|
71
|
+
}
|
|
72
|
+
const policy = result.cacheControl === undefined
|
|
73
|
+
? {
|
|
74
|
+
store: true,
|
|
75
|
+
maxAgeMs: Math.max(0, cached.entry.maxAgeMs - (parseDeltaSeconds(result.age ?? "") ?? 0) * 1_000)
|
|
76
|
+
}
|
|
77
|
+
: resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, cached.entry.maxAgeMs);
|
|
78
|
+
if (!policy.store) {
|
|
79
|
+
return {
|
|
80
|
+
sourceText: cached.entry.sourceText,
|
|
81
|
+
document: cached.document,
|
|
82
|
+
...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const entry = {
|
|
86
|
+
...cached.entry,
|
|
87
|
+
validatedAt: now,
|
|
88
|
+
maxAgeMs: policy.maxAgeMs,
|
|
89
|
+
...(result.etag === undefined ? {} : { etag: result.etag })
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
sourceText: cached.entry.sourceText,
|
|
93
|
+
document: cached.document,
|
|
94
|
+
...(writableFs === null
|
|
95
|
+
? {}
|
|
96
|
+
: { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const policy = resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, DEFAULT_OPENAPI_CACHE_MAX_AGE_MS);
|
|
100
|
+
const document = parseOpenApiDocument(result.sourceText, inputUrl);
|
|
101
|
+
if (!policy.store) {
|
|
102
|
+
return {
|
|
103
|
+
sourceText: result.sourceText,
|
|
104
|
+
document,
|
|
105
|
+
...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const entry = {
|
|
109
|
+
version: 1,
|
|
110
|
+
sourceText: result.sourceText,
|
|
111
|
+
validatedAt: now,
|
|
112
|
+
maxAgeMs: policy.maxAgeMs,
|
|
113
|
+
...(result.etag === undefined ? {} : { etag: result.etag })
|
|
114
|
+
};
|
|
115
|
+
return {
|
|
116
|
+
sourceText: result.sourceText,
|
|
117
|
+
document,
|
|
118
|
+
...(writableFs === null
|
|
119
|
+
? {}
|
|
120
|
+
: { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function notifyTimeout(options, inputUrl, error, usingCachedDocument) {
|
|
124
|
+
if (!(error instanceof OpenApiTimeoutError) || options.onTimeout === undefined) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
void Promise.resolve(options.onTimeout({
|
|
129
|
+
source: redactSensitiveQueryValues(inputUrl.toString()),
|
|
130
|
+
timeoutMs: error.timeoutMs,
|
|
131
|
+
usingCachedDocument
|
|
132
|
+
})).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// A presentation hook cannot change source-loading behavior.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function resolveCachePolicy(cacheControl, age, configuredMaxAgeMs, fallbackMaxAgeMs) {
|
|
139
|
+
const directives = (cacheControl ?? "")
|
|
140
|
+
.split(",")
|
|
141
|
+
.map((directive) => directive.trim().toLowerCase());
|
|
142
|
+
if (directives.includes("no-store")) {
|
|
143
|
+
return { store: false, maxAgeMs: 0 };
|
|
144
|
+
}
|
|
145
|
+
if (directives.includes("no-cache")) {
|
|
146
|
+
return { store: true, maxAgeMs: 0 };
|
|
147
|
+
}
|
|
148
|
+
for (const directive of directives) {
|
|
149
|
+
if (!directive.startsWith("max-age=")) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const seconds = parseDeltaSeconds(directive.slice("max-age=".length));
|
|
153
|
+
if (seconds !== null) {
|
|
154
|
+
const responseAge = parseDeltaSeconds(age ?? "") ?? 0;
|
|
155
|
+
return { store: true, maxAgeMs: Math.max(0, seconds - responseAge) * 1_000 };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
store: true,
|
|
160
|
+
maxAgeMs: configuredMaxAgeMs ?? fallbackMaxAgeMs
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function parseDeltaSeconds(value) {
|
|
164
|
+
const trimmed = value.trim();
|
|
165
|
+
const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
|
|
166
|
+
? trimmed.slice(1, -1)
|
|
167
|
+
: trimmed;
|
|
168
|
+
if (unquoted.length === 0) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
const seconds = Number(unquoted);
|
|
172
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
|
|
173
|
+
}
|
|
174
|
+
function resolveCacheDirectory(configuredDirectory) {
|
|
175
|
+
const envDirectory = readOwnEnvValue(process.env, "TOOLCRAFT_OPENAPI_CACHE_DIR");
|
|
176
|
+
const xdgCacheHome = readOwnEnvValue(process.env, "XDG_CACHE_HOME");
|
|
177
|
+
const directory = configuredDirectory ?? envDirectory;
|
|
178
|
+
if (directory !== undefined) {
|
|
179
|
+
if (!path.isAbsolute(directory)) {
|
|
180
|
+
throw new UserError("OpenAPI cache directory must be an absolute path.");
|
|
181
|
+
}
|
|
182
|
+
return directory;
|
|
183
|
+
}
|
|
184
|
+
const cacheRoot = xdgCacheHome ?? path.join(os.homedir(), ".cache");
|
|
185
|
+
if (!path.isAbsolute(cacheRoot)) {
|
|
186
|
+
throw new UserError("XDG_CACHE_HOME must be an absolute path.");
|
|
187
|
+
}
|
|
188
|
+
return path.join(cacheRoot, "toolcraft-openapi", "specs");
|
|
189
|
+
}
|
|
190
|
+
function resolveCachePath(inputUrl, directory) {
|
|
191
|
+
const canonicalUrl = new URL(inputUrl);
|
|
192
|
+
canonicalUrl.hash = "";
|
|
193
|
+
const key = createHash("sha256").update(canonicalUrl.toString()).digest("hex");
|
|
194
|
+
return path.join(directory, `${key}.json`);
|
|
195
|
+
}
|
|
196
|
+
async function readCacheEntry(cachePath, fs) {
|
|
197
|
+
if (!hasRealpath(fs)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
await assertSafeCachePath(cachePath, fs);
|
|
202
|
+
const parsed = JSON.parse(await fs.readFile(cachePath, "utf8"));
|
|
203
|
+
if (!isCacheEntry(parsed)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
entry: sanitizeCacheEntry(parsed),
|
|
208
|
+
document: parseOpenApiDocument(parsed.sourceText, cachePath)
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function sanitizeCacheEntry(entry) {
|
|
216
|
+
if (entry.etag === undefined || isValidEtag(entry.etag)) {
|
|
217
|
+
return entry;
|
|
218
|
+
}
|
|
219
|
+
const withoutEtag = { ...entry };
|
|
220
|
+
delete withoutEtag.etag;
|
|
221
|
+
return withoutEtag;
|
|
222
|
+
}
|
|
223
|
+
function isValidEtag(etag) {
|
|
224
|
+
try {
|
|
225
|
+
new Headers({ "If-None-Match": etag });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
async function persistCacheEntry(cachePath, entry, fs) {
|
|
233
|
+
try {
|
|
234
|
+
await assertSafeCachePath(cachePath, fs);
|
|
235
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
|
|
236
|
+
await assertSafeCachePath(cachePath, fs);
|
|
237
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
238
|
+
const temporaryPath = `${cachePath}.${randomUUID()}.tmp`;
|
|
239
|
+
let temporaryCreated = false;
|
|
240
|
+
try {
|
|
241
|
+
await fs.writeFile(temporaryPath, JSON.stringify(entry), {
|
|
242
|
+
encoding: "utf8",
|
|
243
|
+
flag: "wx",
|
|
244
|
+
mode: 0o600
|
|
245
|
+
});
|
|
246
|
+
temporaryCreated = true;
|
|
247
|
+
await fs.rename(temporaryPath, cachePath);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
if (temporaryCreated || !hasOwnErrorCode(error, "EEXIST")) {
|
|
252
|
+
await fs.unlink(temporaryPath).catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
if (!hasOwnErrorCode(error, "EEXIST")) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
// Cache writes are best-effort; the materialized live document remains usable.
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async function removeCacheEntry(cachePath, fs) {
|
|
265
|
+
try {
|
|
266
|
+
await assertSafeCachePath(cachePath, fs);
|
|
267
|
+
await fs.unlink(cachePath);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// Cache removal is best-effort; the materialized live document remains usable.
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function getWritableFileSystem(fs) {
|
|
274
|
+
if (typeof fs.writeFile !== "function" ||
|
|
275
|
+
typeof fs.rename !== "function" ||
|
|
276
|
+
typeof fs.mkdir !== "function" ||
|
|
277
|
+
typeof fs.unlink !== "function" ||
|
|
278
|
+
typeof fs.realpath !== "function") {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return fs;
|
|
282
|
+
}
|
|
283
|
+
function hasRealpath(fs) {
|
|
284
|
+
return typeof fs.realpath === "function";
|
|
285
|
+
}
|
|
286
|
+
async function assertSafeCachePath(cachePath, fs) {
|
|
287
|
+
const directory = path.resolve(path.dirname(cachePath));
|
|
288
|
+
let existingPath = directory;
|
|
289
|
+
while (true) {
|
|
290
|
+
try {
|
|
291
|
+
if (path.resolve(await fs.realpath(existingPath)) !== existingPath) {
|
|
292
|
+
throw new UserError("OpenAPI cache path must remain inside its configured directory.");
|
|
293
|
+
}
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
if (!hasOwnErrorCode(error, "ENOENT")) {
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
const parentPath = path.dirname(existingPath);
|
|
301
|
+
if (parentPath === existingPath) {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
existingPath = parentPath;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
if (path.resolve(await fs.realpath(directory)) !== directory) {
|
|
309
|
+
throw new UserError("OpenAPI cache path must remain inside its configured directory.");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (hasOwnErrorCode(error, "ENOENT")) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
try {
|
|
319
|
+
const canonicalCachePath = path.resolve(await fs.realpath(cachePath));
|
|
320
|
+
if (canonicalCachePath !== path.resolve(cachePath)) {
|
|
321
|
+
throw new UserError("OpenAPI cache path must remain inside its configured directory.");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (!hasOwnErrorCode(error, "ENOENT")) {
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function isCacheEntry(value) {
|
|
331
|
+
if (typeof value !== "object" || value === null) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
const entry = value;
|
|
335
|
+
return (Object.hasOwn(entry, "version") &&
|
|
336
|
+
entry.version === 1 &&
|
|
337
|
+
Object.hasOwn(entry, "sourceText") &&
|
|
338
|
+
typeof entry.sourceText === "string" &&
|
|
339
|
+
Object.hasOwn(entry, "validatedAt") &&
|
|
340
|
+
typeof entry.validatedAt === "number" &&
|
|
341
|
+
Number.isFinite(entry.validatedAt) &&
|
|
342
|
+
Object.hasOwn(entry, "maxAgeMs") &&
|
|
343
|
+
typeof entry.maxAgeMs === "number" &&
|
|
344
|
+
Number.isFinite(entry.maxAgeMs) &&
|
|
345
|
+
entry.maxAgeMs >= 0 &&
|
|
346
|
+
(!Object.hasOwn(entry, "etag") || typeof entry.etag === "string"));
|
|
347
|
+
}
|
|
348
|
+
function notifyFallback(cache, inputUrl, error) {
|
|
349
|
+
if (cache.onFallback === undefined) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
353
|
+
try {
|
|
354
|
+
void Promise.resolve(cache.onFallback(`Using cached OpenAPI document for ${JSON.stringify(redactSensitiveQueryValues(inputUrl.toString()))} because refresh failed: ${message}`)).catch(() => undefined);
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
// Cache fallback remains usable even if a presentation callback fails.
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function validateTimeout(timeoutMs) {
|
|
361
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
|
|
362
|
+
throw new UserError("OpenAPI fetch timeout must be a finite non-negative number.");
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function validateMaxAge(maxAgeMs) {
|
|
366
|
+
if (maxAgeMs !== undefined && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {
|
|
367
|
+
throw new UserError("OpenAPI cache maxAgeMs must be a finite non-negative number.");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function readOwnEnvValue(env, key) {
|
|
371
|
+
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
const value = env[key]?.trim();
|
|
375
|
+
return value === undefined || value.length === 0 ? undefined : value;
|
|
376
|
+
}
|
package/dist/spec-source.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { UserError } from "toolcraft";
|
|
1
2
|
import type { OpenApiDocument } from "./generate.js";
|
|
2
3
|
export interface OpenApiSourceFileSystem {
|
|
3
4
|
readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
|
|
@@ -7,5 +8,30 @@ export interface OpenApiSourceServices {
|
|
|
7
8
|
fetch: typeof globalThis.fetch;
|
|
8
9
|
fs: OpenApiSourceFileSystem;
|
|
9
10
|
}
|
|
11
|
+
export interface OpenApiHttpSourceOptions {
|
|
12
|
+
etag?: string;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
export type OpenApiHttpSourceResult = {
|
|
16
|
+
status: "modified";
|
|
17
|
+
sourceText: string;
|
|
18
|
+
etag?: string;
|
|
19
|
+
cacheControl?: string;
|
|
20
|
+
age?: string;
|
|
21
|
+
} | {
|
|
22
|
+
status: "not-modified";
|
|
23
|
+
etag?: string;
|
|
24
|
+
cacheControl?: string;
|
|
25
|
+
age?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare class OpenApiHttpStatusError extends UserError {
|
|
28
|
+
}
|
|
29
|
+
export declare class OpenApiTransportError extends UserError {
|
|
30
|
+
}
|
|
31
|
+
export declare class OpenApiTimeoutError extends OpenApiTransportError {
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
constructor(message: string, timeoutMs: number, options?: ErrorOptions);
|
|
34
|
+
}
|
|
10
35
|
export declare function readOpenApiSourceText(input: string | URL, services: OpenApiSourceServices): Promise<string>;
|
|
36
|
+
export declare function fetchOpenApiHttpSource(inputUrl: URL, fetch: typeof globalThis.fetch, options?: OpenApiHttpSourceOptions): Promise<OpenApiHttpSourceResult>;
|
|
11
37
|
export declare function parseOpenApiDocument(sourceText: string, input: string | URL): OpenApiDocument;
|
package/dist/spec-source.js
CHANGED
|
@@ -4,6 +4,18 @@ import { parse as parseYaml } from "yaml";
|
|
|
4
4
|
import { UserError } from "toolcraft";
|
|
5
5
|
import { renderSourceSnippet } from "toolcraft/source-snippet";
|
|
6
6
|
import { classifyNetworkError } from "./network-error.js";
|
|
7
|
+
import { redactSensitiveQueryValues } from "./redaction.js";
|
|
8
|
+
export class OpenApiHttpStatusError extends UserError {
|
|
9
|
+
}
|
|
10
|
+
export class OpenApiTransportError extends UserError {
|
|
11
|
+
}
|
|
12
|
+
export class OpenApiTimeoutError extends OpenApiTransportError {
|
|
13
|
+
timeoutMs;
|
|
14
|
+
constructor(message, timeoutMs, options) {
|
|
15
|
+
super(message, options);
|
|
16
|
+
this.timeoutMs = timeoutMs;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
7
19
|
export async function readOpenApiSourceText(input, services) {
|
|
8
20
|
const inputUrl = input instanceof URL ? input : tryParseUrl(input);
|
|
9
21
|
const sourceLabel = formatSourceLabel(input);
|
|
@@ -20,38 +32,132 @@ export async function readOpenApiSourceText(input, services) {
|
|
|
20
32
|
if (inputUrl.protocol !== "http:" && inputUrl.protocol !== "https:") {
|
|
21
33
|
throw new UserError(`Unsupported OpenAPI input URL protocol ${JSON.stringify(inputUrl.protocol)}.`);
|
|
22
34
|
}
|
|
35
|
+
const result = await fetchOpenApiHttpSource(inputUrl, services.fetch);
|
|
36
|
+
if (result.status === "not-modified") {
|
|
37
|
+
throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
|
|
38
|
+
}
|
|
39
|
+
return result.sourceText;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error instanceof UserError) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
throw new UserError(`Failed to read OpenAPI document ${JSON.stringify(sourceLabel)}: ${getErrorMessage(error)}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export async function fetchOpenApiHttpSource(inputUrl, fetch, options = {}) {
|
|
49
|
+
validateTimeout(options.timeoutMs);
|
|
50
|
+
const url = inputUrl.toString();
|
|
51
|
+
const timeoutMs = options.timeoutMs;
|
|
52
|
+
const controller = timeoutMs === undefined || timeoutMs === 0 ? undefined : new AbortController();
|
|
53
|
+
const requestInit = createOpenApiRequestInit(options.etag, controller?.signal);
|
|
54
|
+
const request = async () => {
|
|
23
55
|
let response;
|
|
24
56
|
try {
|
|
25
|
-
response = await
|
|
57
|
+
response = requestInit === undefined ? await fetch(url) : await fetch(url, requestInit);
|
|
26
58
|
}
|
|
27
59
|
catch (error) {
|
|
28
|
-
throw
|
|
60
|
+
throw toTransportError(error, url) ?? error;
|
|
61
|
+
}
|
|
62
|
+
if (response.status === 304) {
|
|
63
|
+
return {
|
|
64
|
+
status: "not-modified",
|
|
65
|
+
...readResponseCacheHeaders(response)
|
|
66
|
+
};
|
|
29
67
|
}
|
|
30
68
|
if (!response.ok) {
|
|
31
69
|
const contentType = response.headers.get("content-type") ?? "";
|
|
32
70
|
const text = await response.text().catch(() => "");
|
|
33
71
|
const snippet = text.length === 0 ? "" : `\n body: ${truncate(text, 500)}`;
|
|
34
|
-
throw new
|
|
72
|
+
throw new OpenApiHttpStatusError(`Failed to fetch ${JSON.stringify(url)}: ` +
|
|
35
73
|
`${response.status} ${response.statusText}` +
|
|
36
74
|
(contentType ? ` (content-type: ${contentType})` : "") +
|
|
37
75
|
snippet);
|
|
38
76
|
}
|
|
39
|
-
|
|
77
|
+
try {
|
|
78
|
+
return {
|
|
79
|
+
status: "modified",
|
|
80
|
+
sourceText: await response.text(),
|
|
81
|
+
...readResponseCacheHeaders(response)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
throw (toTransportError(error, url) ??
|
|
86
|
+
new OpenApiTransportError(`Failed to read the OpenAPI response body from ${JSON.stringify(redactSensitiveQueryValues(url))}.`, { cause: error }));
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (controller === undefined || timeoutMs === undefined) {
|
|
90
|
+
return await request();
|
|
40
91
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
92
|
+
const timeoutCause = Object.assign(new Error("OpenAPI request timed out"), {
|
|
93
|
+
code: "ETIMEDOUT",
|
|
94
|
+
timeout: timeoutMs
|
|
95
|
+
});
|
|
96
|
+
const classified = classifyNetworkError(timeoutCause, url);
|
|
97
|
+
const timeoutError = new OpenApiTimeoutError(classified?.message ?? `OpenAPI request timed out after ${timeoutMs}ms.`, timeoutMs, { cause: timeoutCause });
|
|
98
|
+
let timeout;
|
|
99
|
+
try {
|
|
100
|
+
return await Promise.race([
|
|
101
|
+
request(),
|
|
102
|
+
new Promise((_resolve, reject) => {
|
|
103
|
+
timeout = setTimeout(() => {
|
|
104
|
+
reject(timeoutError);
|
|
105
|
+
controller.abort(timeoutError);
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
})
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
if (timeout !== undefined) {
|
|
112
|
+
clearTimeout(timeout);
|
|
44
113
|
}
|
|
45
|
-
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function toTransportError(error, url) {
|
|
117
|
+
if (error instanceof OpenApiTransportError) {
|
|
118
|
+
return error;
|
|
119
|
+
}
|
|
120
|
+
const classified = classifyNetworkError(error, url);
|
|
121
|
+
return classified === null
|
|
122
|
+
? null
|
|
123
|
+
: new OpenApiTransportError(classified.message, { cause: error });
|
|
124
|
+
}
|
|
125
|
+
function createOpenApiRequestInit(etag, signal) {
|
|
126
|
+
if (etag === undefined && signal === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...(etag === undefined ? {} : { headers: { "If-None-Match": etag } }),
|
|
131
|
+
...(signal === undefined ? {} : { signal })
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function readResponseCacheHeaders(response) {
|
|
135
|
+
const etag = response.headers.get("etag") ?? undefined;
|
|
136
|
+
const cacheControl = response.headers.get("cache-control") ?? undefined;
|
|
137
|
+
const age = response.headers.get("age") ?? undefined;
|
|
138
|
+
return {
|
|
139
|
+
...(etag === undefined ? {} : { etag }),
|
|
140
|
+
...(cacheControl === undefined ? {} : { cacheControl }),
|
|
141
|
+
...(age === undefined ? {} : { age })
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function validateTimeout(timeoutMs) {
|
|
145
|
+
if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
|
|
146
|
+
throw new UserError("OpenAPI fetch timeout must be a finite non-negative number.");
|
|
46
147
|
}
|
|
47
148
|
}
|
|
48
149
|
export function parseOpenApiDocument(sourceText, input) {
|
|
49
150
|
let parsed;
|
|
50
151
|
try {
|
|
51
|
-
parsed =
|
|
152
|
+
parsed = JSON.parse(sourceText);
|
|
52
153
|
}
|
|
53
|
-
catch
|
|
54
|
-
|
|
154
|
+
catch {
|
|
155
|
+
try {
|
|
156
|
+
parsed = parseYaml(sourceText);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
throw new UserError(`Failed to parse OpenAPI document ${JSON.stringify(formatSourceLabel(input))}: ${formatParseErrorMessage(error, sourceText, formatSourceLabel(input))}`);
|
|
160
|
+
}
|
|
55
161
|
}
|
|
56
162
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
57
163
|
throw new UserError(`OpenAPI document ${JSON.stringify(formatSourceLabel(input))} must parse to an object.`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolcraft-openapi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.140",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"toolcraft-openapi-generate": "dist/bin/generate.js"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"toolcraft": "0.0.
|
|
33
|
+
"toolcraft": "0.0.140",
|
|
34
34
|
"auth-store": "^0.0.1",
|
|
35
35
|
"fast-string-width": "^3.0.2",
|
|
36
36
|
"fast-wrap-ansi": "^0.2.0",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"directory": "packages/toolcraft-openapi"
|
|
47
47
|
},
|
|
48
48
|
"optionalDependencies": {
|
|
49
|
-
"toolcraft-schema": "0.0.
|
|
49
|
+
"toolcraft-schema": "0.0.140",
|
|
50
50
|
"toolcraft-design": "*",
|
|
51
51
|
"@poe-code/frontmatter": "*"
|
|
52
52
|
},
|