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 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
 
@@ -38,12 +38,12 @@
38
38
  },
39
39
  {
40
40
  "name": "toolcraft-openapi",
41
- "version": "0.0.138",
41
+ "version": "0.0.140",
42
42
  "license": "MIT"
43
43
  },
44
44
  {
45
45
  "name": "toolcraft-schema",
46
- "version": "0.0.138",
46
+ "version": "0.0.140",
47
47
  "license": "MIT"
48
48
  },
49
49
  {
@@ -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
- for (const [statusCode, response] of Object.entries(operation.responses ?? {})) {
620
- if (!isSuccessStatusCode(statusCode)) {
621
- continue;
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 === "default" || statusCode === "2XX") {
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, lines) {
157
- options.diagnostics?.emit({
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(lines)
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";
@@ -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
- ...source,
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
- : { parameters: pathItem.parameters.map((parameter) => normalizeParameter(resolveReusableParameter(parameter, reusableParameters))).filter(isOpenApiParameter) })
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
- { schema: rewriteReferences(responseRecord.schema) }
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.filter((parameter) => parameter.required === true).map((parameter) => parameter.name);
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 ? {} : { description: parameter.description })
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" ? rewriteReference(entry) : rewriteReferences(entry)
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) ? value.filter((entry) => typeof entry === "string") : [];
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 { type OpenApiSourceFileSystem } from "./spec-source.js";
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?: OpenApiSourceFileSystem;
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 document = await resolveDocument(source, options);
12
- return createRuntimeGroups(collectGeneratedCommands(document));
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 commands = (await commandsFromSpec(spec, {
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
- return defineClient({ ...clientOptions, commands });
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 createRuntimeGroups(commands) {
53
- return groupByNoun(commands).map(({ noun, commands: nounCommands }) => defineGroup({
54
- name: noun,
55
- children: nounCommands.map((command) => createRuntimeCommand(command))
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
+ }
@@ -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;
@@ -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 services.fetch(inputUrl.toString());
57
+ response = requestInit === undefined ? await fetch(url) : await fetch(url, requestInit);
26
58
  }
27
59
  catch (error) {
28
- throw classifyNetworkError(error, inputUrl.toString()) ?? error;
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 UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: ` +
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
- return await response.text();
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
- catch (error) {
42
- if (error instanceof UserError) {
43
- throw error;
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
- throw new UserError(`Failed to read OpenAPI document ${JSON.stringify(sourceLabel)}: ${getErrorMessage(error)}`);
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 = parseYaml(sourceText);
152
+ parsed = JSON.parse(sourceText);
52
153
  }
53
- catch (error) {
54
- throw new UserError(`Failed to parse OpenAPI document ${JSON.stringify(formatSourceLabel(input))}: ${formatParseErrorMessage(error, sourceText, formatSourceLabel(input))}`);
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.`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-schema",
3
- "version": "0.0.138",
3
+ "version": "0.0.140",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-openapi",
3
- "version": "0.0.138",
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.138",
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.138",
49
+ "toolcraft-schema": "0.0.140",
50
50
  "toolcraft-design": "*",
51
51
  "@poe-code/frontmatter": "*"
52
52
  },