@optimizely-opal/opal-tool-ocp-sdk 1.0.0-beta.3 → 1.0.0-beta.5

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 (57) hide show
  1. package/README.md +114 -0
  2. package/dist/auth/AuthUtils.d.ts +5 -5
  3. package/dist/auth/AuthUtils.d.ts.map +1 -1
  4. package/dist/auth/AuthUtils.js +53 -25
  5. package/dist/auth/AuthUtils.js.map +1 -1
  6. package/dist/auth/AuthUtils.test.js +62 -117
  7. package/dist/auth/AuthUtils.test.js.map +1 -1
  8. package/dist/function/GlobalToolFunction.d.ts +1 -1
  9. package/dist/function/GlobalToolFunction.d.ts.map +1 -1
  10. package/dist/function/GlobalToolFunction.js +17 -4
  11. package/dist/function/GlobalToolFunction.js.map +1 -1
  12. package/dist/function/GlobalToolFunction.test.js +54 -8
  13. package/dist/function/GlobalToolFunction.test.js.map +1 -1
  14. package/dist/function/ToolFunction.d.ts +1 -1
  15. package/dist/function/ToolFunction.d.ts.map +1 -1
  16. package/dist/function/ToolFunction.js +17 -4
  17. package/dist/function/ToolFunction.js.map +1 -1
  18. package/dist/function/ToolFunction.test.js +54 -8
  19. package/dist/function/ToolFunction.test.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/service/Service.d.ts +15 -2
  25. package/dist/service/Service.d.ts.map +1 -1
  26. package/dist/service/Service.js +43 -17
  27. package/dist/service/Service.js.map +1 -1
  28. package/dist/service/Service.test.js +84 -2
  29. package/dist/service/Service.test.js.map +1 -1
  30. package/dist/types/ToolError.d.ts +72 -0
  31. package/dist/types/ToolError.d.ts.map +1 -0
  32. package/dist/types/ToolError.js +107 -0
  33. package/dist/types/ToolError.js.map +1 -0
  34. package/dist/types/ToolError.test.d.ts +2 -0
  35. package/dist/types/ToolError.test.d.ts.map +1 -0
  36. package/dist/types/ToolError.test.js +185 -0
  37. package/dist/types/ToolError.test.js.map +1 -0
  38. package/dist/validation/ParameterValidator.d.ts +5 -16
  39. package/dist/validation/ParameterValidator.d.ts.map +1 -1
  40. package/dist/validation/ParameterValidator.js +10 -3
  41. package/dist/validation/ParameterValidator.js.map +1 -1
  42. package/dist/validation/ParameterValidator.test.js +187 -146
  43. package/dist/validation/ParameterValidator.test.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/auth/AuthUtils.test.ts +62 -157
  46. package/src/auth/AuthUtils.ts +66 -32
  47. package/src/function/GlobalToolFunction.test.ts +54 -8
  48. package/src/function/GlobalToolFunction.ts +26 -6
  49. package/src/function/ToolFunction.test.ts +54 -8
  50. package/src/function/ToolFunction.ts +26 -6
  51. package/src/index.ts +1 -0
  52. package/src/service/Service.test.ts +103 -2
  53. package/src/service/Service.ts +45 -17
  54. package/src/types/ToolError.test.ts +222 -0
  55. package/src/types/ToolError.ts +125 -0
  56. package/src/validation/ParameterValidator.test.ts +188 -158
  57. package/src/validation/ParameterValidator.ts +17 -20
@@ -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
- const validationResult = ParameterValidator.validate(params, func.parameters);
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 new App.Response(500, { error: error.message || 'Unknown error' });
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 new App.Response(500, { error: error.message || 'Unknown error' });
250
+ return this.formatErrorResponse(error, interaction.endpoint);
223
251
  }
224
252
  }
225
253
 
