toolcraft-openapi 0.0.39 → 0.0.40

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.
Files changed (62) hide show
  1. package/README.md +67 -0
  2. package/dist/bin/generate.d.ts +9 -0
  3. package/dist/bin/generate.js +122 -6
  4. package/dist/config.d.ts +53 -0
  5. package/dist/config.js +266 -0
  6. package/dist/define-client.js +4 -0
  7. package/dist/diagnose.d.ts +4 -0
  8. package/dist/diagnose.js +93 -0
  9. package/dist/diagnostics.d.ts +19 -0
  10. package/dist/diagnostics.js +19 -0
  11. package/dist/generate.d.ts +13 -1
  12. package/dist/generate.js +107 -4
  13. package/dist/http.d.ts +17 -26
  14. package/dist/http.js +99 -41
  15. package/dist/index.d.ts +6 -1
  16. package/dist/index.js +4 -1
  17. package/dist/runtime.d.ts +7 -0
  18. package/dist/runtime.js +19 -1
  19. package/node_modules/@poe-code/frontmatter/README.md +35 -0
  20. package/node_modules/@poe-code/frontmatter/dist/fences.d.ts +8 -0
  21. package/node_modules/@poe-code/frontmatter/dist/fences.js +72 -0
  22. package/node_modules/@poe-code/frontmatter/dist/index.d.ts +3 -0
  23. package/node_modules/@poe-code/frontmatter/dist/index.js +3 -0
  24. package/node_modules/@poe-code/frontmatter/dist/parse.d.ts +20 -0
  25. package/node_modules/@poe-code/frontmatter/dist/parse.js +137 -0
  26. package/node_modules/@poe-code/frontmatter/dist/stringify.d.ts +1 -0
  27. package/node_modules/@poe-code/frontmatter/dist/stringify.js +35 -0
  28. package/node_modules/@poe-code/frontmatter/package.json +25 -0
  29. package/node_modules/toolcraft-design/README.md +12 -0
  30. package/node_modules/toolcraft-design/dist/prompts/index.d.ts +39 -14
  31. package/node_modules/toolcraft-design/dist/prompts/index.js +10 -6
  32. package/node_modules/toolcraft-design/dist/prompts/interactive/cancel-symbol.d.ts +2 -0
  33. package/node_modules/toolcraft-design/dist/prompts/interactive/cancel-symbol.js +4 -0
  34. package/node_modules/toolcraft-design/dist/prompts/interactive/confirm.d.ts +9 -0
  35. package/node_modules/toolcraft-design/dist/prompts/interactive/confirm.js +47 -0
  36. package/node_modules/toolcraft-design/dist/prompts/interactive/core.d.ts +55 -0
  37. package/node_modules/toolcraft-design/dist/prompts/interactive/core.js +274 -0
  38. package/node_modules/toolcraft-design/dist/prompts/interactive/glyphs.d.ts +20 -0
  39. package/node_modules/toolcraft-design/dist/prompts/interactive/glyphs.js +53 -0
  40. package/node_modules/toolcraft-design/dist/prompts/interactive/index.d.ts +6 -0
  41. package/node_modules/toolcraft-design/dist/prompts/interactive/index.js +6 -0
  42. package/node_modules/toolcraft-design/dist/prompts/interactive/keys.d.ts +2 -0
  43. package/node_modules/toolcraft-design/dist/prompts/interactive/keys.js +28 -0
  44. package/node_modules/toolcraft-design/dist/prompts/interactive/multiselect.d.ts +13 -0
  45. package/node_modules/toolcraft-design/dist/prompts/interactive/multiselect.js +131 -0
  46. package/node_modules/toolcraft-design/dist/prompts/interactive/pagination.d.ts +10 -0
  47. package/node_modules/toolcraft-design/dist/prompts/interactive/pagination.js +52 -0
  48. package/node_modules/toolcraft-design/dist/prompts/interactive/password.d.ts +10 -0
  49. package/node_modules/toolcraft-design/dist/prompts/interactive/password.js +55 -0
  50. package/node_modules/toolcraft-design/dist/prompts/interactive/select.d.ts +18 -0
  51. package/node_modules/toolcraft-design/dist/prompts/interactive/select.js +89 -0
  52. package/node_modules/toolcraft-design/dist/prompts/interactive/test-helpers.d.ts +21 -0
  53. package/node_modules/toolcraft-design/dist/prompts/interactive/test-helpers.js +32 -0
  54. package/node_modules/toolcraft-design/dist/prompts/interactive/text.d.ts +12 -0
  55. package/node_modules/toolcraft-design/dist/prompts/interactive/text.js +60 -0
  56. package/node_modules/toolcraft-design/dist/prompts/interactive/wrap.d.ts +4 -0
  57. package/node_modules/toolcraft-design/dist/prompts/interactive/wrap.js +18 -0
  58. package/node_modules/toolcraft-design/dist/prompts/primitives/cancel.d.ts +1 -1
  59. package/node_modules/toolcraft-design/dist/prompts/primitives/cancel.js +1 -1
  60. package/node_modules/toolcraft-design/dist/terminal-markdown/parser/frontmatter.js +20 -453
  61. package/node_modules/toolcraft-design/package.json +6 -3
  62. package/package.json +7 -7
