@optimizely-opal/opal-tool-ocp-sdk 1.0.0-OCP-1442.5 → 1.0.0-OCP-1449.1
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 +114 -72
- package/dist/auth/AuthUtils.d.ts +12 -5
- package/dist/auth/AuthUtils.d.ts.map +1 -1
- package/dist/auth/AuthUtils.js +80 -25
- package/dist/auth/AuthUtils.js.map +1 -1
- package/dist/auth/AuthUtils.test.js +161 -117
- package/dist/auth/AuthUtils.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +1 -1
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +17 -4
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +54 -8
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +1 -1
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +24 -4
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +260 -8
- package/dist/function/ToolFunction.test.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/logging/ToolLogger.d.ts.map +1 -1
- package/dist/logging/ToolLogger.js +13 -12
- package/dist/logging/ToolLogger.js.map +1 -1
- package/dist/logging/ToolLogger.test.js +171 -0
- package/dist/logging/ToolLogger.test.js.map +1 -1
- package/dist/service/Service.d.ts +88 -2
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +227 -55
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +464 -36
- package/dist/service/Service.test.js.map +1 -1
- package/dist/types/ToolError.d.ts +59 -0
- package/dist/types/ToolError.d.ts.map +1 -0
- package/dist/types/ToolError.js +79 -0
- package/dist/types/ToolError.js.map +1 -0
- package/dist/types/ToolError.test.d.ts +2 -0
- package/dist/types/ToolError.test.d.ts.map +1 -0
- package/dist/types/ToolError.test.js +161 -0
- package/dist/types/ToolError.test.js.map +1 -0
- package/dist/validation/ParameterValidator.d.ts +5 -16
- package/dist/validation/ParameterValidator.d.ts.map +1 -1
- package/dist/validation/ParameterValidator.js +10 -3
- package/dist/validation/ParameterValidator.js.map +1 -1
- package/dist/validation/ParameterValidator.test.js +186 -146
- package/dist/validation/ParameterValidator.test.js.map +1 -1
- package/package.json +1 -1
- package/src/auth/AuthUtils.test.ts +176 -157
- package/src/auth/AuthUtils.ts +96 -33
- package/src/function/GlobalToolFunction.test.ts +54 -8
- package/src/function/GlobalToolFunction.ts +26 -6
- package/src/function/ToolFunction.test.ts +274 -8
- package/src/function/ToolFunction.ts +33 -7
- package/src/index.ts +1 -0
- package/src/logging/ToolLogger.test.ts +184 -0
- package/src/logging/ToolLogger.ts +13 -12
- package/src/service/Service.test.ts +577 -34
- package/src/service/Service.ts +286 -54
- package/src/types/ToolError.test.ts +192 -0
- package/src/types/ToolError.ts +95 -0
- package/src/validation/ParameterValidator.test.ts +185 -158
- package/src/validation/ParameterValidator.ts +17 -20
package/src/service/Service.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* eslint-disable max-classes-per-file */
|
|
2
|
-
import { AuthRequirement, Parameter } from '../types/Models';
|
|
2
|
+
import { AuthRequirement, IslandResponse, Parameter } from '../types/Models';
|
|
3
|
+
import { ToolError } from '../types/ToolError';
|
|
3
4
|
import * as App from '@zaiusinc/app-sdk';
|
|
4
|
-
import { logger, Headers } from '@zaiusinc/app-sdk';
|
|
5
|
+
import { logger, Headers, getAppContext } from '@zaiusinc/app-sdk';
|
|
5
6
|
import { ToolFunction } from '../function/ToolFunction';
|
|
6
7
|
import { GlobalToolFunction } from '../function/GlobalToolFunction';
|
|
7
8
|
import { ParameterValidator } from '../validation/ParameterValidator';
|
|
@@ -11,13 +12,41 @@ import { ParameterValidator } from '../validation/ParameterValidator';
|
|
|
11
12
|
*/
|
|
12
13
|
const DEFAULT_OPTIID_AUTH = new AuthRequirement('OptiID', 'default', true);
|
|
13
14
|
|
|
15
|
+
export class NestedInteractions {
|
|
16
|
+
public constructor(public response: IslandResponse) {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Interface for tool description override data (compatible with KV store)
|
|
22
|
+
*/
|
|
23
|
+
interface ToolOverride extends App.ValueHash {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string;
|
|
26
|
+
parameters: ParameterOverride[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ParameterOverride extends App.ValueHash {
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Interface for stored override data in KV store
|
|
36
|
+
*/
|
|
37
|
+
interface StoredOverrides extends App.ValueHash {
|
|
38
|
+
[tool_name: string]: ToolOverride;
|
|
39
|
+
}
|
|
40
|
+
|
|
14
41
|
/**
|
|
15
42
|
* Result type for interaction handlers
|
|
16
43
|
*/
|
|
17
44
|
export class InteractionResult {
|
|
18
45
|
public constructor(
|
|
19
46
|
public message: string,
|
|
20
|
-
public link?: string
|
|
47
|
+
public link?: string,
|
|
48
|
+
public dispatch_event?: boolean,
|
|
49
|
+
public interactions?: NestedInteractions
|
|
21
50
|
) {}
|
|
22
51
|
}
|
|
23
52
|
|
|
@@ -88,6 +117,107 @@ export class ToolsService {
|
|
|
88
117
|
private functions: Map<string, Tool<any>> = new Map();
|
|
89
118
|
private interactions: Map<string, Interaction<any>> = new Map();
|
|
90
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Generate KV store key for tool overrides
|
|
122
|
+
* @param appVersionId App version ID
|
|
123
|
+
* @param functionName Function name
|
|
124
|
+
* @returns KV store key
|
|
125
|
+
*/
|
|
126
|
+
private getOverrideKey(appVersionId: string, functionName: string): string {
|
|
127
|
+
return `${appVersionId}:${functionName}:opal-tools-overrides`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get tool overrides from KV store
|
|
132
|
+
* @param appVersionId App version ID
|
|
133
|
+
* @param functionName Function name
|
|
134
|
+
* @returns Stored overrides or null if not found
|
|
135
|
+
*/
|
|
136
|
+
private async getOverrides(appVersionId: string, functionName: string): Promise<StoredOverrides> {
|
|
137
|
+
const key = this.getOverrideKey(appVersionId, functionName);
|
|
138
|
+
return await App.storage.kvStore.get<StoredOverrides>(key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Apply overrides to tool definitions
|
|
143
|
+
* @param tools Original tool definitions
|
|
144
|
+
* @param overrides Override data
|
|
145
|
+
* @returns Tools with overrides applied
|
|
146
|
+
*/
|
|
147
|
+
private applyOverrides(tools: Array<Tool<any>>, overrides: StoredOverrides): Array<Tool<any>> {
|
|
148
|
+
return tools.map((tool) => {
|
|
149
|
+
const override = overrides[tool.name];
|
|
150
|
+
if (!override) {
|
|
151
|
+
return tool;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clone the tool and apply overrides
|
|
155
|
+
const overriddenTool = new Tool(
|
|
156
|
+
tool.name,
|
|
157
|
+
override.description,
|
|
158
|
+
tool.parameters.map((param) => {
|
|
159
|
+
const paramOverride = override.parameters?.find((p) => p.name === param.name);
|
|
160
|
+
if (paramOverride) {
|
|
161
|
+
return new Parameter(
|
|
162
|
+
param.name,
|
|
163
|
+
param.type,
|
|
164
|
+
paramOverride.description,
|
|
165
|
+
param.required
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return param;
|
|
169
|
+
}),
|
|
170
|
+
tool.endpoint,
|
|
171
|
+
tool.handler,
|
|
172
|
+
tool.authRequirements
|
|
173
|
+
);
|
|
174
|
+
overriddenTool.httpMethod = tool.httpMethod;
|
|
175
|
+
return overriddenTool;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Save tool description overrides
|
|
181
|
+
* @param appVersionId App version ID
|
|
182
|
+
* @param functionName Function name
|
|
183
|
+
* @param overrideData Override data from request
|
|
184
|
+
*/
|
|
185
|
+
public async saveToolOverrides(
|
|
186
|
+
appVersionId: string,
|
|
187
|
+
functionName: string,
|
|
188
|
+
overrides: StoredOverrides
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const key = this.getOverrideKey(appVersionId, functionName);
|
|
191
|
+
await App.storage.kvStore.patch(key, overrides);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Delete tool description overrides
|
|
196
|
+
* @param appVersionId App version ID
|
|
197
|
+
* @param functionName Function name
|
|
198
|
+
*/
|
|
199
|
+
public async deleteToolOverrides(appVersionId: string, functionName: string): Promise<void> {
|
|
200
|
+
const key = this.getOverrideKey(appVersionId, functionName);
|
|
201
|
+
await App.storage.kvStore.delete(key);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get tool definitions with overrides applied
|
|
206
|
+
* @param appVersionId App version ID
|
|
207
|
+
* @param functionName Function name
|
|
208
|
+
* @returns Tool definitions with overrides applied
|
|
209
|
+
*/
|
|
210
|
+
public async getToolsWithOverrides(appVersionId: string, functionName: string): Promise<Array<Tool<any>>> {
|
|
211
|
+
const tools = Array.from(this.functions.values());
|
|
212
|
+
const overrides = await this.getOverrides(appVersionId, functionName);
|
|
213
|
+
|
|
214
|
+
if (overrides) {
|
|
215
|
+
return this.applyOverrides(tools, overrides);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return tools;
|
|
219
|
+
}
|
|
220
|
+
|
|
91
221
|
/**
|
|
92
222
|
* Enforce OptiID authentication for tools by ensuring OptiID auth requirement is present
|
|
93
223
|
* @param authRequirements Original authentication requirements
|
|
@@ -104,6 +234,37 @@ export class ToolsService {
|
|
|
104
234
|
return [...(authRequirements || []), DEFAULT_OPTIID_AUTH];
|
|
105
235
|
}
|
|
106
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Format an error as RFC 9457 Problem Details response
|
|
239
|
+
* @param error The error to format
|
|
240
|
+
* @param instance URI reference identifying the specific occurrence
|
|
241
|
+
* @returns RFC 9457 compliant Response
|
|
242
|
+
*/
|
|
243
|
+
private formatErrorResponse(error: any, instance: string): App.Response {
|
|
244
|
+
let status = 500;
|
|
245
|
+
let problemDetails: Record<string, unknown>;
|
|
246
|
+
|
|
247
|
+
if (error instanceof ToolError) {
|
|
248
|
+
// Use ToolError's status and format
|
|
249
|
+
status = error.status;
|
|
250
|
+
problemDetails = error.toProblemDetails(instance);
|
|
251
|
+
} else {
|
|
252
|
+
// Convert regular errors to RFC 9457 format with 500 status
|
|
253
|
+
problemDetails = {
|
|
254
|
+
title: 'Internal Server Error',
|
|
255
|
+
status: 500,
|
|
256
|
+
detail: error.message || 'An unexpected error occurred',
|
|
257
|
+
instance
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return new App.Response(
|
|
262
|
+
status,
|
|
263
|
+
problemDetails,
|
|
264
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
107
268
|
/**
|
|
108
269
|
* Register a tool function with generic auth data
|
|
109
270
|
* @param name Tool name
|
|
@@ -161,70 +322,141 @@ export class ToolsService {
|
|
|
161
322
|
req: App.Request,
|
|
162
323
|
functionContext: ToolFunction | GlobalToolFunction
|
|
163
324
|
): Promise<App.Response> {
|
|
325
|
+
// Handle discovery endpoint with overrides
|
|
164
326
|
if (req.path === '/discovery') {
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
|
-
const func = this.functions.get(req.path);
|
|
168
|
-
if (func) {
|
|
169
|
-
try {
|
|
170
|
-
let params;
|
|
171
|
-
if (req.bodyJSON && req.bodyJSON.parameters) {
|
|
172
|
-
params = req.bodyJSON.parameters;
|
|
173
|
-
} else {
|
|
174
|
-
params = req.bodyJSON;
|
|
175
|
-
}
|
|
327
|
+
return await this.handleDiscoveryRequest(functionContext);
|
|
328
|
+
}
|
|
176
329
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
return new App.Response(400, {
|
|
182
|
-
title: 'One or more validation errors occurred.',
|
|
183
|
-
status: 400,
|
|
184
|
-
detail: "See 'errors' field for details.",
|
|
185
|
-
instance: func.endpoint,
|
|
186
|
-
errors: validationResult.errors.map((error) => ({
|
|
187
|
-
field: error.field,
|
|
188
|
-
message: error.message
|
|
189
|
-
}))
|
|
190
|
-
}, new Headers([['content-type', 'application/problem+json']]));
|
|
191
|
-
}
|
|
192
|
-
}
|
|
330
|
+
// Handle overrides endpoint
|
|
331
|
+
if (req.path === '/overrides') {
|
|
332
|
+
return await this.handleOverridesRequest(req, functionContext);
|
|
333
|
+
}
|
|
193
334
|
|
|
194
|
-
|
|
195
|
-
|
|
335
|
+
// Handle regular tool functions
|
|
336
|
+
const func = this.functions.get(req.path);
|
|
337
|
+
if (func) {
|
|
338
|
+
try {
|
|
339
|
+
let params;
|
|
340
|
+
if (req.bodyJSON && req.bodyJSON.parameters) {
|
|
341
|
+
params = req.bodyJSON.parameters;
|
|
342
|
+
} else {
|
|
343
|
+
params = req.bodyJSON;
|
|
344
|
+
}
|
|
196
345
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
return new App.Response(500, { error: error.message || 'Unknown error' });
|
|
346
|
+
// Validate parameters before calling the handler (only if tool has parameter definitions)
|
|
347
|
+
// ParameterValidator.validate() throws ToolError if validation fails
|
|
348
|
+
if (func.parameters && func.parameters.length > 0) {
|
|
349
|
+
ParameterValidator.validate(params, func.parameters, func.endpoint);
|
|
202
350
|
}
|
|
351
|
+
|
|
352
|
+
// Extract auth data from body JSON
|
|
353
|
+
const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
|
|
354
|
+
const result = await func.handler(functionContext, params, authData);
|
|
355
|
+
return new App.Response(200, result);
|
|
356
|
+
} catch (error: any) {
|
|
357
|
+
logger.error(`Error in function ${func.name}:`, error);
|
|
358
|
+
return this.formatErrorResponse(error, func.endpoint);
|
|
203
359
|
}
|
|
360
|
+
}
|
|
204
361
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
362
|
+
// Handle interactions
|
|
363
|
+
const interaction = this.interactions.get(req.path);
|
|
364
|
+
if (interaction) {
|
|
365
|
+
try {
|
|
366
|
+
let params;
|
|
367
|
+
if (req.bodyJSON && req.bodyJSON.data) {
|
|
368
|
+
params = req.bodyJSON.data;
|
|
369
|
+
} else {
|
|
370
|
+
params = req.bodyJSON;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Extract auth data from body JSON
|
|
374
|
+
const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
|
|
214
375
|
|
|
215
|
-
|
|
216
|
-
|
|
376
|
+
const result = await interaction.handler(functionContext, params, authData);
|
|
377
|
+
return new App.Response(200, result);
|
|
378
|
+
} catch (error: any) {
|
|
379
|
+
logger.error(`Error in function ${interaction.name}:`, error);
|
|
380
|
+
return this.formatErrorResponse(error, interaction.endpoint);
|
|
217
381
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return new App.Response(404, 'Function not found');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Handle discovery endpoint with overrides applied
|
|
389
|
+
* @param functionContext The function context to get function name from
|
|
390
|
+
* @returns Response with tool definitions
|
|
391
|
+
*/
|
|
392
|
+
private async handleDiscoveryRequest(functionContext: ToolFunction | GlobalToolFunction): Promise<App.Response> {
|
|
393
|
+
try {
|
|
394
|
+
// Get app version from app context
|
|
395
|
+
const appVersionId = getAppContext().manifest.meta.version;
|
|
396
|
+
// Get function name from function context
|
|
397
|
+
const functionName = functionContext.constructor.name;
|
|
398
|
+
|
|
399
|
+
const toolsWithOverrides = await this.getToolsWithOverrides(appVersionId, functionName);
|
|
400
|
+
return new App.Response(200, { functions: toolsWithOverrides.map((f) => f.toJSON()) });
|
|
401
|
+
} catch (error: any) {
|
|
402
|
+
logger.error('Error getting tools with overrides:', error);
|
|
403
|
+
// Fallback to original tools if override fetch fails
|
|
404
|
+
return new App.Response(200, { functions: Array.from(this.functions.values()).map((f) => f.toJSON()) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Handle overrides endpoint for saving and deleting tool description overrides
|
|
410
|
+
* @param req The request object
|
|
411
|
+
* @param functionContext The function context to get function name from
|
|
412
|
+
* @returns Response indicating success or failure
|
|
413
|
+
*/
|
|
414
|
+
private async handleOverridesRequest(
|
|
415
|
+
req: App.Request,
|
|
416
|
+
functionContext: ToolFunction | GlobalToolFunction
|
|
417
|
+
): Promise<App.Response> {
|
|
418
|
+
if (req.method === 'PATCH') {
|
|
419
|
+
try {
|
|
420
|
+
// Get app version from app context
|
|
421
|
+
const appVersionId = getAppContext().manifest.meta.version;
|
|
422
|
+
// Get function name from function context
|
|
423
|
+
const functionName = functionContext.constructor.name;
|
|
424
|
+
|
|
425
|
+
if (!req.bodyJSON?.functions || !Array.isArray(req.bodyJSON.functions)) {
|
|
426
|
+
return new App.Response(400, { error: 'Invalid request body. Expected functions array.' });
|
|
223
427
|
}
|
|
428
|
+
|
|
429
|
+
// Convert array format to map format for storage
|
|
430
|
+
const overrideData: StoredOverrides = (req.bodyJSON.functions as ToolOverride[]).reduce(
|
|
431
|
+
(map: StoredOverrides, tool: ToolOverride) => {
|
|
432
|
+
map[tool.name] = tool;
|
|
433
|
+
return map;
|
|
434
|
+
},
|
|
435
|
+
{}
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
await this.saveToolOverrides(appVersionId, functionName, overrideData);
|
|
439
|
+
return new App.Response(200, { success: true });
|
|
440
|
+
} catch (error: any) {
|
|
441
|
+
logger.error('Error saving tool overrides:', error);
|
|
442
|
+
return new App.Response(500, { error: error.message || 'Unknown error' });
|
|
224
443
|
}
|
|
444
|
+
} else if (req.method === 'DELETE') {
|
|
445
|
+
try {
|
|
446
|
+
// Get app version from app context
|
|
447
|
+
const appVersionId = getAppContext().manifest.meta.version;
|
|
448
|
+
// Get function name from function context
|
|
449
|
+
const functionName = functionContext.constructor.name;
|
|
225
450
|
|
|
226
|
-
|
|
451
|
+
await this.deleteToolOverrides(appVersionId, functionName);
|
|
452
|
+
return new App.Response(200, { success: true });
|
|
453
|
+
} catch (error: any) {
|
|
454
|
+
logger.error('Error deleting tool overrides:', error);
|
|
455
|
+
return new App.Response(500, { error: error.message || 'Unknown error' });
|
|
456
|
+
}
|
|
227
457
|
}
|
|
458
|
+
|
|
459
|
+
return new App.Response(405, { error: 'Method not allowed' });
|
|
228
460
|
}
|
|
229
461
|
}
|
|
230
462
|
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { ToolError } from './ToolError';
|
|
2
|
+
|
|
3
|
+
describe('ToolError', () => {
|
|
4
|
+
describe('constructor', () => {
|
|
5
|
+
it('should create error with message and default status 500', () => {
|
|
6
|
+
const error = new ToolError('Something went wrong');
|
|
7
|
+
|
|
8
|
+
expect(error.message).toBe('Something went wrong');
|
|
9
|
+
expect(error.status).toBe(500);
|
|
10
|
+
expect(error.detail).toBeUndefined();
|
|
11
|
+
expect(error.name).toBe('ToolError');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should create error with custom status code', () => {
|
|
15
|
+
const error = new ToolError('Not found', 404);
|
|
16
|
+
|
|
17
|
+
expect(error.message).toBe('Not found');
|
|
18
|
+
expect(error.status).toBe(404);
|
|
19
|
+
expect(error.detail).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should create error with message, status, and detail', () => {
|
|
23
|
+
const error = new ToolError('Validation failed', 400, 'Email format is invalid');
|
|
24
|
+
|
|
25
|
+
expect(error.message).toBe('Validation failed');
|
|
26
|
+
expect(error.status).toBe(400);
|
|
27
|
+
expect(error.detail).toBe('Email format is invalid');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should be an instance of Error', () => {
|
|
31
|
+
const error = new ToolError('Test error');
|
|
32
|
+
|
|
33
|
+
expect(error).toBeInstanceOf(Error);
|
|
34
|
+
expect(error).toBeInstanceOf(ToolError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should have a stack trace', () => {
|
|
38
|
+
const error = new ToolError('Test error');
|
|
39
|
+
|
|
40
|
+
expect(error.stack).toBeDefined();
|
|
41
|
+
expect(error.stack).toContain('ToolError');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('toProblemDetails', () => {
|
|
46
|
+
it('should convert to RFC 9457 format with all fields', () => {
|
|
47
|
+
const error = new ToolError('Resource not found', 404, 'The task with ID 123 does not exist');
|
|
48
|
+
const problemDetails = error.toProblemDetails('/api/tasks/123');
|
|
49
|
+
|
|
50
|
+
expect(problemDetails).toEqual({
|
|
51
|
+
title: 'Resource not found',
|
|
52
|
+
status: 404,
|
|
53
|
+
detail: 'The task with ID 123 does not exist',
|
|
54
|
+
instance: '/api/tasks/123'
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should omit detail field when not provided', () => {
|
|
59
|
+
const error = new ToolError('Bad request', 400);
|
|
60
|
+
const problemDetails = error.toProblemDetails('/api/tasks');
|
|
61
|
+
|
|
62
|
+
expect(problemDetails).toEqual({
|
|
63
|
+
title: 'Bad request',
|
|
64
|
+
status: 400,
|
|
65
|
+
instance: '/api/tasks'
|
|
66
|
+
});
|
|
67
|
+
expect(problemDetails).not.toHaveProperty('detail');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should include default status 500', () => {
|
|
71
|
+
const error = new ToolError('Internal error');
|
|
72
|
+
const problemDetails = error.toProblemDetails('/api/endpoint');
|
|
73
|
+
|
|
74
|
+
expect(problemDetails).toEqual({
|
|
75
|
+
title: 'Internal error',
|
|
76
|
+
status: 500,
|
|
77
|
+
instance: '/api/endpoint'
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle different instance paths', () => {
|
|
82
|
+
const error = new ToolError('Error', 500, 'Details');
|
|
83
|
+
const problemDetails1 = error.toProblemDetails('/api/v1/resource');
|
|
84
|
+
const problemDetails2 = error.toProblemDetails('/webhook/callback');
|
|
85
|
+
|
|
86
|
+
expect(problemDetails1.instance).toBe('/api/v1/resource');
|
|
87
|
+
expect(problemDetails2.instance).toBe('/webhook/callback');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should include errors array when provided', () => {
|
|
91
|
+
const errors = [
|
|
92
|
+
{ field: 'email', message: 'Invalid email format' },
|
|
93
|
+
{ field: 'age', message: 'Must be a positive number' }
|
|
94
|
+
];
|
|
95
|
+
const error = new ToolError('Validation failed', 400, "See 'errors' field for details.", errors);
|
|
96
|
+
const problemDetails = error.toProblemDetails('/api/users');
|
|
97
|
+
|
|
98
|
+
expect(problemDetails).toEqual({
|
|
99
|
+
title: 'Validation failed',
|
|
100
|
+
status: 400,
|
|
101
|
+
detail: "See 'errors' field for details.",
|
|
102
|
+
instance: '/api/users',
|
|
103
|
+
errors: [
|
|
104
|
+
{ field: 'email', message: 'Invalid email format' },
|
|
105
|
+
{ field: 'age', message: 'Must be a positive number' }
|
|
106
|
+
]
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should omit errors field when empty array', () => {
|
|
111
|
+
const error = new ToolError('Error', 400, 'Detail', []);
|
|
112
|
+
const problemDetails = error.toProblemDetails('/api/endpoint');
|
|
113
|
+
|
|
114
|
+
expect(problemDetails).toEqual({
|
|
115
|
+
title: 'Error',
|
|
116
|
+
status: 400,
|
|
117
|
+
detail: 'Detail',
|
|
118
|
+
instance: '/api/endpoint'
|
|
119
|
+
});
|
|
120
|
+
expect(problemDetails).not.toHaveProperty('errors');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should omit errors field when not provided', () => {
|
|
124
|
+
const error = new ToolError('Error', 400, 'Detail');
|
|
125
|
+
const problemDetails = error.toProblemDetails('/api/endpoint');
|
|
126
|
+
|
|
127
|
+
expect(problemDetails).toEqual({
|
|
128
|
+
title: 'Error',
|
|
129
|
+
status: 400,
|
|
130
|
+
detail: 'Detail',
|
|
131
|
+
instance: '/api/endpoint'
|
|
132
|
+
});
|
|
133
|
+
expect(problemDetails).not.toHaveProperty('errors');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should support errors without detail field', () => {
|
|
137
|
+
const errors = [{ field: 'name', message: 'Required' }];
|
|
138
|
+
const error = new ToolError('Validation failed', 400, undefined, errors);
|
|
139
|
+
const problemDetails = error.toProblemDetails('/api/endpoint');
|
|
140
|
+
|
|
141
|
+
expect(problemDetails).toEqual({
|
|
142
|
+
title: 'Validation failed',
|
|
143
|
+
status: 400,
|
|
144
|
+
instance: '/api/endpoint',
|
|
145
|
+
errors: [{ field: 'name', message: 'Required' }]
|
|
146
|
+
});
|
|
147
|
+
expect(problemDetails).not.toHaveProperty('detail');
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('common HTTP status codes', () => {
|
|
152
|
+
it('should support 400 Bad Request', () => {
|
|
153
|
+
const error = new ToolError('Bad Request', 400);
|
|
154
|
+
expect(error.status).toBe(400);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should support 401 Unauthorized', () => {
|
|
158
|
+
const error = new ToolError('Unauthorized', 401);
|
|
159
|
+
expect(error.status).toBe(401);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should support 403 Forbidden', () => {
|
|
163
|
+
const error = new ToolError('Forbidden', 403);
|
|
164
|
+
expect(error.status).toBe(403);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should support 404 Not Found', () => {
|
|
168
|
+
const error = new ToolError('Not Found', 404);
|
|
169
|
+
expect(error.status).toBe(404);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should support 409 Conflict', () => {
|
|
173
|
+
const error = new ToolError('Conflict', 409);
|
|
174
|
+
expect(error.status).toBe(409);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should support 422 Unprocessable Entity', () => {
|
|
178
|
+
const error = new ToolError('Unprocessable Entity', 422);
|
|
179
|
+
expect(error.status).toBe(422);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should support 500 Internal Server Error', () => {
|
|
183
|
+
const error = new ToolError('Internal Server Error', 500);
|
|
184
|
+
expect(error.status).toBe(500);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should support 503 Service Unavailable', () => {
|
|
188
|
+
const error = new ToolError('Service Unavailable', 503);
|
|
189
|
+
expect(error.status).toBe(503);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation error details
|
|
3
|
+
*/
|
|
4
|
+
export interface ValidationError {
|
|
5
|
+
field: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Custom error class for tool functions that supports RFC 9457 Problem Details format
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* // Throw a 404 error
|
|
15
|
+
* throw new ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
16
|
+
*
|
|
17
|
+
* // Throw a 400 error
|
|
18
|
+
* throw new ToolError('Invalid input', 400, 'The priority must be "low", "medium", or "high"');
|
|
19
|
+
*
|
|
20
|
+
* // Throw a 500 error (default)
|
|
21
|
+
* throw new ToolError('Database connection failed');
|
|
22
|
+
*
|
|
23
|
+
* // Throw a validation error with multiple field errors
|
|
24
|
+
* throw new ToolError('Validation failed', 400, "See 'errors' field for details.", [
|
|
25
|
+
* { field: 'email', message: 'Invalid email format' },
|
|
26
|
+
* { field: 'age', message: 'Must be a positive number' }
|
|
27
|
+
* ]);
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export class ToolError extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* HTTP status code for the error response
|
|
33
|
+
*/
|
|
34
|
+
public readonly status: number;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detailed error description (optional)
|
|
38
|
+
*/
|
|
39
|
+
public readonly detail?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Array of validation errors (optional)
|
|
43
|
+
*/
|
|
44
|
+
public readonly errors?: ValidationError[];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a new ToolError
|
|
48
|
+
*
|
|
49
|
+
* @param message - Error message (used as RFC 9457 "title" field)
|
|
50
|
+
* @param status - HTTP status code (default: 500)
|
|
51
|
+
* @param detail - Detailed error description (optional)
|
|
52
|
+
* @param errors - Array of validation errors (optional)
|
|
53
|
+
*/
|
|
54
|
+
public constructor(
|
|
55
|
+
message: string,
|
|
56
|
+
status: number = 500,
|
|
57
|
+
detail?: string,
|
|
58
|
+
errors?: ValidationError[]
|
|
59
|
+
) {
|
|
60
|
+
super(message);
|
|
61
|
+
this.name = 'ToolError';
|
|
62
|
+
this.status = status;
|
|
63
|
+
this.detail = detail;
|
|
64
|
+
this.errors = errors;
|
|
65
|
+
|
|
66
|
+
// Maintains proper stack trace for where error was thrown (V8 engines only)
|
|
67
|
+
if (Error.captureStackTrace) {
|
|
68
|
+
Error.captureStackTrace(this, ToolError);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convert error to RFC 9457 Problem Details format
|
|
74
|
+
*
|
|
75
|
+
* @param instance - URI reference identifying the specific occurrence of the problem
|
|
76
|
+
* @returns RFC 9457 compliant error object
|
|
77
|
+
*/
|
|
78
|
+
public toProblemDetails(instance: string): Record<string, unknown> {
|
|
79
|
+
const problemDetails: Record<string, unknown> = {
|
|
80
|
+
title: this.message,
|
|
81
|
+
status: this.status,
|
|
82
|
+
instance
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (this.detail) {
|
|
86
|
+
problemDetails.detail = this.detail;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (this.errors && this.errors.length > 0) {
|
|
90
|
+
problemDetails.errors = this.errors;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return problemDetails;
|
|
94
|
+
}
|
|
95
|
+
}
|