@@ -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
+ });
@@ -0,0 +1,125 @@
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
+ * The error message includes status code and details for better logging:
13
+ * - No detail: "[status] message"
14
+ * - With detail: "[status] message: detail"
15
+ * - With errors: "[status] message: field1 (error1); field2 (error2)"
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * // Throw a 404 error
20
+ * // Message: "[404] Resource not found: The requested task does not exist"
21
+ * throw new ToolError('Resource not found', 404, 'The requested task does not exist');
22
+ *
23
+ * // Throw a 400 error
24
+ * // Message: "[400] Invalid input: The priority must be "low", "medium", or "high""
25
+ * throw new ToolError('Invalid input', 400, 'The priority must be "low", "medium", or "high"');
26
+ *
27
+ * // Throw a 500 error (default)
28
+ * // Message: "[500] Database connection failed"
29
+ * throw new ToolError('Database connection failed');
30
+ *
31
+ * // Throw a validation error with multiple field errors
32
+ * // Message: "[400] Validation failed: email (Invalid email format); age (Must be a positive number)"
33
+ * throw new ToolError('Validation failed', 400, "See 'errors' field for details.", [
34
+ * { field: 'email', message: 'Invalid email format' },
35
+ * { field: 'age', message: 'Must be a positive number' }
36
+ * ]);
37
+ * ```
38
+ */
39
+ export class ToolError extends Error {
40
+ /**
41
+ * HTTP status code for the error response
42
+ */
43
+ public readonly status: number;
44
+
45
+ /**
46
+ * Detailed error description (optional)
47
+ */
48
+ public readonly detail?: string;
49
+
50
+ /**
51
+ * Array of validation errors (optional)
52
+ */
53
+ public readonly errors?: ValidationError[];
54
+
55
+ /**
56
+ * The title field for RFC 9457 format (same as message when no detail is provided)
57
+ */
58
+ public readonly title: string;
59
+
60
+ /**
61
+ * Create a new ToolError
62
+ *
63
+ * @param message - Error message (used as RFC 9457 "title" field)
64
+ * @param status - HTTP status code (default: 500)
65
+ * @param detail - Detailed error description (optional)
66
+ * @param errors - Array of validation errors (optional)
67
+ */
68
+ public constructor(
69
+ message: string,
70
+ status: number = 500,
71
+ detail?: string,
72
+ errors?: ValidationError[]
73
+ ) {
74
+ // Build comprehensive error message for logging/debugging
75
+ // Format: [status] message: details
76
+ let fullMessage = `[${status}] ${message}`;
77
+
78
+ if (errors && errors.length > 0) {
79
+ // Errors take precedence: field1 (message1); field2 (message2)
80
+ const errorDetails = errors
81
+ .map((err) => `${err.field} (${err.message})`)
82
+ .join('; ');
83
+ fullMessage += `: ${errorDetails}`;
84
+ } else if (detail) {
85
+ // Include detail if no errors
86
+ fullMessage += `: ${detail}`;
87
+ }
88
+
89
+ super(fullMessage);
90
+ this.name = 'ToolError';
91
+ this.title = message;
92
+ this.status = status;
93
+ this.detail = detail;
94
+ this.errors = errors;
95
+
96
+ // Maintains proper stack trace for where error was thrown (V8 engines only)
97
+ if (Error.captureStackTrace) {
98
+ Error.captureStackTrace(this, ToolError);
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Convert error to RFC 9457 Problem Details format
104
+ *
105
+ * @param instance - URI reference identifying the specific occurrence of the problem
106
+ * @returns RFC 9457 compliant error object
107
+ */
108
+ public toProblemDetails(instance: string): Record<string, unknown> {
109
+ const problemDetails: Record<string, unknown> = {
110
+ title: this.title,
111
+ status: this.status,
112
+ instance
113
+ };
114
+
115
+ if (this.detail) {
116
+ problemDetails.detail = this.detail;
117
+ }
118
+
119
+ if (this.errors && this.errors.length > 0) {
120
+ problemDetails.errors = this.errors;
121
+ }
122
+
123
+ return problemDetails;
124
+ }
125
+ }