@@ -0,0 +1,93 @@
1
+ import { DIAGNOSTIC_CODES, createDiagnostic } from "./diagnostics.js";
2
+ export function diagnose(config, document) {
3
+ const configuredMethods = collectConfiguredMethods(config.resources);
4
+ return [
5
+ ...diagnoseDuplicateMethods(configuredMethods),
6
+ ...diagnoseUnknownPagination(config, configuredMethods),
7
+ ...diagnoseUnmappedEndpoints(config, document, configuredMethods)
8
+ ];
9
+ }
10
+ function collectConfiguredMethods(resources) {
11
+ const methods = [];
12
+ function visit(resource, path) {
13
+ for (const [name, method] of Object.entries(resource.methods ?? {})) {
14
+ methods.push({
15
+ configPath: [...path, "methods", name].join("."),
16
+ method
17
+ });
18
+ }
19
+ for (const [name, child] of Object.entries(resource.subresources ?? {})) {
20
+ visit(child, [...path, "subresources", name]);
21
+ }
22
+ }
23
+ for (const [name, resource] of Object.entries(resources ?? {})) {
24
+ visit(resource, ["resources", name]);
25
+ }
26
+ return methods;
27
+ }
28
+ function diagnoseDuplicateMethods(methods) {
29
+ const seen = new Map();
30
+ const diagnostics = [];
31
+ for (const method of methods) {
32
+ const key = endpointKey(method.method.method, method.method.path);
33
+ const existing = seen.get(key);
34
+ if (existing === undefined) {
35
+ seen.set(key, method);
36
+ continue;
37
+ }
38
+ diagnostics.push(createDiagnostic({
39
+ code: DIAGNOSTIC_CODES.duplicateMethodPath,
40
+ severity: "error",
41
+ location: method.configPath,
42
+ message: `${method.configPath} and ${existing.configPath} both bind ${key}.`
43
+ }));
44
+ }
45
+ return diagnostics;
46
+ }
47
+ function diagnoseUnknownPagination(config, methods) {
48
+ const schemes = new Set(Object.keys(config.pagination ?? {}));
49
+ return methods
50
+ .filter((method) => method.method.pagination !== undefined && !schemes.has(method.method.pagination))
51
+ .map((method) => createDiagnostic({
52
+ code: DIAGNOSTIC_CODES.unknownPaginationScheme,
53
+ severity: "error",
54
+ location: method.configPath,
55
+ message: `${method.configPath} references undeclared pagination scheme ${JSON.stringify(method.method.pagination)}.`
56
+ }));
57
+ }
58
+ function diagnoseUnmappedEndpoints(config, document, methods) {
59
+ if (config.resources === undefined) {
60
+ return [];
61
+ }
62
+ const configured = new Set(methods.map((method) => endpointKey(method.method.method, method.method.path)));
63
+ const unspecified = new Set((config.unspecified_endpoints ?? []).map(normalizeEndpointText));
64
+ const diagnostics = [];
65
+ for (const [path, pathItem] of Object.entries(document.paths ?? {})) {
66
+ if (pathItem === undefined) {
67
+ continue;
68
+ }
69
+ for (const method of ["get", "post", "put", "patch", "delete", "head", "options"]) {
70
+ if (!Object.hasOwn(pathItem, method)) {
71
+ continue;
72
+ }
73
+ const key = endpointKey(method, path);
74
+ if (configured.has(key) || unspecified.has(key)) {
75
+ continue;
76
+ }
77
+ diagnostics.push(createDiagnostic({
78
+ code: DIAGNOSTIC_CODES.unmappedEndpoint,
79
+ severity: "error",
80
+ location: `paths.${path}.${method}`,
81
+ message: `${method.toUpperCase()} ${path} is not listed in resources or unspecified_endpoints.`
82
+ }));
83
+ }
84
+ }
85
+ return diagnostics;
86
+ }
87
+ function endpointKey(method, path) {
88
+ return `${method.toLowerCase()} ${path}`;
89
+ }
90
+ function normalizeEndpointText(value) {
91
+ const [method, requestPath] = value.trim().split(" ");
92
+ return `${(method ?? "").toLowerCase()} ${requestPath ?? ""}`;
93
+ }
@@ -0,0 +1,19 @@
1
+ export type DiagnosticSeverity = "error" | "warn";
2
+ export interface Diagnostic {
3
+ code: string;
4
+ severity: DiagnosticSeverity;
5
+ message: string;
6
+ location?: string;
7
+ }
8
+ export declare const DIAGNOSTIC_CODES: {
9
+ readonly unmappedEndpoint: "TOOLCRAFT_OPENAPI_001";
10
+ readonly duplicateMethodPath: "TOOLCRAFT_OPENAPI_002";
11
+ readonly unknownPaginationScheme: "TOOLCRAFT_OPENAPI_003";
12
+ readonly specDrift: "TOOLCRAFT_OPENAPI_004";
13
+ readonly reservedMethodName: "TOOLCRAFT_OPENAPI_005";
14
+ readonly invalidEdition: "TOOLCRAFT_OPENAPI_006";
15
+ readonly invalidConfig: "TOOLCRAFT_OPENAPI_007";
16
+ };
17
+ export declare function createDiagnostic(args: Diagnostic): Diagnostic;
18
+ export declare function formatDiagnostic(diagnostic: Diagnostic): string;
19
+ export declare function formatDiagnostics(diagnostics: readonly Diagnostic[]): string;
@@ -0,0 +1,19 @@
1
+ export const DIAGNOSTIC_CODES = {
2
+ unmappedEndpoint: "TOOLCRAFT_OPENAPI_001",
3
+ duplicateMethodPath: "TOOLCRAFT_OPENAPI_002",
4
+ unknownPaginationScheme: "TOOLCRAFT_OPENAPI_003",
5
+ specDrift: "TOOLCRAFT_OPENAPI_004",
6
+ reservedMethodName: "TOOLCRAFT_OPENAPI_005",
7
+ invalidEdition: "TOOLCRAFT_OPENAPI_006",
8
+ invalidConfig: "TOOLCRAFT_OPENAPI_007"
9
+ };
10
+ export function createDiagnostic(args) {
11
+ return args;
12
+ }
13
+ export function formatDiagnostic(diagnostic) {
14
+ const location = diagnostic.location === undefined ? "" : ` ${diagnostic.location}`;
15
+ return `${diagnostic.code} ${diagnostic.severity}${location}: ${diagnostic.message}`;
16
+ }
17
+ export function formatDiagnostics(diagnostics) {
18
+ return diagnostics.map((diagnostic) => `${formatDiagnostic(diagnostic)}\n`).join("");
19
+ }
@@ -1,4 +1,5 @@
1
1
  import { type HttpMethod } from "./naming.js";
