@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.1 → 1.0.0-beta.10

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 (74) hide show
  1. package/README.md +169 -3
  2. package/dist/auth/AuthUtils.d.ts +12 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +80 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +161 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +5 -3
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +32 -8
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +73 -12
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +11 -4
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +45 -9
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +278 -11
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/logging/ToolLogger.d.ts +42 -0
  25. package/dist/logging/ToolLogger.d.ts.map +1 -0
  26. package/dist/logging/ToolLogger.js +255 -0
  27. package/dist/logging/ToolLogger.js.map +1 -0
  28. package/dist/logging/ToolLogger.test.d.ts +2 -0
  29. package/dist/logging/ToolLogger.test.d.ts.map +1 -0
  30. package/dist/logging/ToolLogger.test.js +864 -0
  31. package/dist/logging/ToolLogger.test.js.map +1 -0
  32. package/dist/service/Service.d.ts +88 -2
  33. package/dist/service/Service.d.ts.map +1 -1
  34. package/dist/service/Service.js +228 -39
  35. package/dist/service/Service.js.map +1 -1
  36. package/dist/service/Service.test.js +558 -22
  37. package/dist/service/Service.test.js.map +1 -1
  38. package/dist/types/Models.d.ts +7 -1
  39. package/dist/types/Models.d.ts.map +1 -1
  40. package/dist/types/Models.js +5 -1
  41. package/dist/types/Models.js.map +1 -1
  42. package/dist/types/ToolError.d.ts +72 -0
  43. package/dist/types/ToolError.d.ts.map +1 -0
  44. package/dist/types/ToolError.js +107 -0
  45. package/dist/types/ToolError.js.map +1 -0
  46. package/dist/types/ToolError.test.d.ts +2 -0
  47. package/dist/types/ToolError.test.d.ts.map +1 -0
  48. package/dist/types/ToolError.test.js +185 -0
  49. package/dist/types/ToolError.test.js.map +1 -0
  50. package/dist/validation/ParameterValidator.d.ts +31 -0
  51. package/dist/validation/ParameterValidator.d.ts.map +1 -0
  52. package/dist/validation/ParameterValidator.js +129 -0
  53. package/dist/validation/ParameterValidator.js.map +1 -0
  54. package/dist/validation/ParameterValidator.test.d.ts +2 -0
  55. package/dist/validation/ParameterValidator.test.d.ts.map +1 -0
  56. package/dist/validation/ParameterValidator.test.js +323 -0
  57. package/dist/validation/ParameterValidator.test.js.map +1 -0
  58. package/package.json +3 -3
  59. package/src/auth/AuthUtils.test.ts +176 -157
  60. package/src/auth/AuthUtils.ts +96 -33
  61. package/src/function/GlobalToolFunction.test.ts +78 -14
  62. package/src/function/GlobalToolFunction.ts +46 -11
  63. package/src/function/ToolFunction.test.ts +298 -13
  64. package/src/function/ToolFunction.ts +61 -13
  65. package/src/index.ts +2 -1
  66. package/src/logging/ToolLogger.test.ts +1020 -0
  67. package/src/logging/ToolLogger.ts +292 -0
  68. package/src/service/Service.test.ts +712 -28
  69. package/src/service/Service.ts +288 -38
  70. package/src/types/Models.ts +8 -1
  71. package/src/types/ToolError.test.ts +222 -0
  72. package/src/types/ToolError.ts +125 -0
  73. package/src/validation/ParameterValidator.test.ts +371 -0
  74. package/src/validation/ParameterValidator.ts +150 -0
@@ -1,22 +1,52 @@
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 } 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';
8
+ import { ParameterValidator } from '../validation/ParameterValidator';
7
9
 
8
10
  /**
9
11
  * Default OptiID authentication requirement that will be enforced for all tools
10
12
  */
11
13
  const DEFAULT_OPTIID_AUTH = new AuthRequirement('OptiID', 'default', true);
12
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
+
13
41
  /**
14
42
  * Result type for interaction handlers
15
43
  */
