Skip to content

Commit 53bce87

Browse files
ryoppippiclaude
andauthored
refactor: flatten client structure and add Zod validation to RPC client (#168)
* feat!: replace @stackone/stackone-client-ts with custom RPC client Remove the external TypeScript client SDK dependency and implement a custom RPC client for the StackOne API. This reduces dependencies and gives full control over the API client implementation. Changes: - Add src/client/rpc-client.ts with RpcClient class - Update toolsets/base.ts to use new RpcClient instead of StackOne - Update tests to mock RpcClient instead of StackOne - Add comprehensive tests for the new RPC client using MSW - Add MSW handler for /actions/rpc endpoint - Remove @stackone/stackone-client-ts from dependencies BREAKING CHANGE: BaseToolSetConfig.stackOneClient is now rpcClient * refactor(client): flatten directory structure - Merge rpc-client.ts implementation into client/index.ts - Rename tests/rpc-client.spec.ts to index.spec.ts - Remove empty client/tests/ directory - Update import paths in test file * refactor: flatten client directory structure - Move src/client/index.ts to src/rpc-client.ts - Move src/client/index.spec.ts to src/rpc-client.spec.ts - Rename src/mcp.ts to src/mcp-client.ts - Update import paths in base.ts and test files - Remove src/client/ directory This simplifies the directory structure by placing small client modules at the src/ root level rather than in nested directories. * refactor(tests): remove redundant outer describe block in rpc-client.spec.ts * refactor(tests): remove useless constructor tests from rpc-client.spec.ts * refactor(tests): remove describe block and use test() instead of it() * feat(rpc-client): add Zod validation for request/response schemas - Add Zod schemas for RpcActionRequest, RpcActionResponse, and RpcClientConfig - Validate config in constructor and request in rpcAction - Validate response body before returning - Use catalog:dev for zod dependency - Remove duplicate catalog entry from pnpm-workspace.yaml * refactor(tests): replace type assertions with toMatchObject in rpc-client tests - Use toMatchObject for structural validation instead of 'as' type assertions - Use expect.any(String) for dynamic values - Use rejects.toMatchObject for error property validation - Remove all type casting from test file * refactor(rpc-client): use safeParse and improve type safety - Use safeParse instead of parse for response validation - Throw StackOneAPIError with context on validation failure - Add 'as const satisfies' for requestBody type narrowing - Remove type assertion from return value * docs(rpc-client): add API reference links to RpcClient class * fix: lint errors - sort imports and remove unused JsonDict import * refactor(tests): use nullish coalescing (??) instead of logical OR (||) * fix(rpc-client): align response schema with server's ActionsRpcResponseApiModel The previous implementation wrapped the API response in an artificial `actionsRpcResponse` property which didn't match the actual server response structure from unified-cloud-api. Changes: - Update RpcActionResponseSchema to match server's ActionsRpcResponseApiModel: - `data`: object, array of objects, or null - `next`: optional pagination cursor - Use passthrough() to allow additional connector-specific fields - Fix headers type from Record<string, string> to Record<string, unknown> to match server's ActionsRpcRequestDto - Remove artificial wrapping of response in `actionsRpcResponse` property - Add explicit `unknown` type annotation to response.json() for type safety - Export RpcActionResponseData type for consumers needing the data shape This enables proper type inference without type assertions when consuming the RPC client response. Refs: unified-cloud-api/src/modules/actions/models/actions-rpc-response.api.model.ts * refactor(toolsets): eliminate type assertion with type-safe conversion Replace `as JsonDict` type assertion with explicit conversion function `rpcResponseToJsonDict()` that iterates over response properties. The RpcActionResponse type uses z.passthrough() which preserves additional fields beyond `data` and `next`. This makes it structurally compatible with Record<string, unknown>, but TypeScript's type system requires explicit conversion to maintain type safety. This change ensures no implicit `any` or unsafe type assertions are used when handling RPC responses in the toolset. * test(rpc-client): update tests for correct response structure Update test expectations to match the actual server response structure where `data` is a direct property of the response, not nested under `actionsRpcResponse`. Changes: - Access response.data directly instead of response.actionsRpcResponse - Add explicit assertions for response data content - Rename test to clarify array data handling - Add comments explaining response structure alignment with server --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 12d3e35 commit 53bce87

File tree

8 files changed

+353
-31
lines changed

8 files changed

+353
-31
lines changed

mocks/handlers.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,77 @@ export const handlers = [
214214
});
215215
}),
216216