2
+ import type { ToolcraftConfig } from "./config.js";
2
3
  type OpenApiOperation = OpenApiOperationObject | OpenApiReferenceObject;
3
4
  type OpenApiOperationMap = Partial<Record<HttpMethod, OpenApiOperation>>;
4
5
  type OpenApiParameterLocation = "path" | "query" | "header" | "cookie";
@@ -12,6 +13,7 @@ export interface OpenApiDocument {
12
13
  title?: string;
13
14
  version?: string;
14
15
  };
16
+ servers?: OpenApiServerObject[];
15
17
  security?: OpenApiSecurityRequirementObject[];
16
18
  paths?: Record<string, OpenApiPathItemObject | undefined>;
17
19
  components?: {
@@ -98,6 +100,7 @@ type OpenApiSecurityRequirementObject = Record<string, string[]>;
98
100
  export interface GenerateOptions {
99
101
  specSha: string;
100
102
  brand?: string;
103
+ config?: ToolcraftConfig;
101
104
  }
102
105
  export interface GeneratedFile {
103
106
  path: string;
@@ -110,6 +113,7 @@ export interface GeneratedCommand {
110
113
  filePath: string;
111
114
  operationId: string;
112
115
  description?: string;
116
+ examples?: GeneratedCommandExample[];
113
117
  method: string;
114
118
  path: string;
115
119
  auth: "required" | "none";
@@ -119,6 +123,8 @@ export interface GeneratedCommand {
119
123
  bodyMode?: "json" | "form" | "raw" | "base64" | "multipart";
120
124
  contentType?: string;
121
125
  multipartBinaryFields?: readonly string[];
126
+ idempotencyHeader?: string;
127
+ rawResponse?: boolean;
122
128
  confirm: boolean;
123
129
  params: GeneratedParam[];
124
130
  paramsSchemaOptions?: GeneratedObjectSchemaOptions;
@@ -132,12 +138,17 @@ export interface GeneratedParam {
132
138
  sourceName: string;
133
139
  location: "path" | "query" | "header" | "body" | "transport";
134
140
  description?: string;
141
+ longAliases?: string[];
135
142
  shortFlag?: string;
136
143
  scope?: readonly [GeneratedParamScope, ...GeneratedParamScope[]];
137
144
  global?: boolean;
138
145
  optional: boolean;
139
146
  definition: GeneratedParamDefinition;
140
147
  }
148
+ interface GeneratedCommandExample {
149
+ title: string;
150
+ params: Record<string, unknown>;
151
+ }
141
152
  interface GeneratedParamDefinitionMetadata {
142
153
  defaultValue?: unknown;
143
154
  format?: string;
@@ -202,6 +213,7 @@ export interface GeneratedObjectSchemaOptions {
202
213
  interface RenderSchemaOptionsInput {
203
214
  definition: GeneratedParamDefinition;
204
215
  description?: string;
216
+ longAliases?: string[];
205
217
  shortFlag?: string;
206
218
  scope?: readonly [GeneratedParamScope, ...GeneratedParamScope[]];
207
219
  global?: boolean;
@@ -226,7 +238,7 @@ interface SchemaOptionEntry {
226
238
  }
227
239
  type QueryArraySerialization = "repeat" | "brackets" | "comma" | "pipe";
228
240
  export declare function generate(document: OpenApiDocument, options: GenerateOptions): GeneratedFile[];
229
- export declare function collectGeneratedCommands(document: OpenApiDocument): GeneratedCommand[];
241
+ export declare function collectGeneratedCommands(document: OpenApiDocument, config?: ToolcraftConfig): GeneratedCommand[];
230
242
  export declare function collectGeneratedCommand(document: OpenApiDocument, path: string, method: HttpMethod): GeneratedCommand;
231
243
  export declare function collectSchemaOptionEntries(param: RenderSchemaOptionsInput): SchemaOptionEntry[];
232
244
  export {};
package/dist/generate.js CHANGED
@@ -65,6 +65,10 @@ const SCHEMA_OPTION_SOURCES = [
65
65
  key: "short",
66
66
  get: (param) => param.shortFlag
67
67
  },
68
+ {
69
+ key: "cliAliases",
70
+ get: (param) => param.longAliases
71
+ },
68
72
  {
69
73
  key: "scope",
70
74
  get: (param) => param.scope
@@ -120,7 +124,7 @@ const SCHEMA_OPTION_SOURCES = [
120
124
  ];
121
125
  export function generate(document, options) {
122
126
  const normalizedDocument = normalizeOpenApiDocument(document);
123
- const commands = collectGeneratedCommands(normalizedDocument);
127
+ const commands = collectGeneratedCommands(normalizedDocument, options.config);
124
128
  const brand = options.brand ?? "blue";
125
129
  const label = normalizedDocument.info?.title ?? "Toolcraft";
126
130
  return [
@@ -135,17 +139,102 @@ export function generate(document, options) {
135
139
  createCliFile({ brand, label })
136
140
  ];
137
141
  }
138
- export function collectGeneratedCommands(document) {
142
+ export function collectGeneratedCommands(document, config) {
139
143
  const normalizedDocument = normalizeOpenApiDocument(document);
140
144
  const paths = normalizedDocument.paths;
141
145
  if (paths === undefined) {
142
146
  throw new UserError('OpenAPI document must define a top-level "paths" object.');
143
147
  }
144
148
  const commands = collectOperations(paths).map((entry) => createGeneratedCommand(normalizedDocument, entry));
149
+ applyConfiguredCommandShape(commands, config);
145
150
  disambiguateCommandPaths(commands);
146
151
  assertUniqueCommandPaths(commands);
147
152
  return commands.slice().sort((left, right) => compareGeneratedCommandPaths(left, right));
148
153
  }
154
+ function applyConfiguredCommandShape(commands, config) {
155
+ const configured = collectConfiguredCommandShapes(config?.resources);
156
+ if (configured.length === 0) {
157
+ return;
158
+ }
159
+ for (const command of commands) {
160
+ const match = configured.find((candidate) => candidate.method.method.toUpperCase() === command.method && candidate.method.path === command.path);
161
+ if (match === undefined) {
162
+ continue;
163
+ }
164
+ command.noun = createSafeGeneratedNoun(match.noun);
165
+ command.verb = createSafeGeneratedVerb(match.verb);
166
+ command.examples = config?.readme?.examples?.[match.exampleKey];
167
+ applyConfiguredRawResponse(command);
168
+ applyConfiguredIdempotency(command, match.method, config);
169
+ refreshGeneratedCommandNames(command);
170
+ }
171
+ }
172
+ function applyConfiguredRawResponse(command) {
173
+ command.rawResponse = true;
174
+ if (command.params.some((param) => param.paramName === "rawResponse")) {
175
+ return;
176
+ }
177
+ command.params.push({
178
+ paramName: "rawResponse",
179
+ sourceName: "rawResponse",
180
+ location: "transport",
181
+ description: "Return parsed data with the raw HTTP Response.",
182
+ longAliases: ["raw"],
183
+ scope: ["cli", "sdk"],
184
+ optional: true,
185
+ definition: { kind: "boolean" }
186
+ });
187
+ }
188
+ function applyConfiguredIdempotency(command, method, config) {
189
+ const header = config?.client_settings?.idempotency_header;
190
+ if (method.idempotent !== true || header === undefined || command.method === "GET") {
191
+ return;
192
+ }
193
+ command.idempotencyHeader = header;
194
+ if (command.params.some((param) => param.paramName === "idempotencyKey")) {
195
+ return;
196
+ }
197
+ command.params.push({
198
+ paramName: "idempotencyKey",
199
+ sourceName: "idempotencyKey",
200
+ location: "transport",
201
+ description: "Set an idempotency key to retry this request safely.",
202
+ scope: ["cli", "mcp", "sdk"],
203
+ optional: true,
204
+ definition: { kind: "string" }
205
+ });
206
+ }
207
+ function collectConfiguredCommandShapes(resources) {
208
+ const shapes = [];
209
+ function visit(resourceName, resource, resourcePath) {
210
+ for (const [methodName, method] of Object.entries(resource.methods ?? {})) {
211
+ shapes.push({
212
+ exampleKey: [...resourcePath, methodName].join("."),
213
+ noun: resourceName,
214
+ verb: methodName,
215
+ method
216
+ });
217
+ }
218
+ for (const [childName, childResource] of Object.entries(resource.subresources ?? {})) {
219
+ visit(childName, childResource, [...resourcePath, childName]);
220
+ }
221
+ }
222
+ for (const [resourceName, resource] of Object.entries(resources ?? {})) {
223
+ visit(resourceName, resource, [resourceName]);
224
+ }
225
+ return shapes;
226
+ }
227
+ function createSafeGeneratedVerb(value) {
228
+ const verb = toCamelCase(value);
229
+ if (!isIdentifierName(verb)) {
230
+ throw new UserError(`Configured method ${JSON.stringify(value)} must map to a valid command name.`);
231
+ }
232
+ return verb;
233
+ }
234
+ function refreshGeneratedCommandNames(command) {
235
+ command.exportName = `${toCamelCase(command.noun)}${toPascalCase(command.verb)}Command`;
236
+ command.filePath = `${command.noun}/${command.verb}.ts`;
237
+ }
149
238
  export function collectGeneratedCommand(document, path, method) {
150
239
  const normalizedDocument = normalizeOpenApiDocument(document);
151
240
  const pathItem = normalizedDocument.paths?.[path];
@@ -1315,6 +1404,9 @@ function createCommandFile(options) {
1315
1404
  if (options.description !== undefined) {
1316
1405
  lines.push(` description: ${JSON.stringify(options.description)},`);
1317
1406
  }
1407
+ if (options.examples !== undefined && options.examples.length > 0) {
1408
+ lines.push(` examples: ${JSON.stringify(options.examples)},`);
1409
+ }
1318
1410
  lines.push(' scope: ["cli", "mcp", "sdk"] as const,');
1319
1411
  if (options.confirm) {
1320
1412
  lines.push(" confirm: true,");
@@ -1382,6 +1474,16 @@ function createCommandFile(options) {
1382
1474
  lines.push(" fetch,");
1383
1475
  lines.push(" dryRun: params.dryRun,");
1384
1476
  lines.push(" verbose: params.verbose,");
1477
+ if (options.rawResponse === true) {
1478
+ lines.push(" rawResponse: params.rawResponse,");
1479
+ }
1480
+ if (options.idempotencyHeader !== undefined) {
1481
+ lines.push(" idempotency: {");
1482
+ lines.push(` header: ${JSON.stringify(options.idempotencyHeader)},`);
1483
+ lines.push(" enabled: true,");
1484
+ lines.push(" key: params.idempotencyKey,");
1485
+ lines.push(" },");
1486
+ }
1385
1487
  if (usesRequestShapeVariable) {
1386
1488
  lines.push(" ...preparedRequestShape,");
1387
1489
  }
@@ -1455,14 +1557,15 @@ function renderParamLines(params) {
1455
1557
  return params.map((param) => ` ${renderObjectKey(param.paramName)}: ${renderParamSchema(param)},`);
1456
1558
  }
1457
1559
  function renderParamSchema(param) {
1458
- const schema = renderDefinition(param.definition, param.description, param.shortFlag, param.scope, param.global);
1560
+ const schema = renderDefinition(param.definition, param.description, param.shortFlag, param.longAliases, param.scope, param.global);
1459
1561
  return param.optional ? `S.Optional(${schema})` : schema;
1460
1562
  }
1461
- function renderDefinition(definition, description, shortFlag, scope, global) {
1563
+ function renderDefinition(definition, description, shortFlag, longAliases, scope, global) {
1462
1564
  const options = renderSchemaOptions({
1463
1565
  definition,
1464
1566
  description,
1465
1567
  shortFlag,
1568
+ longAliases,
1466
1569
  scope,
1467
1570
  global
1468
1571
  });
package/dist/http.d.ts CHANGED
@@ -1,5 +1,7 @@
1
- import { type HandlerEnv, type HandlerFs } from "toolcraft";
1
+ import { HttpError, type HandlerEnv, type HandlerFs, type HttpErrorRequest, type HttpErrorResponse } from "toolcraft";
2
2
  import type { TokenSource } from "./auth/types.js";
3
+ export { HttpError };
4
+ export type { HttpErrorRequest, HttpErrorResponse };
3
5
  type QueryScalar = string | number | boolean | null | undefined;
4
6
  type QueryObject = {
5
7
  readonly [key: string]: QueryValue;
@@ -25,21 +27,23 @@ export interface HttpRequestOptions {
25
27
  dryRun?: boolean;
26
28
  verbose?: boolean;
27
29
  signal?: AbortSignal;
30
+ rawResponse?: boolean;
31
+ retries?: {
32
+ max: number;
33
+ backoff: "exponential";
34
+ retryOn: number[];
35
+ sleep?: (ms: number) => Promise<void>;
36
+ random?: () => number;
37
+ };
38
+ idempotency?: {
39
+ header: string;
40
+ enabled: boolean;
41
+ key?: string;
42
+ createKey?: () => string;
43
+ };
28
44
  writeStdout?: (chunk: string) => void;
29
45
  writeStderr?: (chunk: string) => void;
30
46
  }
31
- export interface HttpErrorRequest {
32
- method: string;
33
- url: string;
34
- headers: Record<string, string>;
35
- body?: unknown;
36
- }
37
- export interface HttpErrorResponse {
38
- status: number;
39
- statusText: string;
40
- headers: Record<string, string>;
41
- body: unknown;
42
- }
43
47
  export interface BinaryHttpResponse {
44
48
  contentType: string;
45
49
  encoding: "base64";
@@ -49,18 +53,6 @@ export interface BinaryHttpResponse {
49
53
  type RuntimeFileSystem = Pick<HandlerFs, "exists" | "readFile" | "writeFile">;
50
54
  type RuntimeEnvironment = Pick<HandlerEnv, "get">;
51
55
  type RequestShape = Partial<Pick<HttpRequestOptions, "pathParams" | "query" | "headers" | "body">>;
52
- export declare class HttpError extends Error {
53
- readonly status: number;
54
- readonly statusText: string;
55
- readonly request: HttpErrorRequest;
56
- readonly response: HttpErrorResponse;
57
- get body(): unknown;
58
- constructor(args: {
59
- request: HttpErrorRequest;
60
- response: HttpErrorResponse;
61
- message?: string;
62
- });
63
- }
64
56
  export declare function requestJson<TResult = unknown>(options: HttpRequestOptions): Promise<TResult | undefined>;
65
57
  export declare function prepareMultipartFileInputs(requestShape: RequestShape, options: {
66
58
  bodyMode?: HttpRequestOptions["bodyMode"];
@@ -72,4 +64,3 @@ export declare function writeBinaryResponseOutput(result: unknown, outputPath: u
72
64
  fs?: RuntimeFileSystem;
73
65
  env?: RuntimeEnvironment;
74
66
  }): Promise<unknown>;
75
- export {};
package/dist/http.js CHANGED
@@ -1,27 +1,11 @@
1
1
  import path from "node:path";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { text as designText } from "toolcraft-design";
3
- import { UserError } from "toolcraft";
4
+ import { HttpError, UserError, createHttpError } from "toolcraft";
4
5
  import { classifyNetworkError } from "./network-error.js";
5
6
  import { redactHeaders, redactHeaderValue, redactSensitiveQueryValues } from "./redaction.js";
7
+ export { HttpError };
6
8
  const TRANSCRIPT_BODY_BYTE_LIMIT = 4 * 1024;
7
- export class HttpError extends Error {
8
- status;
9
- statusText;
10
- request;
11
- response;
12
- get body() {
13
- return this.response.body;
14
- }
15
- constructor(args) {
16
- super(args.message ??
17
- `${args.request.method} ${args.request.url} → ${args.response.status} ${args.response.statusText}`);
18
- this.name = "HttpError";
19
- this.status = args.response.status;
20
- this.statusText = args.response.statusText;
21
- this.request = args.request;
22
- this.response = args.response;
23
- }
24
- }
25
9
  export async function requestJson(options) {
26
10
  const token = options.auth === "none" ? undefined : await options.tokenSource.getToken();
27
11
  const method = options.method.toUpperCase();
@@ -38,7 +22,12 @@ export async function requestJson(options) {
38
22
  : JSON.stringify(options.body)
39
23
  : undefined;
40
24
  const url = buildRequestUrl(options);
41
- const headers = createHeaders(token, hasBody, options.headers, options.accept, options.bodyMode, options.contentType);
25
+ const idempotencyHeader = options.idempotency?.enabled === true && method !== "GET"
26
+ ? {
27
+ [options.idempotency.header]: options.idempotency.key ?? options.idempotency.createKey?.() ?? randomUUID()
28
+ }
29
+ : {};
30
+ const headers = createHeaders(token, hasBody, { ...options.headers, ...idempotencyHeader }, options.accept, options.bodyMode, options.contentType);
42
31
  const writeStdout = options.writeStdout ?? process.stdout.write.bind(process.stdout);
43
32
  const writeStderr = options.writeStderr ?? process.stderr.write.bind(process.stderr);
44
33
  const requestLine = `${method} ${redactSensitiveQueryValues(url)}`;
@@ -49,18 +38,12 @@ export async function requestJson(options) {
49
38
  if (options.verbose) {
50
39
  writeStderr(formatTranscriptLines(formatVerboseRequestTranscript(method, url, headers, options.body)));
51
40
  }
52
- let response;
53
- try {
54
- response = await (options.fetch ?? globalThis.fetch)(url, {
55
- method,
56
- headers,
57
- body: serializedBody,
58
- signal: options.signal
59
- });
60
- }
61
- catch (error) {
62
- throw classifyNetworkError(error, url) ?? error;
63
- }
41
+ const response = await fetchWithRetries(options, url, {
42
+ method,
43
+ headers,
44
+ body: serializedBody,
45
+ signal: options.signal
46
+ });
64
47
  const contentType = response.headers.get("content-type");
65
48
  const request = createHttpErrorRequest(method, url, headers, options.body);
66
49
  const responseHeaders = redactHeaders(serializeHeaders(response.headers));
@@ -70,7 +53,7 @@ export async function requestJson(options) {
70
53
  if (options.verbose) {
71
54
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders)));
72
55
  }
73
- return undefined;
56
+ return formatRawResponseResult(undefined, response, options.rawResponse);
74
57
  }
75
58
  const body = {
76
59
  contentType: contentType ?? options.accept ?? "application/octet-stream",
@@ -81,7 +64,7 @@ export async function requestJson(options) {
81
64
  if (options.verbose) {
82
65
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
83
66
  }
84
- return body;
67
+ return formatRawResponseResult(body, response, options.rawResponse);
85
68
  }
86
69
  const text = await response.text();
87
70
  if (response.ok) {
@@ -89,19 +72,19 @@ export async function requestJson(options) {
89
72
  if (options.verbose) {
90
73
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders)));
91
74
  }
92
- return undefined;
75
+ return formatRawResponseResult(undefined, response, options.rawResponse);
93
76
  }
94
77
  if (options.responseMode === "text") {
95
78
  if (options.verbose) {
96
79
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, text)));
97
80
  }
98
- return text;
81
+ return formatRawResponseResult(text, response, options.rawResponse);
99
82
  }
100
83
  if (!isJsonContentType(contentType)) {
101
84
  if (options.verbose) {
102
85
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, text)));
103
86
  }
104
- throw new HttpError({
87
+ throw createHttpError({
105
88
  request,
106
89
  response: {
107
90
  status: response.status,
@@ -120,7 +103,7 @@ export async function requestJson(options) {
120
103
  if (options.verbose) {
121
104
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, text)));
122
105
  }
123
- throw new HttpError({
106
+ throw createHttpError({
124
107
  request,
125
108
  response: {
126
109
  status: response.status,
@@ -134,7 +117,7 @@ export async function requestJson(options) {
134
117
  if (options.verbose) {
135
118
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
136
119
  }
137
- return body;
120
+ return formatRawResponseResult(body, response, options.rawResponse);
138
121
  }
139
122
  if (response.status === 401 && options.auth === "required") {
140
123
  await options.tokenSource.invalidate?.(token).catch(() => undefined);
@@ -143,16 +126,76 @@ export async function requestJson(options) {
143
126
  if (options.verbose) {
144
127
  writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
145
128
  }
146
- throw new HttpError({
129
+ throw createHttpError({
147
130
  request,
148
131
  response: {
149
132
  status: response.status,
150
133
  statusText: response.statusText,
151
134
  headers: responseHeaders,
152
135
  body
153
- }
136
+ },
137
+ code: extractErrorCode(body),
138
+ requestId: response.headers.get("x-request-id") ?? response.headers.get("x-requestid") ?? undefined
154
139
  });
155
140
  }
141
+ async function fetchWithRetries(options, url, init) {
142
+ const fetchImpl = options.fetch ?? globalThis.fetch;
143
+ const retries = options.retries;
144
+ const maxRetries = retries?.max ?? 0;
145
+ let attempt = 0;
146
+ while (true) {
147
+ try {
148
+ const response = await fetchImpl(url, init);
149
+ if (retries === undefined ||
150
+ attempt >= maxRetries ||
151
+ !shouldRetryStatus(response.status, retries.retryOn)) {
152
+ return response;
153
+ }
154
+ await sleepBeforeRetry(response, retries, attempt);
155
+ attempt += 1;
156
+ }
157
+ catch (error) {
158
+ const classified = classifyNetworkError(error, url) ?? error;
159
+ if (attempt >= maxRetries || retries === undefined) {
160
+ throw classified;
161
+ }
162
+ await sleepBeforeRetry(undefined, retries, attempt);
163
+ attempt += 1;
164
+ }
165
+ }
166
+ }
167
+ function shouldRetryStatus(status, retryOn) {
168
+ return retryOn?.includes(status) === true;
169
+ }
170
+ async function sleepBeforeRetry(response, retries, attempt) {
171
+ const retryAfter = response?.headers.get("retry-after");
172
+ const delay = parseRetryAfter(retryAfter) ?? calculateBackoffDelay(attempt, retries.random);
173
+ await (retries.sleep ?? defaultSleep)(delay);
174
+ }
175
+ function parseRetryAfter(value) {
176
+ if (value === null || value === undefined) {
177
+ return undefined;
178
+ }
179
+ const seconds = Number(value);
180
+ if (Number.isFinite(seconds) && seconds >= 0) {
181
+ return seconds * 1000;
182
+ }
183
+ const timestamp = Date.parse(value);
184
+ if (!Number.isNaN(timestamp)) {
185
+ return Math.max(0, timestamp - Date.now());
186
+ }
187
+ return undefined;
188
+ }
189
+ function calculateBackoffDelay(attempt, random) {
190
+ const base = 100 * 2 ** attempt;
191
+ return Math.floor(base * (random ?? Math.random)());
192
+ }
193
+ function defaultSleep(ms) {
194
+ return new Promise((resolve) => setTimeout(resolve, ms));
195
+ }
196
+ function formatRawResponseResult(data, response, rawResponse) {
197
+ return rawResponse === true ? { data, response } : data;
198
+ }
156
199
  export async function prepareMultipartFileInputs(requestShape, options) {
157
200
  if (options.bodyMode !== "multipart" ||
158
201
  options.multipartBinaryFields === undefined ||
@@ -442,6 +485,21 @@ function parseResponseBody(text, contentType) {
442
485
  }
443
486
  return JSON.parse(text);
444
487
  }
488
+ function extractErrorCode(body) {
489
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
490
+ return undefined;
491
+ }
492
+ const directCode = body.code;
493
+ if (typeof directCode === "string") {
494
+ return directCode;
495
+ }
496
+ const error = body.error;
497
+ if (error !== null && typeof error === "object" && !Array.isArray(error)) {
498
+ const nestedCode = error.code;
499
+ return typeof nestedCode === "string" ? nestedCode : undefined;
500
+ }
501
+ return undefined;
502
+ }
445
503
  function isJsonContentType(contentType) {
446
504
  if (contentType === null) {
447
505
  return false;