16
44
  export class InteractionResult {
17
45
  public constructor(
18
46
  public message: string,
19
- public link?: string
47
+ public link?: string,
48
+ public dispatch_event?: boolean,
49
+ public interactions?: NestedInteractions
20
50
  ) {}
21
51
  }
22
52
 
@@ -87,6 +117,107 @@ export class ToolsService {
87
117
  private functions: Map<string, Tool<any>> = new Map();
88
118
  private interactions: Map<string, Interaction<any>> = new Map();
89
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
+
90
221
  /**
91
222
  * Enforce OptiID authentication for tools by ensuring OptiID auth requirement is present
92
223
  * @param authRequirements Original authentication requirements
@@ -103,6 +234,37 @@ export class ToolsService {
103
234
  return [...(authRequirements || []), DEFAULT_OPTIID_AUTH];
104
235
  }
105
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
+
106
268
  /**
107
269
  * Register a tool function with generic auth data
108
270
  * @param name Tool name
@@ -160,53 +322,141 @@ export class ToolsService {
160
322
  req: App.Request,
161
323
  functionContext: ToolFunction | GlobalToolFunction
162
324
  ): Promise<App.Response> {
325
+ // Handle discovery endpoint with overrides
163
326
  if (req.path === '/discovery') {
164
- return new App.Response(200, { functions: Array.from(this.functions.values()).map((f) => f.toJSON()) });
165
- } else {
166
- const func = this.functions.get(req.path);
167
- if (func) {
168
- try {
169
- let params;
170
- if (req.bodyJSON && req.bodyJSON.parameters) {
171
- params = req.bodyJSON.parameters;
172
- } else {
173
- params = req.bodyJSON;
174
- }
327
+ return await this.handleDiscoveryRequest(functionContext);
328
+ }
175
329
 
176
- // Extract auth data from body JSON
177
- const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
330
+ // Handle overrides endpoint
331
+ if (req.path === '/overrides') {
332
+ return await this.handleOverridesRequest(req, functionContext);
333
+ }
334
+
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
+ }
178
345
 
179
- const result = await func.handler(functionContext, params, authData);
180
- return new App.Response(200, result);
181
- } catch (error: any) {
182
- logger.error(`Error in function ${func.name}:`, error);
183
- 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);
184
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);
185
359
  }
360
+ }
186
361
 
187
- const interaction = this.interactions.get(req.path);
188
- if (interaction) {
189
- try {
190
- let params;
191
- if (req.bodyJSON && req.bodyJSON.data) {
192
- params = req.bodyJSON.data;
193
- } else {
194
- params = req.bodyJSON;
195
- }
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;
196
375
 
197
- // Extract auth data from body JSON
198
- const authData = req.bodyJSON ? req.bodyJSON.auth : undefined;
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);
199
381
 
200
- const result = await interaction.handler(functionContext, params, authData);
201
- return new App.Response(200, result);
202
- } catch (error: any) {
203
- logger.error(`Error in function ${interaction.name}:`, error);
204
- return new App.Response(500, { error: error.message || 'Unknown error' });
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.' });
205
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' });
206
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;
207
450
 
208
- return new App.Response(404, 'Function not found');
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
+ }
209
457
  }
458
+
459
+ return new App.Response(405, { error: 'Method not allowed' });
210
460
  }
211
461
  }
212
462
 
@@ -143,7 +143,9 @@ export class IslandConfig {
143
143
 
144
144
  public constructor(
145
145
  public fields: IslandField[],
146
- public actions: IslandAction[]
146
+ public actions: IslandAction[],
147
+ public type?: 'card' | 'button',
148
+ public icon?: string,
147
149
  ) {}
148
150
  }
149
151
 
@@ -161,3 +163,8 @@ export class IslandResponse {
161
163
  }
162
164
  }
163
165
 
166
+ export interface ReadyResponse {
167
+ ready: boolean;
168
+ reason?: string;
169
+ }
170
+
@@ -0,0 +1,222 @@
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('[500] Something went wrong');
9
+ expect(error.title).toBe('Something went wrong');
10
+ expect(error.status).toBe(500);
11
+ expect(error.detail).toBeUndefined();
12
+ expect(error.name).toBe('ToolError');
13
+ });
14
+
15
+ it('should create error with custom status code', () => {
16
+ const error = new ToolError('Not found', 404);
17
+
18
+ expect(error.message).toBe('[404] Not found');
19
+ expect(error.title).toBe('Not found');
20
+ expect(error.status).toBe(404);
21
+ expect(error.detail).toBeUndefined();
22
+ });
23
+
24
+ it('should create error with message, status, and detail', () => {
25
+ const error = new ToolError('Validation failed', 400, 'Email format is invalid');
26
+
27
+ expect(error.message).toBe('[400] Validation failed: Email format is invalid');
28
+ expect(error.title).toBe('Validation failed');
29
+ expect(error.status).toBe(400);
30
+ expect(error.detail).toBe('Email format is invalid');
31
+ });
32
+
33
+ it('should create error with errors array in message', () => {
34
+ const errors = [
35
+ { field: 'email', message: 'Invalid email format' },
36
+ { field: 'age', message: 'Must be a positive number' }
37
+ ];
38
+ const error = new ToolError('Validation failed', 400, "See 'errors' field for details.", errors);
39
+
40
+ expect(error.message).toBe(
41
+ '[400] Validation failed: email (Invalid email format); age (Must be a positive number)'
42
+ );
43
+ expect(error.title).toBe('Validation failed');
44
+ expect(error.status).toBe(400);
45
+ expect(error.detail).toBe("See 'errors' field for details.");
46
+ expect(error.errors).toEqual(errors);
47
+ });
48
+
49
+ it('should create error with single error in message', () => {
50
+ const errors = [{ field: 'username', message: 'Required field is missing' }];
51
+ const error = new ToolError('Validation failed', 400, undefined, errors);
52
+
53
+ expect(error.message).toBe('[400] Validation failed: username (Required field is missing)');
54
+ expect(error.title).toBe('Validation failed');
55
+ expect(error.status).toBe(400);
56
+ expect(error.detail).toBeUndefined();
57
+ expect(error.errors).toEqual(errors);
58
+ });
59
+
60
+ it('should be an instance of Error', () => {
61
+ const error = new ToolError('Test error');
62
+
63
+ expect(error).toBeInstanceOf(Error);
64
+ expect(error).toBeInstanceOf(ToolError);
65
+ });
66
+
67
+ it('should have a stack trace', () => {
68
+ const error = new ToolError('Test error');
69
+
70
+ expect(error.stack).toBeDefined();
71
+ expect(error.stack).toContain('ToolError');
72
+ });
73
+ });
74
+
75
+ describe('toProblemDetails', () => {
76
+ it('should convert to RFC 9457 format with all fields', () => {
77
+ const error = new ToolError('Resource not found', 404, 'The task with ID 123 does not exist');
78
+ const problemDetails = error.toProblemDetails('/api/tasks/123');
79
+
80
+ expect(problemDetails).toEqual({
81
+ title: 'Resource not found',
82
+ status: 404,
83
+ detail: 'The task with ID 123 does not exist',
84
+ instance: '/api/tasks/123'
85
+ });
86
+ });
87
+
88
+ it('should omit detail field when not provided', () => {
89
+ const error = new ToolError('Bad request', 400);
90
+ const problemDetails = error.toProblemDetails('/api/tasks');
91
+
92
+ expect(problemDetails).toEqual({
93
+ title: 'Bad request',
94
+ status: 400,
95
+ instance: '/api/tasks'
96
+ });
97
+ expect(problemDetails).not.toHaveProperty('detail');
98
+ });
99
+
100
+ it('should include default status 500', () => {
101
+ const error = new ToolError('Internal error');
102
+ const problemDetails = error.toProblemDetails('/api/endpoint');
103
+
104
+ expect(problemDetails).toEqual({
105
+ title: 'Internal error',
106
+ status: 500,
107
+ instance: '/api/endpoint'
108
+ });
109
+ });
110
+
111
+ it('should handle different instance paths', () => {
112
+ const error = new ToolError('Error', 500, 'Details');
113
+ const problemDetails1 = error.toProblemDetails('/api/v1/resource');
114
+ const problemDetails2 = error.toProblemDetails('/webhook/callback');
115
+
116
+ expect(problemDetails1.instance).toBe('/api/v1/resource');
117
+ expect(problemDetails2.instance).toBe('/webhook/callback');
118
+ });
119
+
120
+ it('should include errors array when provided', () => {
121
+ const errors = [
122
+ { field: 'email', message: 'Invalid email format' },
123
+ { field: 'age', message: 'Must be a positive number' }
124
+ ];
125
+ const error = new ToolError('Validation failed', 400, "See 'errors' field for details.", errors);
126
+ const problemDetails = error.toProblemDetails('/api/users');
127
+
128
+ expect(problemDetails).toEqual({
129
+ title: 'Validation failed',
130
+ status: 400,
131
+ detail: "See 'errors' field for details.",
132
+ instance: '/api/users',
133
+ errors: [
134
+ { field: 'email', message: 'Invalid email format' },
135
+ { field: 'age', message: 'Must be a positive number' }
136
+ ]
137
+ });
138
+ });
139
+
140
+ it('should omit errors field when empty array', () => {
141
+ const error = new ToolError('Error', 400, 'Detail', []);
142
+ const problemDetails = error.toProblemDetails('/api/endpoint');
143
+
144
+ expect(problemDetails).toEqual({
145
+ title: 'Error',
146
+ status: 400,
147
+ detail: 'Detail',
148
+ instance: '/api/endpoint'
149
+ });
150
+ expect(problemDetails).not.toHaveProperty('errors');
151
+ });
152
+
153
+ it('should omit errors field when not provided', () => {
154
+ const error = new ToolError('Error', 400, 'Detail');
155
+ const problemDetails = error.toProblemDetails('/api/endpoint');
156
+
157
+ expect(problemDetails).toEqual({
158
+ title: 'Error',
159
+ status: 400,
160
+ detail: 'Detail',
161
+ instance: '/api/endpoint'
162
+ });
163
+ expect(problemDetails).not.toHaveProperty('errors');
164
+ });
165
+
166
+ it('should support errors without detail field', () => {
167
+ const errors = [{ field: 'name', message: 'Required' }];
168
+ const error = new ToolError('Validation failed', 400, undefined, errors);
169
+ const problemDetails = error.toProblemDetails('/api/endpoint');
170
+
171
+ expect(problemDetails).toEqual({
172
+ title: 'Validation failed',
173
+ status: 400,
174
+ instance: '/api/endpoint',
175
+ errors: [{ field: 'name', message: 'Required' }]
176
+ });
177
+ expect(problemDetails).not.toHaveProperty('detail');
178
+ });
179
+ });
180
+
181
+ describe('common HTTP status codes', () => {
182
+ it('should support 400 Bad Request', () => {
183
+ const error = new ToolError('Bad Request', 400);
184
+ expect(error.status).toBe(400);
185
+ });
186
+
187
+ it('should support 401 Unauthorized', () => {
188
+ const error = new ToolError('Unauthorized', 401);
189
+ expect(error.status).toBe(401);
190
+ });
191
+
192
+ it('should support 403 Forbidden', () => {
193
+ const error = new ToolError('Forbidden', 403);
194
+ expect(error.status).toBe(403);
195
+ });
196
+
197
+ it('should support 404 Not Found', () => {
198
+ const error = new ToolError('Not Found', 404);
199
+ expect(error.status).toBe(404);
200
+ });
201
+
202
+ it('should support 409 Conflict', () => {
203
+ const error = new ToolError('Conflict', 409);
204
+ expect(error.status).toBe(409);
205
+ });
206
+
207
+ it('should support 422 Unprocessable Entity', () => {
208
+ const error = new ToolError('Unprocessable Entity', 422);
209
+ expect(error.status).toBe(422);
210
+ });
211
+
212
+ it('should support 500 Internal Server Error', () => {
213
+ const error = new ToolError('Internal Server Error', 500);
214
+ expect(error.status).toBe(500);
215
+ });
216
+
217
+ it('should support 503 Service Unavailable', () => {
218
+ const error = new ToolError('Service Unavailable', 503);
219
+ expect(error.status).toBe(503);
220
+ });
221
+ });
222
+ });