217+
// ============================================================
218+
// StackOne Actions RPC endpoint
219+
// ============================================================
220+
http.post('https://api.stackone.com/actions/rpc', async ({ request }) => {
221+
const authHeader = request.headers.get('Authorization');
222+
223+
// Check for authentication
224+
if (!authHeader || !authHeader.startsWith('Basic ')) {
225+
return HttpResponse.json(
226+
{ error: 'Unauthorized', message: 'Missing or invalid authorization header' },
227+
{ status: 401 }
228+
);
229+
}
230+
231+
const body = (await request.json()) as {
232+
action?: string;
233+
body?: Record<string, unknown>;
234+
headers?: Record<string, string>;
235+
path?: Record<string, string>;
236+
query?: Record<string, string>;
237+
};
238+
239+
// Validate action is provided
240+
if (!body.action) {
241+
return HttpResponse.json(
242+
{ error: 'Bad Request', message: 'Action is required' },
243+
{ status: 400 }
244+
);
245+
}
246+
247+
// Return mock response based on action
248+
if (body.action === 'hris_get_employee') {
249+
return HttpResponse.json({
250+
data: {
251+
id: body.path?.id || 'test-id',
252+
name: 'Test Employee',
253+
...(body.body || {}),
254+
},
255+
});
256+
}
257+
258+
if (body.action === 'hris_list_employees') {
259+
return HttpResponse.json({
260+
data: [
261+
{ id: '1', name: 'Employee 1' },
262+
{ id: '2', name: 'Employee 2' },
263+
],
264+
});
265+
}
266+
267+
if (body.action === 'test_error_action') {
268+
return HttpResponse.json(
269+
{ error: 'Internal Server Error', message: 'Test error response' },
270+
{ status: 500 }
271+
);
272+
}
273+
274+
// Default response for other actions
275+
return HttpResponse.json({
276+
data: {
277+
action: body.action,
278+
received: {
279+
body: body.body,
280+
headers: body.headers,
281+
path: body.path,
282+
query: body.query,
283+
},
284+
},
285+
});
286+
}),
287+
217288
// ============================================================
218289
// StackOne Unified HRIS endpoints
219290
// ============================================================

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
"dependencies": {
3434
"@modelcontextprotocol/sdk": "catalog:prod",
3535
"@orama/orama": "catalog:prod",
36-
"@stackone/stackone-client-ts": "catalog:prod",
3736
"json-schema": "catalog:prod"
3837
},
3938
"devDependencies": {

pnpm-lock.yaml

Lines changed: 0 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,12 @@ catalogs:
3232
prod:
3333
'@modelcontextprotocol/sdk': ^1.19.1
3434
'@orama/orama': ^3.1.11
35-
'@stackone/stackone-client-ts': 4.32.2
3635
json-schema: ^0.4.0
3736

3837
enablePrePostScripts: true
3938

4039
minimumReleaseAge: 1440
4140

42-
minimumReleaseAgeExclude:
43-
- '@stackone/stackone-client-ts'
44-
4541
onlyBuiltDependencies:
4642
- '@biomejs/biome'
4743
- esbuild
File renamed without changes.

src/rpc-client.spec.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { RpcClient } from './rpc-client';
2+
import { StackOneAPIError } from './utils/errors';
3+
4+
test('should successfully execute an RPC action', async () => {
5+
const client = new RpcClient({
6+
security: { username: 'test-api-key' },
7+
});
8+
9+
const response = await client.actions.rpcAction({
10+
action: 'hris_get_employee',
11+
body: { fields: 'name,email' },
12+
path: { id: 'emp-123' },
13+
});
14+
15+
// Response matches server's ActionsRpcResponseApiModel structure
16+
expect(response).toHaveProperty('data');
17+
expect(response.data).toMatchObject({
18+
id: 'emp-123',
19+
name: 'Test Employee',
20+
});
21+
});
22+
23+
test('should send correct payload structure', async () => {
24+
const client = new RpcClient({
25+
security: { username: 'test-api-key' },
26+
});
27+
28+
const response = await client.actions.rpcAction({
29+
action: 'custom_action',
30+
body: { key: 'value' },
31+
headers: { 'x-custom': 'header' },
32+
path: { id: '123' },
33+
query: { filter: 'active' },
34+
});
35+
36+
// Response matches server's ActionsRpcResponseApiModel structure
37+
expect(response.data).toMatchObject({
38+
action: 'custom_action',
39+
received: {
40+
body: { key: 'value' },
41+
headers: { 'x-custom': 'header' },
42+
path: { id: '123' },
43+
query: { filter: 'active' },
44+
},
45+
});
46+
});
47+
48+
test('should handle list actions with array data', async () => {
49+
const client = new RpcClient({
50+
security: { username: 'test-api-key' },
51+
});
52+
53+
const response = await client.actions.rpcAction({
54+
action: 'hris_list_employees',
55+
});
56+
57+
// Response data can be an array (matches RpcActionResponseData union type)
58+
expect(Array.isArray(response.data)).toBe(true);
59+
expect(response.data).toMatchObject([
60+
{ id: expect.any(String), name: expect.any(String) },
61+
{ id: expect.any(String), name: expect.any(String) },
62+
]);
63+
});
64+
65+
test('should throw StackOneAPIError on server error', async () => {
66+
const client = new RpcClient({
67+
security: { username: 'test-api-key' },
68+
});
69+
70+
await expect(
71+
client.actions.rpcAction({
72+
action: 'test_error_action',
73+
})
74+
).rejects.toThrow(StackOneAPIError);
75+
});
76+
77+
test('should include request body in error for debugging', async () => {
78+
const client = new RpcClient({
79+
security: { username: 'test-api-key' },
80+
});
81+
82+
await expect(
83+
client.actions.rpcAction({
84+
action: 'test_error_action',
85+
body: { debug: 'data' },
86+
})
87+
).rejects.toMatchObject({
88+
statusCode: 500,
89+
requestBody: { action: 'test_error_action' },
90+
});
91+
});
92+
93+
test('should work with only action parameter', async () => {
94+
const client = new RpcClient({
95+
security: { username: 'test-api-key' },
96+
});
97+
98+
const response = await client.actions.rpcAction({
99+
action: 'simple_action',
100+
});
101+
102+
// Response has data field (server returns { data: { action, received } })
103+
expect(response).toHaveProperty('data');
104+
});

src/rpc-client.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { z } from 'zod';
2+
import { StackOneAPIError } from './utils/errors';
3+
4+
/**
5+
* Zod schema for RPC action request validation
6+
* @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
7+
*/
8+
const rpcActionRequestSchema = z.object({
9+
action: z.string(),
10+
body: z.record(z.unknown()).optional(),
11+
headers: z.record(z.unknown()).optional(),
12+
path: z.record(z.unknown()).optional(),
13+
query: z.record(z.unknown()).optional(),
14+
});
15+
16+
/**
17+
* RPC action request payload
18+
*/
19+
export type RpcActionRequest = z.infer<typeof rpcActionRequestSchema>;
20+
21+
/**
22+
* Zod schema for RPC action response data
23+
*/
24+
const rpcActionResponseDataSchema = z.union([
25+
z.record(z.unknown()),
26+
z.array(z.record(z.unknown())),
27+
z.null(),
28+
]);
29+
30+
/**
31+
* Zod schema for RPC action response validation
32+
*
33+
* The server returns a flexible JSON structure. Known fields:
34+
* - `data`: The main response data (object, array, or null)
35+
* - `next`: Pagination cursor for fetching next page
36+
*
37+
* Additional fields from the connector response are passed through.
38+
* @see unified-cloud-api/src/unified-api-v2/unifiedAPIv2.service.ts processActionCall
39+
*/
40+
const rpcActionResponseSchema = z
41+
.object({
42+
next: z.string().nullish(),
43+
data: rpcActionResponseDataSchema.optional(),
44+
})
45+
.passthrough();
46+
47+
/**
48+
* RPC action response data type - can be object, array of objects, or null
49+
*/
50+
export type RpcActionResponseData = z.infer<typeof rpcActionResponseDataSchema>;
51+
52+
/**
53+
* RPC action response from the StackOne API
54+
* Contains known fields (data, next) plus any additional fields from the connector
55+
*/
56+
export type RpcActionResponse = z.infer<typeof rpcActionResponseSchema>;
57+
58+
/**
59+
* Zod schema for RPC client configuration validation
60+
*/
61+
const rpcClientConfigSchema = z.object({
62+
serverURL: z.string().optional(),
63+
security: z.object({
64+
username: z.string(),
65+
password: z.string().optional(),
66+
}),
67+
});
68+
69+
/**
70+
* Configuration for the RPC client
71+
*/
72+
export type RpcClientConfig = z.infer<typeof rpcClientConfigSchema>;
73+
74+
/**
75+
* Custom RPC client for StackOne API.
76+
* Replaces the @stackone/stackone-client-ts dependency.
77+
*
78+
* @see https://docs.stackone.com/platform/api-reference/actions/list-all-actions-metadata
79+
* @see https://docs.stackone.com/platform/api-reference/actions/make-an-rpc-call-to-an-action
80+
*/
81+
export class RpcClient {
82+
private readonly baseUrl: string;
83+
private readonly authHeader: string;
84+
85+
constructor(config: RpcClientConfig) {
86+
const validatedConfig = rpcClientConfigSchema.parse(config);
87+
this.baseUrl = validatedConfig.serverURL || 'https://api.stackone.com';
88+
const username = validatedConfig.security.username;
89+
const password = validatedConfig.security.password || '';
90+
this.authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
91+
}
92+
93+
/**
94+
* Actions namespace containing RPC methods
95+
*/
96+
readonly actions = {
97+
/**
98+
* Execute an RPC action
99+
* @param request The RPC action request
100+
* @returns The RPC action response matching server's ActionsRpcResponseApiModel
101+
*/
102+
rpcAction: async (request: RpcActionRequest): Promise<RpcActionResponse> => {
103+
const validatedRequest = rpcActionRequestSchema.parse(request);
104+
const url = `${this.baseUrl}/actions/rpc`;
105+
106+
const requestBody = {
107+
action: validatedRequest.action,
108+
body: validatedRequest.body,
109+
headers: validatedRequest.headers,
110+
path: validatedRequest.path,
111+
query: validatedRequest.query,
112+
} as const satisfies RpcActionRequest;
113+
114+
const response = await fetch(url, {
115+
method: 'POST',
116+
headers: {
117+
'Content-Type': 'application/json',
118+
Authorization: this.authHeader,
119+
'User-Agent': 'stackone-ai-node',
120+
},
121+
body: JSON.stringify(requestBody),
122+
});
123+
124+
const responseBody: unknown = await response.json();
125+
126+
if (!response.ok) {
127+
throw new StackOneAPIError(
128+
`RPC action failed for ${url}`,
129+
response.status,
130+
responseBody,
131+
requestBody
132+
);
133+
}
134+
135+
const validation = rpcActionResponseSchema.safeParse(responseBody);
136+
137+
if (!validation.success) {
138+
throw new StackOneAPIError(
139+
`Invalid RPC action response for ${url}`,
140+
response.status,
141+
responseBody,
142+
requestBody
143+
);
144+
}
145+
146+
return validation.data;
147+
},
148+
};
149+
}

0 commit comments

Comments
 (0)