@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.2 → 1.0.0-beta.4
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 -0
- package/dist/auth/AuthUtils.d.ts +5 -5
- package/dist/auth/AuthUtils.d.ts.map +1 -1
- package/dist/auth/AuthUtils.js +53 -25
- package/dist/auth/AuthUtils.js.map +1 -1
- package/dist/auth/AuthUtils.test.js +62 -117
- package/dist/auth/AuthUtils.test.js.map +1 -1
- package/dist/function/GlobalToolFunction.d.ts +2 -1
- package/dist/function/GlobalToolFunction.d.ts.map +1 -1
- package/dist/function/GlobalToolFunction.js +25 -4
- package/dist/function/GlobalToolFunction.js.map +1 -1
- package/dist/function/GlobalToolFunction.test.js +57 -8
- package/dist/function/GlobalToolFunction.test.js.map +1 -1
- package/dist/function/ToolFunction.d.ts +8 -2
- package/dist/function/ToolFunction.d.ts.map +1 -1
- package/dist/function/ToolFunction.js +31 -5
- package/dist/function/ToolFunction.js.map +1 -1
- package/dist/function/ToolFunction.test.js +57 -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 +34 -0
- package/dist/logging/ToolLogger.d.ts.map +1 -0
- package/dist/logging/ToolLogger.js +153 -0
- package/dist/logging/ToolLogger.js.map +1 -0
- package/dist/logging/ToolLogger.test.d.ts +2 -0
- package/dist/logging/ToolLogger.test.d.ts.map +1 -0
- package/dist/logging/ToolLogger.test.js +646 -0
- package/dist/logging/ToolLogger.test.js.map +1 -0
- package/dist/service/Service.d.ts +15 -2
- package/dist/service/Service.d.ts.map +1 -1
- package/dist/service/Service.js +43 -17
- package/dist/service/Service.js.map +1 -1
- package/dist/service/Service.test.js +84 -2
- 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 +62 -157
- package/src/auth/AuthUtils.ts +66 -32
- package/src/function/GlobalToolFunction.test.ts +57 -8
- package/src/function/GlobalToolFunction.ts +37 -6
- package/src/function/ToolFunction.test.ts +57 -8
- package/src/function/ToolFunction.ts +45 -7
- package/src/index.ts +1 -0
- package/src/logging/ToolLogger.test.ts +753 -0
- package/src/logging/ToolLogger.ts +177 -0
- package/src/service/Service.test.ts +103 -2
- package/src/service/Service.ts +45 -17
- 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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { logger, LogVisibility } from '@zaiusinc/app-sdk';
|
|
2
|
+
import * as App from '@zaiusinc/app-sdk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Utility class for logging Opal tool requests and responses with security considerations
|
|
6
|
+
*/
|
|
7
|
+
export class ToolLogger {
|
|
8
|
+
private static readonly SENSITIVE_FIELDS = [
|
|
9
|
+
// Authentication / secrets
|
|
10
|
+
'password',
|
|
11
|
+
'pass',
|
|
12
|
+
'secret',
|
|
13
|
+
'key',
|
|
14
|
+
'token',
|
|
15
|
+
'auth',
|
|
16
|
+
'credentials',
|
|
17
|
+
'access_token',
|
|
18
|
+
'refresh_token',
|
|
19
|
+
'api_key',
|
|
20
|
+
'private_key',
|
|
21
|
+
'client_secret',
|
|
22
|
+
'session_token',
|
|
23
|
+
'authorization',
|
|
24
|
+
|
|
25
|
+
// Payment-related
|
|
26
|
+
'card_number',
|
|
27
|
+
'credit_card',
|
|
28
|
+
'cvv',
|
|
29
|
+
'expiry_date',
|
|
30
|
+
|
|
31
|
+
// Personal info
|
|
32
|
+
'ssn', // social security number
|
|
33
|
+
'nid', // national ID
|
|
34
|
+
'passport',
|
|
35
|
+
'dob', // date of birth
|
|
36
|
+
'email',
|
|
37
|
+
'phone',
|
|
38
|
+
'address',
|
|
39
|
+
|
|
40
|
+
// Misc / environment
|
|
41
|
+
'otp',
|
|
42
|
+
'pin',
|
|
43
|
+
'security_answer',
|
|
44
|
+
'security_question',
|
|
45
|
+
'signing_key',
|
|
46
|
+
'encryption_key',
|
|
47
|
+
'jwt',
|
|
48
|
+
'bearer_token'
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
private static readonly MAX_PARAM_LENGTH = 100;
|
|
52
|
+
private static readonly MAX_ARRAY_ITEMS = 10;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Redacts sensitive data from an object
|
|
56
|
+
*/
|
|
57
|
+
private static redactSensitiveData(data: any, maxDepth = 5): any {
|
|
58
|
+
if (maxDepth <= 0) {
|
|
59
|
+
return '[MAX_DEPTH_EXCEEDED]';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (data === null || data === undefined) {
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (typeof data === 'string') {
|
|
67
|
+
return data.length > this.MAX_PARAM_LENGTH
|
|
68
|
+
? `${data.substring(0, this.MAX_PARAM_LENGTH)}... (truncated, ${data.length} chars total)`
|
|
69
|
+
: data;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof data === 'number' || typeof data === 'boolean') {
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(data)) {
|
|
77
|
+
const truncated = data.slice(0, this.MAX_ARRAY_ITEMS);
|
|
78
|
+
const result = truncated.map((item) => this.redactSensitiveData(item, maxDepth - 1));
|
|
79
|
+
if (data.length > this.MAX_ARRAY_ITEMS) {
|
|
80
|
+
result.push(`... (${data.length - this.MAX_ARRAY_ITEMS} more items truncated)`);
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (typeof data === 'object') {
|
|
86
|
+
const result: any = {};
|
|
87
|
+
for (const [key, value] of Object.entries(data)) {
|
|
88
|
+
// Check if this field contains sensitive data
|
|
89
|
+
const isSensitive = this.isSensitiveField(key);
|
|
90
|
+
|
|
91
|
+
if (isSensitive) {
|
|
92
|
+
result[key] = '[REDACTED]';
|
|
93
|
+
} else {
|
|
94
|
+
result[key] = this.redactSensitiveData(value, maxDepth - 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Checks if a field name is considered sensitive
|
|
105
|
+
*/
|
|
106
|
+
private static isSensitiveField(fieldName: string): boolean {
|
|
107
|
+
const lowerKey = fieldName.toLowerCase();
|
|
108
|
+
return this.SENSITIVE_FIELDS.some((sensitiveField) =>
|
|
109
|
+
lowerKey.includes(sensitiveField)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Creates a summary of request parameters
|
|
115
|
+
*/
|
|
116
|
+
private static createParameterSummary(params: any): any {
|
|
117
|
+
if (!params) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return this.redactSensitiveData(params);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Calculates content length of response data
|
|
126
|
+
*/
|
|
127
|
+
private static calculateContentLength(response?: App.Response) {
|
|
128
|
+
if (!response) {
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
return response.bodyAsU8Array?.length || 'unknown';
|
|
134
|
+
} catch {
|
|
135
|
+
return 'unknown';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Logs an incoming request
|
|
141
|
+
*/
|
|
142
|
+
public static logRequest(
|
|
143
|
+
req: App.Request,
|
|
144
|
+
): void {
|
|
145
|
+
const params = req.bodyJSON && req.bodyJSON.parameters ? req.bodyJSON.parameters : req.bodyJSON;
|
|
146
|
+
const requestLog = {
|
|
147
|
+
event: 'opal_tool_request',
|
|
148
|
+
path: req.path,
|
|
149
|
+
parameters: this.createParameterSummary(params)
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Log with Zaius audience so developers only see requests for accounts they have access to
|
|
153
|
+
logger.info(LogVisibility.Zaius, JSON.stringify(requestLog));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Logs a successful response
|
|
158
|
+
*/
|
|
159
|
+
public static logResponse(
|
|
160
|
+
req: App.Request,
|
|
161
|
+
response: App.Response,
|
|
162
|
+
processingTimeMs?: number
|
|
163
|
+
): void {
|
|
164
|
+
const responseLog = {
|
|
165
|
+
event: 'opal_tool_response',
|
|
166
|
+
path: req.path,
|
|
167
|
+
duration: processingTimeMs ? `${processingTimeMs}ms` : undefined,
|
|
168
|
+
status: response.status,
|
|
169
|
+
contentType: response.headers?.get('content-type') || 'unknown',
|
|
170
|
+
contentLength: this.calculateContentLength(response),
|
|
171
|
+
success: response.status >= 200 && response.status < 300
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Log with Zaius audience so developers only see requests for accounts they have access to
|
|
175
|
+
logger.info(LogVisibility.Zaius, JSON.stringify(responseLog));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { toolsService, Tool, Interaction } from './Service';
|
|
2
2
|
import { Parameter, ParameterType, AuthRequirement, OptiIdAuthDataCredentials, OptiIdAuthData } from '../types/Models';
|
|
3
|
+
import { ToolError } from '../types/ToolError';
|
|
3
4
|
import { ToolFunction } from '../function/ToolFunction';
|
|
4
5
|
import { logger } from '@zaiusinc/app-sdk';
|
|
5
6
|
|
|
@@ -370,7 +371,7 @@ describe('ToolsService', () => {
|
|
|
370
371
|
);
|
|
371
372
|
});
|
|
372
373
|
|
|
373
|
-
it('should return 500 error when tool handler throws
|
|
374
|
+
it('should return 500 error in RFC 9457 format when tool handler throws a regular error', async () => {
|
|
374
375
|
const errorMessage = 'Tool execution failed';
|
|
375
376
|
jest.mocked(mockTool.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
376
377
|
|
|
@@ -378,6 +379,13 @@ describe('ToolsService', () => {
|
|
|
378
379
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
379
380
|
|
|
380
381
|
expect(response.status).toBe(500);
|
|
382
|
+
expect(response.bodyJSON).toEqual({
|
|
383
|
+
title: 'Internal Server Error',
|
|
384
|
+
status: 500,
|
|
385
|
+
detail: errorMessage,
|
|
386
|
+
instance: mockTool.endpoint
|
|
387
|
+
});
|
|
388
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
381
389
|
expect(logger.error).toHaveBeenCalledWith(
|
|
382
390
|
`Error in function ${mockTool.name}:`,
|
|
383
391
|
expect.any(Error)
|
|
@@ -391,6 +399,67 @@ describe('ToolsService', () => {
|
|
|
391
399
|
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
392
400
|
|
|
393
401
|
expect(response.status).toBe(500);
|
|
402
|
+
expect(response.bodyJSON).toEqual({
|
|
403
|
+
title: 'Internal Server Error',
|
|
404
|
+
status: 500,
|
|
405
|
+
detail: 'An unexpected error occurred',
|
|
406
|
+
instance: mockTool.endpoint
|
|
407
|
+
});
|
|
408
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('should return custom status code when tool handler throws ToolError', async () => {
|
|
412
|
+
const toolError = new ToolError('Resource not found', 404, 'The requested task does not exist');
|
|
413
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
414
|
+
|
|
415
|
+
const mockRequest = createMockRequest();
|
|
416
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
417
|
+
|
|
418
|
+
expect(response.status).toBe(404);
|
|
419
|
+
expect(response.bodyJSON).toEqual({
|
|
420
|
+
title: 'Resource not found',
|
|
421
|
+
status: 404,
|
|
422
|
+
detail: 'The requested task does not exist',
|
|
423
|
+
instance: mockTool.endpoint
|
|
424
|
+
});
|
|
425
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
426
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
427
|
+
`Error in function ${mockTool.name}:`,
|
|
428
|
+
expect.any(ToolError)
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should return ToolError without detail field when detail is not provided', async () => {
|
|
433
|
+
const toolError = new ToolError('Bad request', 400);
|
|
434
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
435
|
+
|
|
436
|
+
const mockRequest = createMockRequest();
|
|
437
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
438
|
+
|
|
439
|
+
expect(response.status).toBe(400);
|
|
440
|
+
expect(response.bodyJSON).toEqual({
|
|
441
|
+
title: 'Bad request',
|
|
442
|
+
status: 400,
|
|
443
|
+
instance: mockTool.endpoint
|
|
444
|
+
});
|
|
445
|
+
expect(response.bodyJSON).not.toHaveProperty('detail');
|
|
446
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('should default to 500 when ToolError is created without status', async () => {
|
|
450
|
+
const toolError = new ToolError('Database error');
|
|
451
|
+
jest.mocked(mockTool.handler).mockRejectedValueOnce(toolError);
|
|
452
|
+
|
|
453
|
+
const mockRequest = createMockRequest();
|
|
454
|
+
const response = await toolsService.processRequest(mockRequest, mockToolFunction);
|
|
455
|
+
|
|
456
|
+
expect(response.status).toBe(500);
|
|
457
|
+
expect(response.bodyJSON).toEqual({
|
|
458
|
+
title: 'Database error',
|
|
459
|
+
status: 500,
|
|
460
|
+
instance: mockTool.endpoint
|
|
461
|
+
});
|
|
462
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
394
463
|
});
|
|
395
464
|
});
|
|
396
465
|
|
|
@@ -488,7 +557,7 @@ describe('ToolsService', () => {
|
|
|
488
557
|
);
|
|
489
558
|
});
|
|
490
559
|
|
|
491
|
-
it('should return 500 error when interaction handler throws
|
|
560
|
+
it('should return 500 error in RFC 9457 format when interaction handler throws a regular error', async () => {
|
|
492
561
|
const errorMessage = 'Interaction execution failed';
|
|
493
562
|
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(new Error(errorMessage));
|
|
494
563
|
|
|
@@ -500,11 +569,43 @@ describe('ToolsService', () => {
|
|
|
500
569
|
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
501
570
|
|
|
502
571
|
expect(response.status).toBe(500);
|
|
572
|
+
expect(response.bodyJSON).toEqual({
|
|
573
|
+
title: 'Internal Server Error',
|
|
574
|
+
status: 500,
|
|
575
|
+
detail: errorMessage,
|
|
576
|
+
instance: mockInteraction.endpoint
|
|
577
|
+
});
|
|
578
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
503
579
|
expect(logger.error).toHaveBeenCalledWith(
|
|
504
580
|
`Error in function ${mockInteraction.name}:`,
|
|
505
581
|
expect.any(Error)
|
|
506
582
|
);
|
|
507
583
|
});
|
|
584
|
+
|
|
585
|
+
it('should return custom status code when interaction handler throws ToolError', async () => {
|
|
586
|
+
const toolError = new ToolError('Webhook validation failed', 400, 'Invalid signature');
|
|
587
|
+
jest.mocked(mockInteraction.handler).mockRejectedValueOnce(toolError);
|
|
588
|
+
|
|
589
|
+
const interactionRequest = createMockRequest({
|
|
590
|
+
path: '/test-interaction',
|
|
591
|
+
bodyJSON: { data: { param1: 'test-value' } }
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const response = await toolsService.processRequest(interactionRequest, mockToolFunction);
|
|
595
|
+
|
|
596
|
+
expect(response.status).toBe(400);
|
|
597
|
+
expect(response.bodyJSON).toEqual({
|
|
598
|
+
title: 'Webhook validation failed',
|
|
599
|
+
status: 400,
|
|
600
|
+
detail: 'Invalid signature',
|
|
601
|
+
instance: mockInteraction.endpoint
|
|
602
|
+
});
|
|
603
|
+
expect(response.headers.get('content-type')).toBe('application/problem+json');
|
|
604
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
605
|
+
`Error in function ${mockInteraction.name}:`,
|
|
606
|
+
expect.any(ToolError)
|
|
607
|
+
);
|
|
608
|
+
});
|
|
508
609
|
});
|
|
509
610
|
|
|
510
611
|
describe('error cases', () => {
|
package/src/service/Service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
5
|
import { logger, Headers } from '@zaiusinc/app-sdk';
|
|
5
6
|
import { ToolFunction } from '../function/ToolFunction';
|
|
@@ -11,13 +12,20 @@ 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
|
+
|
|
14
20
|
/**
|
|
15
21
|
* Result type for interaction handlers
|
|
16
22
|
*/
|
|
17
23
|
export class InteractionResult {
|
|
18
24
|
public constructor(
|
|
19
25
|
public message: string,
|
|
20
|
-
public link?: string
|
|
26
|
+
public link?: string,
|
|
27
|
+
public dispatch_event?: boolean,
|
|
28
|
+
public interactions?: NestedInteractions
|
|
21
29
|
) {}
|
|
22
30
|
}
|
|
23
31
|
|
|
@@ -104,6 +112,37 @@ export class ToolsService {
|
|
|
104
112
|
return [...(authRequirements || []), DEFAULT_OPTIID_AUTH];
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Format an error as RFC 9457 Problem Details response
|
|
117
|
+
* @param error The error to format
|
|
118
|
+
* @param instance URI reference identifying the specific occurrence
|
|
119
|
+
* @returns RFC 9457 compliant Response
|
|
120
|
+
*/
|
|
121
|
+
private formatErrorResponse(error: any, instance: string): App.Response {
|
|
122
|
+
let status = 500;
|
|
123
|
+
let problemDetails: Record<string, unknown>;
|
|
124
|
+
|
|
125
|
+
if (error instanceof ToolError) {
|
|
126
|
+
// Use ToolError's status and format
|
|
127
|
+
status = error.status;
|
|
128
|
+
problemDetails = error.toProblemDetails(instance);
|
|
129
|
+
} else {
|
|
130
|
+
// Convert regular errors to RFC 9457 format with 500 status
|
|
131
|
+
problemDetails = {
|
|
132
|
+
title: 'Internal Server Error',
|
|
133
|
+
status: 500,
|
|
134
|
+
detail: error.message || 'An unexpected error occurred',
|
|
135
|
+
instance
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return new App.Response(
|
|
140
|
+
status,
|
|
141
|
+
problemDetails,
|
|
142
|
+
new Headers([['content-type', 'application/problem+json']])
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
107
146
|
/**
|
|
108
147
|
* Register a tool function with generic auth data
|
|
109
148
|
* @param name Tool name
|
|
@@ -175,20 +214,9 @@ export class ToolsService {
|
|
|
175
214
|
}
|
|
176
215
|
|
|
177
216
|
// Validate parameters before calling the handler (only if tool has parameter definitions)
|
|
217
|
+
// ParameterValidator.validate() throws ToolError if validation fails
|
|
178
218
|
if (func.parameters && func.parameters.length > 0) {
|
|
179
|
-
|
|
180
|
-
if (!validationResult.isValid) {
|
|
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
|
-
}
|
|
219
|
+
ParameterValidator.validate(params, func.parameters, func.endpoint);
|
|
192
220
|
}
|
|
193
221
|
|
|
194
222
|
// Extract auth data from body JSON
|
|
@@ -198,7 +226,7 @@ export class ToolsService {
|
|
|
198
226
|
return new App.Response(200, result);
|
|
199
227
|
} catch (error: any) {
|
|
200
228
|
logger.error(`Error in function ${func.name}:`, error);
|
|
201
|
-
return
|
|
229
|
+
return this.formatErrorResponse(error, func.endpoint);
|
|
202
230
|
}
|
|
203
231
|
}
|
|
204
232
|
|
|
@@ -219,7 +247,7 @@ export class ToolsService {
|
|
|
219
247
|
return new App.Response(200, result);
|
|
220
248
|
} catch (error: any) {
|
|
221
249
|
logger.error(`Error in function ${interaction.name}:`, error);
|
|
222
|
-
return
|
|
250
|
+
return this.formatErrorResponse(error, interaction.endpoint);
|
|
223
251
|
}
|
|
224
252
|
}
|
|
225
253
|
|
|
@@ -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
|
+
});
|