Skip to content

Commit 5de08c1

Browse files
authored
feat: Add support for map parameter type (#78)
* add map parameter to protocol * try and add tests * fix param name and type * lint * run tests on new version of tools * lint * test on latest toolbox version * fix protocol * rename additional properties * fix e2e tests * clean comment * fix test * remove wrong test * add unit tests * improve test coverage * lint * roll back accidental changes * Made check more robust * refactor to avoid code duplication * disable linter for a particular line * Update toolbox version * de capitalise additional properties * use zodv3 error messages * clean up param file * lint * test fix * small fixes * lint * remove description from additional properties
1 parent 6ed5348 commit 5de08c1

File tree

5 files changed

+252
-97
lines changed

5 files changed

+252
-97
lines changed

packages/toolbox-core/integration.cloudbuild.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,4 @@ options:
4242
logging: CLOUD_LOGGING_ONLY
4343
substitutions:
4444
_VERSION: '22.16.0'
45-
_TOOLBOX_VERSION: '0.10.0'
45+
_TOOLBOX_VERSION: '0.12.0'

packages/toolbox-core/src/toolbox_core/protocol.ts

Lines changed: 68 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -14,72 +14,72 @@
1414

1515
import {z, ZodRawShape, ZodTypeAny, ZodObject} from 'zod';
1616

17-
// Define All Interfaces
18-
19-
interface BaseParameter {
20-
name: string;
21-
description: string;
22-
authSources?: string[];
23-
required?: boolean;
24-
}
25-
26-
interface StringParameter extends BaseParameter {
17+
// Type Definitions
18+
interface StringType {
2719
type: 'string';
2820
}
29-
30-
interface IntegerParameter extends BaseParameter {
21+
interface IntegerType {
3122
type: 'integer';
3223
}
33-
34-
interface FloatParameter extends BaseParameter {
24+
interface FloatType {
3525
type: 'float';
3626
}
37-
38-
interface BooleanParameter extends BaseParameter {
27+
interface BooleanType {
3928
type: 'boolean';
4029
}
41-
42-
interface ArrayParameter extends BaseParameter {
30+
interface ArrayType {
4331
type: 'array';
44-
items: ParameterSchema; // Recursive reference to the ParameterSchema type
32+
items: TypeSchema; // Recursive
33+
}
34+
interface ObjectType {
35+
type: 'object';
36+
additionalProperties?: boolean | TypeSchema; // Recursive
4537
}
4638

47-
export type ParameterSchema =
48-
| StringParameter
49-
| IntegerParameter
50-
| FloatParameter
51-
| BooleanParameter
52-
| ArrayParameter;
39+
// Union of all pure type definitions.
40+
export type TypeSchema =
41+
| StringType
42+
| IntegerType
43+
| FloatType
44+
| BooleanType
45+
| ArrayType
46+
| ObjectType;
5347

54-
// Get all Zod schema types
48+
// The base properties of a named parameter.
49+
interface BaseParameter {
50+
name: string;
51+
description: string;
52+
authSources?: string[];
53+
required?: boolean;
54+
}
55+
56+
export type ParameterSchema = BaseParameter & TypeSchema;
5557

58+
// Zod schema for the pure type definitions. This must be lazy for recursion.
59+
const ZodTypeSchema: z.ZodType<TypeSchema> = z.lazy(() =>
60+
z.discriminatedUnion('type', [
61+
z.object({type: z.literal('string')}),
62+
z.object({type: z.literal('integer')}),
63+
z.object({type: z.literal('float')}),
64+
z.object({type: z.literal('boolean')}),
65+
z.object({type: z.literal('array'), items: ZodTypeSchema}),
66+
z.object({
67+
type: z.literal('object'),
68+
additionalProperties: z.union([z.boolean(), ZodTypeSchema]).optional(),
69+
}),
70+
]),
71+
);
72+
73+
// Zod schema for the base properties.
5674
const ZodBaseParameter = z.object({
5775
name: z.string().min(1, 'Parameter name cannot be empty'),
5876
description: z.string(),
5977
authSources: z.array(z.string()).optional(),
6078
required: z.boolean().optional(),
6179
});
6280

63-
export const ZodParameterSchema = z.lazy(() =>
64-
z.discriminatedUnion('type', [
65-
ZodBaseParameter.extend({
66-
type: z.literal('string'),
67-
}),
68-
ZodBaseParameter.extend({
69-
type: z.literal('integer'),
70-
}),
71-
ZodBaseParameter.extend({
72-
type: z.literal('float'),
73-
}),
74-
ZodBaseParameter.extend({
75-
type: z.literal('boolean'),
76-
}),
77-
ZodBaseParameter.extend({
78-
type: z.literal('array'),
79-
items: ZodParameterSchema, // Recursive reference for the item's definition
80-
}),
81-
]),
82-
) as z.ZodType<ParameterSchema>;
81+
export const ZodParameterSchema: z.ZodType<ParameterSchema> =
82+
ZodBaseParameter.and(ZodTypeSchema);
8383

8484
export const ZodToolSchema = z.object({
8585
description: z.string().min(1, 'Tool description cannot be empty'),
@@ -97,52 +97,44 @@ export const ZodManifestSchema = z.object({
9797

9898
export type ZodManifest = z.infer<typeof ZodManifestSchema>;
9999

100-
/**
101-
* Recursively builds a Zod schema for a single parameter based on its TypeScript definition.
102-
* @param param The ParameterSchema (TypeScript type) to convert.
103-
* @returns A ZodTypeAny representing the schema for this parameter.
104-
*/
105-
function buildZodShapeFromParam(param: ParameterSchema): ZodTypeAny {
106-
let schema: ZodTypeAny;
107-
switch (param.type) {
100+
function buildZodShapeFromTypeSchema(typeSchema: TypeSchema): ZodTypeAny {
101+
switch (typeSchema.type) {
108102
case 'string':
109-
schema = z.string();
110-
break;
103+
return z.string();
111104
case 'integer':
112-
schema = z.number().int();
113-
break;
105+
return z.number().int();
114106
case 'float':
115-
schema = z.number();
116-
break;
107+
return z.number();
117108
case 'boolean':
118-
schema = z.boolean();
119-
break;
109+
return z.boolean();
120110
case 'array':
121-
// Recursively build the schema for array items
122-
// Array items inherit the 'required' status of the parent array.
123-
param.items.required = param.required;
124-
schema = z.array(buildZodShapeFromParam(param.items));
125-
break;
111+
return z.array(buildZodShapeFromTypeSchema(typeSchema.items));
112+
case 'object':
113+
if (typeof typeSchema.additionalProperties === 'object') {
114+
return z.record(
115+
z.string(),
116+
buildZodShapeFromTypeSchema(typeSchema.additionalProperties),
117+
);
118+
} else if (typeSchema.additionalProperties === false) {
119+
return z.object({});
120+
} else {
121+
return z.record(z.string(), z.any());
122+
}
126123
default: {
127-
// This ensures exhaustiveness at compile time if ParameterSchema is a discriminated union
128-
const _exhaustiveCheck: never = param;
124+
const _exhaustiveCheck: never = typeSchema;
129125
throw new Error(`Unknown parameter type: ${_exhaustiveCheck['type']}`);
130126
}
131127
}
128+
}
132129

130+
function buildZodShapeFromParam(param: ParameterSchema): ZodTypeAny {
131+
const schema = buildZodShapeFromTypeSchema(param);
133132
if (param.required === false) {
134133
return schema.nullish();
135134
}
136-
137135
return schema;
138136
}
139137

140-
/**
141-
* Creates a ZodObject schema from an array of ParameterSchema (TypeScript types).
142-
* This combined schema is used by ToolboxTool to validate its call arguments.
143-
* @param params Array of ParameterSchema objects.
144-
* @returns A ZodObject schema.
145-
*/
146138
export function createZodSchemaFromParams(
147139
params: ParameterSchema[],
148140
): ZodObject<ZodRawShape> {

packages/toolbox-core/test/e2e/jest.globalSetup.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export default async function globalSetup(): Promise<void> {
4141
const toolsManifest = await accessSecretVersion(
4242
projectId,
4343
'sdk_testing_tools',
44+
'34',
4445
);
4546
const toolsFilePath = await createTmpFile(toolsManifest);
4647
(globalThis as CustomGlobal).__TOOLS_FILE_PATH__ = toolsFilePath;

packages/toolbox-core/test/e2e/test.e2e.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ describe('ToolboxClient E2E Tests', () => {
116116
'get-row-by-id',
117117
'get-n-rows',
118118
'search-rows',
119+
'process-data',
119120
]);
120121
expect(loadedToolNames).toEqual(expectedDefaultTools);
121122

@@ -493,4 +494,80 @@ describe('ToolboxClient E2E Tests', () => {
493494
expect(response).toBe('null');
494495
});
495496
});
497+
describe('Map/Object Params E2E Tests', () => {
498+
let processDataTool: ReturnType<typeof ToolboxTool>;
499+
500+
beforeAll(async () => {
501+
processDataTool = await commonToolboxClient.loadTool('process-data');
502+
});
503+
504+
it('should correctly identify map/object parameters in the schema', () => {
505+
const paramSchema = processDataTool.getParamSchema();
506+
const baseArgs = {
507+
execution_context: {env: 'prod'},
508+
user_scores: {user1: 100},
509+
};
510+
511+
// Test required untyped map (dict[str, Any])
512+
expect(paramSchema.safeParse(baseArgs).success).toBe(true);
513+
const argsWithoutExec = {...baseArgs};
514+
delete (argsWithoutExec as Partial<typeof argsWithoutExec>)
515+
.execution_context;
516+
expect(paramSchema.safeParse(argsWithoutExec).success).toBe(false);
517+
518+
// Test required typed map (dict[str, int])
519+
expect(
520+
paramSchema.safeParse({
521+
...baseArgs,
522+
user_scores: {user1: 'not-a-number'},
523+
}).success,
524+
).toBe(false);
525+
526+
// Test optional typed map (dict[str, bool])
527+
expect(
528+
paramSchema.safeParse({
529+
...baseArgs,
530+
feature_flags: {new_feature: true},
531+
}).success,
532+
).toBe(true);
533+
expect(
534+
paramSchema.safeParse({...baseArgs, feature_flags: null}).success,
535+
).toBe(true);
536+
expect(paramSchema.safeParse(baseArgs).success).toBe(true); // Omitted
537+
});
538+
539+
it('should run tool with valid map parameters', async () => {
540+
const response = await processDataTool({
541+
execution_context: {env: 'prod', id: 1234, user: 1234.5},
542+
user_scores: {user1: 100, user2: 200},
543+
feature_flags: {new_feature: true},
544+
});
545+
expect(typeof response).toBe('string');
546+
expect(response).toContain(
547+
'"execution_context":{"env":"prod","id":1234,"user":1234.5}',
548+
);
549+
expect(response).toContain('"user_scores":{"user1":100,"user2":200}');
550+
expect(response).toContain('"feature_flags":{"new_feature":true}');
551+
});
552+
553+
it('should run tool with optional map param omitted', async () => {
554+
const response = await processDataTool({
555+
execution_context: {env: 'dev'},
556+
user_scores: {user3: 300},
557+
});
558+
expect(typeof response).toBe('string');
559+
expect(response).toContain('"execution_context":{"env":"dev"}');
560+
expect(response).toContain('"user_scores":{"user3":300}');
561+
expect(response).toContain('"feature_flags":null');
562+
});
563+
564+
it('should fail when a map parameter has the wrong value type', async () => {
565+
await expect(
566+
processDataTool({
567+
execution_context: {env: 'staging'},
568+
user_scores: {user4: 'not-an-integer'},
569+
}),
570+
).rejects.toThrow(/user_scores\.user4: Expected number, received string/);
571+
});
572+
});
496573
});

0 commit comments

Comments
 (0)