@slates/provider 1.0.0-rc.33 → 1.0.0-rc.35

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.
@@ -0,0 +1,94 @@
1
+ import { z } from 'zod';
2
+ import type { SlateContext } from '../context';
3
+ import { SlateDeclarationError } from '../error';
4
+ import type { SlateSpecification } from '../specification/specification';
5
+
6
+ export interface SlateOption {
7
+ value: string | number;
8
+ label: string;
9
+ description?: string;
10
+ }
11
+
12
+ export interface SlateOptionListPage {
13
+ options: SlateOption[];
14
+ nextPageToken?: any | null;
15
+ }
16
+
17
+ export type SlateOptionListHandler<
18
+ ConfigType extends {},
19
+ AuthType extends {},
20
+ ParamsType extends {} = {}
21
+ > = (
22
+ context: SlateContext<
23
+ ConfigType,
24
+ AuthType,
25
+ { query: string | null; pageToken: any | null; params: ParamsType }
26
+ >
27
+ ) => Promise<SlateOptionListPage>;
28
+
29
+ export interface SlateOptionListParameters<
30
+ ConfigType extends {},
31
+ AuthType extends {},
32
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
33
+ > {
34
+ key: string;
35
+ /** Made strict when declared, so params with a key the schema does not name are rejected. */
36
+ paramsSchema?: ParamsSchema;
37
+ list: SlateOptionListHandler<ConfigType, AuthType, z.output<ParamsSchema>>;
38
+ }
39
+
40
+ /** A list of options other integrations can link their selection fields to. */
41
+ export class SlateOptionList<
42
+ ConfigType extends {},
43
+ AuthType extends {},
44
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
45
+ > {
46
+ private constructor(
47
+ private readonly _spec: SlateSpecification<ConfigType, AuthType>,
48
+ private readonly _params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>,
49
+ private readonly _paramsSchema: z.ZodObject<any>
50
+ ) {}
51
+
52
+ static create<
53
+ ConfigType extends {},
54
+ AuthType extends {},
55
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
56
+ >(
57
+ spec: SlateSpecification<ConfigType, AuthType>,
58
+ params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>
59
+ ) {
60
+ let paramsSchema: z.ZodObject<any> = params.paramsSchema ?? z.object({});
61
+ if (paramsSchema?._zod?.def?.type !== 'object') {
62
+ throw new SlateDeclarationError(
63
+ `Option list "${params.key}" params schema must be a zod object`
64
+ );
65
+ }
66
+
67
+ return new SlateOptionList(spec, params, paramsSchema.strict());
68
+ }
69
+
70
+ get spec() {
71
+ return this._spec;
72
+ }
73
+
74
+ get key() {
75
+ return this._params.key;
76
+ }
77
+
78
+ get paramsSchema() {
79
+ return this._paramsSchema;
80
+ }
81
+
82
+ get list() {
83
+ return this._params.list;
84
+ }
85
+ }
86
+
87
+ export let optionList = <
88
+ ConfigType extends {},
89
+ AuthType extends {},
90
+ ParamsSchema extends z.ZodObject<any> = z.ZodObject<{}>
91
+ >(
92
+ spec: SlateSpecification<ConfigType, AuthType>,
93
+ params: SlateOptionListParameters<ConfigType, AuthType, ParamsSchema>
94
+ ) => SlateOptionList.create(spec, params);
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { SlateAuth } from '../auth';
4
+ import { SlateConfig } from '../config';
5
+ import { SlateSpecification } from '../specification/specification';
6
+ import { SlateTool } from './tool';
7
+ import { SlateTrigger } from './trigger';
8
+ import { triggerGroup } from './triggerGroup';
9
+
10
+ let spec = SlateSpecification.create({
11
+ key: 'demo',
12
+ name: 'Demo',
13
+ config: SlateConfig.create(z.object({})),
14
+ auth: SlateAuth.create<{}>().output(z.object({}))
15
+ });
16
+
17
+ let group = triggerGroup(spec, { key: 'group', name: 'Group' })
18
+ .polling({ pollEvents: async () => ({ events: [] }) })
19
+ .routingMatchers(async () => [])
20
+ .build();
21
+
22
+ let output = z.object({
23
+ fileId: z.string(),
24
+ parents: z.array(z.string()),
25
+ size: z.number().optional()
26
+ });
27
+
28
+ let create = () =>
29
+ SlateTrigger.create(spec, { key: 'changed', name: 'Changed' })
30
+ .input(z.object({}))
31
+ .triggerGroup(group)
32
+ .matches(() => true)
33
+ .map(async () => ({
34
+ type: 'file.changed',
35
+ id: '1',
36
+ output: { fileId: 'a', parents: [] }
37
+ }));
38
+
39
+ describe('trigger event attributes', () => {
40
+ it('exposes the declared fields on the built trigger', () => {
41
+ let trigger = create().output(output).eventAttributes(['fileId', 'parents']).build();
42
+
43
+ expect(trigger.eventAttributes).toEqual(['fileId', 'parents']);
44
+ });
45
+
46
+ it('is empty when none are declared', () => {
47
+ expect(create().output(output).build().eventAttributes).toEqual([]);
48
+ });
49
+
50
+ it('checks the fields against an output schema set after the declaration', () => {
51
+ expect(
52
+ create()
53
+ .eventAttributes(['fileId'] as never[])
54
+ .output(output)
55
+ .build().eventAttributes
56
+ ).toEqual(['fileId']);
57
+
58
+ expect(() =>
59
+ create()
60
+ .eventAttributes(['folderId'] as never[])
61
+ .output(output)
62
+ .build()
63
+ ).toThrow('Event attribute "folderId" is not a top-level property of the output schema');
64
+ });
65
+
66
+ it('rejects fields that are not top-level output properties', () => {
67
+ expect(() =>
68
+ create()
69
+ .output(output)
70
+ .eventAttributes(['folderId' as any])
71
+ ).toThrow('Event attribute "folderId" is not a top-level property of the output schema');
72
+ });
73
+
74
+ it('requires the output schema to be a zod object', () => {
75
+ expect(() =>
76
+ create()
77
+ .output(z.record(z.string(), z.string()) as unknown as z.ZodType<{ fileId: string }>)
78
+ .eventAttributes(['fileId'])
79
+ ).toThrow('Event attributes require the output schema to be a zod object');
80
+
81
+ expect(() =>
82
+ create()
83
+ .eventAttributes(['fileId'] as never[])
84
+ .output(z.record(z.string(), z.string()) as unknown as z.ZodType<{ fileId: string }>)
85
+ .build()
86
+ ).toThrow('Event attributes require the output schema to be a zod object');
87
+ });
88
+
89
+ it('rejects names that do not match the key pattern', () => {
90
+ let schema = z.object({ _id: z.string(), '1st': z.string(), 'a-b': z.string() });
91
+ for (let field of ['_id', '1st', 'a-b']) {
92
+ expect(() =>
93
+ create()
94
+ .output(schema)
95
+ .eventAttributes([field as any])
96
+ ).toThrow(`Event attribute "${field}" must start with a letter`);
97
+ }
98
+
99
+ let long = 'a'.repeat(65);
100
+ expect(() => create().eventAttributes([long as never])).toThrow(
101
+ `Event attribute "${long}" must start with a letter`
102
+ );
103
+ });
104
+
105
+ it('rejects duplicates', () => {
106
+ expect(() => create().output(output).eventAttributes(['fileId', 'fileId'])).toThrow(
107
+ 'Event attribute "fileId" is declared more than once'
108
+ );
109
+ });
110
+
111
+ it('allows at most eight fields', () => {
112
+ let fields = Array.from({ length: 9 }, (_, i) => `field${i}`);
113
+ let schema = z.object(Object.fromEntries(fields.map(field => [field, z.string()])));
114
+
115
+ expect(
116
+ create().output(schema).eventAttributes(fields.slice(0, 8)).build().eventAttributes
117
+ ).toHaveLength(8);
118
+ expect(() => create().output(schema).eventAttributes(fields)).toThrow(
119
+ 'A trigger can declare at most 8 event attributes'
120
+ );
121
+ });
122
+
123
+ it('can only be declared on triggers', () => {
124
+ let tool = SlateTool.create(spec, { key: 'send', name: 'Send' })
125
+ .input(z.object({}))
126
+ .output(z.object({ id: z.string() }));
127
+
128
+ expect(() => tool.eventAttributes(['id'])).toThrow(
129
+ 'eventAttributes can only be set for trigger actions'
130
+ );
131
+ });
132
+ });
@@ -21,6 +21,7 @@ export interface SlateTriggerParameters<
21
21
  triggerGroup: SlateTriggerGroup<ConfigType, AuthType>;
22
22
  matches: SlateTriggerMatchesHandler;
23
23
  map: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
24
+ eventAttributes?: string[];
24
25
  }
25
26
 
26
27
  export class SlateTrigger<
@@ -34,6 +35,7 @@ export class SlateTrigger<
34
35
  #triggerGroup: SlateTriggerGroup<ConfigType, AuthType>;
35
36
  #matches: SlateTriggerMatchesHandler;
36
37
  #map: SlateTriggerMappingHandler<ConfigType, AuthType, InputType, OutputType>;
38
+ #eventAttributes: string[];
37
39
 
38
40
  private constructor(
39
41
  spec: SlateSpecification<ConfigType, AuthType>,
@@ -46,6 +48,7 @@ export class SlateTrigger<
46
48
  this.#triggerGroup = params.triggerGroup;
47
49
  this.#matches = params.matches;
48
50
  this.#map = params.map;
51
+ this.#eventAttributes = params.eventAttributes ?? [];
49
52
  }
50
53
 
51
54
  static create<ConfigType extends {}, AuthType extends {}>(
@@ -69,6 +72,10 @@ export class SlateTrigger<
69
72
  get map() {
70
73
  return this.#map;
71
74
  }
75
+
76
+ get eventAttributes(): string[] {
77
+ return [...this.#eventAttributes];
78
+ }
72
79
  }
73
80
 
74
81
  export let trigger = <ConfigType extends {}, AuthType extends {}>(
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import { SlateAuth } from '../auth';
4
+ import { SlateConfig } from '../config';
5
+ import { SlateSpecification } from '../specification/specification';
6
+ import { triggerGroup } from './triggerGroup';
7
+
8
+ let spec = SlateSpecification.create({
9
+ key: 'demo',
10
+ name: 'Demo',
11
+ config: SlateConfig.create(z.object({})),
12
+ auth: SlateAuth.create<{}>().output(z.object({}))
13
+ });
14
+
15
+ let autoRegistration = {
16
+ webhookTargetList: async () => ({ targets: [], nextPageToken: null }),
17
+ webhookRegister: async () => ({
18
+ webhookRegistrationIdentifier: 'reg',
19
+ webhookRegistrationPayload: {}
20
+ }),
21
+ webhookUnregister: async () => {}
22
+ };
23
+ let setup = async () => ({ webhookSetupDocument: '', partialWebhookRegistrationPayload: {} });
24
+ let manualRegistration = {
25
+ userConfigSchema: z.object({}),
26
+ fullConfigSchema: z.object({}),
27
+ setup
28
+ };
29
+ let deliveryConfig = {
30
+ ...manualRegistration,
31
+ finish: async () => ({ webhookRegistrationPayload: {} }),
32
+ authenticate: async () => ({ routingKeys: [] })
33
+ };
34
+
35
+ type WebhookProps = Parameters<ReturnType<typeof triggerGroup>['webhook']>[0];
36
+
37
+ let build = (props: Omit<WebhookProps, 'process'>) =>
38
+ triggerGroup(spec, { key: 'group', name: 'Group' })
39
+ .webhook({ ...props, process: async () => ({ events: [] }) })
40
+ .routingMatchers(async () => [])
41
+ .build();
42
+
43
+ describe('webhook trigger group declaration', () => {
44
+ it('builds plain automatic and manual groups as before', () => {
45
+ expect(build({ autoRegistration }).webhook).toMatchObject({
46
+ autoRegistration,
47
+ manualRegistration: undefined,
48
+ deliveryConfig: undefined
49
+ });
50
+ expect(build({ manualRegistration }).webhook).toMatchObject({
51
+ autoRegistration: undefined,
52
+ manualRegistration,
53
+ deliveryConfig: undefined
54
+ });
55
+ });
56
+
57
+ it('requires exactly one of automatic or manual registration', () => {
58
+ let message =
59
+ 'Webhook trigger groups require exactly one of autoRegistration or manualRegistration';
60
+ expect(() => build({})).toThrow(message);
61
+ expect(() => build({ deliveryConfig })).toThrow(message);
62
+ expect(() => build({ autoRegistration, manualRegistration })).toThrow(message);
63
+ });
64
+
65
+ it('allows a delivery config only with automatic registration and its handlers', () => {
66
+ expect(build({ autoRegistration, deliveryConfig }).webhook?.deliveryConfig).toBe(
67
+ deliveryConfig
68
+ );
69
+ expect(() => build({ manualRegistration, deliveryConfig })).toThrow(
70
+ 'A webhook delivery config requires automatic webhook registration'
71
+ );
72
+ let { finish: _, ...withoutFinish } = deliveryConfig;
73
+ expect(() =>
74
+ build({
75
+ autoRegistration,
76
+ deliveryConfig: withoutFinish as unknown as typeof deliveryConfig
77
+ })
78
+ ).toThrow('A webhook delivery config requires a finish handler');
79
+ });
80
+
81
+ it('requires a delivery config to authenticate the requests it receives', () => {
82
+ let { authenticate: _, ...withoutAuthenticate } = deliveryConfig;
83
+ expect(() =>
84
+ build({
85
+ autoRegistration,
86
+ deliveryConfig: withoutAuthenticate as unknown as typeof deliveryConfig
87
+ })
88
+ ).toThrow('A webhook delivery config requires an authenticate handler');
89
+ });
90
+ });
91
+
92
+ describe('trigger group selection options', () => {
93
+ let polling = (
94
+ selectionSchema: z.ZodType<Record<string, any>> = z.object({
95
+ fileId: z.string(),
96
+ tab: z.string().optional(),
97
+ fileIds: z.array(z.string()),
98
+ folder: z.object({ id: z.string() })
99
+ })
100
+ ) =>
101
+ triggerGroup(spec, { key: 'group', name: 'Group', selectionSchema })
102
+ .polling({ pollEvents: async () => ({ events: [] }) })
103
+ .routingMatchers(async () => []);
104
+ let list = async () => ({ options: [{ value: 'a', label: 'A' }] });
105
+ let linked = { slate: 'acme/files', optionList: 'files', params: { kind: 'sheet' } };
106
+
107
+ it('rejects fields that are not top-level properties of the selection schema', () => {
108
+ for (let field of ['fileid', 'folder.id', 'id', 'constructor']) {
109
+ expect(() => polling().selectionOptions({ [field]: { list } })).toThrow(
110
+ `Selection field "${field}" is not a top-level property of the selection schema`
111
+ );
112
+ }
113
+ });
114
+
115
+ it('rejects a selection schema that is not a zod object', () => {
116
+ for (let selectionSchema of [
117
+ z.record(z.string(), z.string()),
118
+ z.object({ fileId: z.string() }).optional() as z.ZodType<Record<string, any>>
119
+ ]) {
120
+ expect(() => polling(selectionSchema).selectionOptions({ fileId: { list } })).toThrow(
121
+ 'Selection options require the selection schema to be a zod object'
122
+ );
123
+ }
124
+ expect(() =>
125
+ triggerGroup(spec, { key: 'group', name: 'Group' }).selectionOptions({
126
+ fileId: { list }
127
+ })
128
+ ).toThrow('Selection options require a selection schema');
129
+ });
130
+
131
+ it('rejects a bare handler instead of { list, linked }', () => {
132
+ for (let declaration of [list, null]) {
133
+ expect(() => polling().selectionOptions({ fileId: declaration as any })).toThrow(
134
+ 'Selection field "fileId" must be declared as { list, linked }'
135
+ );
136
+ }
137
+ });
138
+
139
+ it('accepts an own list, a linked list, or both per field', () => {
140
+ let group = polling()
141
+ .selectionOptions({ fileId: { list, linked } })
142
+ .selectionOptions({
143
+ tab: { linked: { slate: 'acme/files', optionList: 'tabs' } },
144
+ fileIds: { list }
145
+ })
146
+ .build();
147
+ expect(group.selectionOptions).toEqual({
148
+ fileId: { list, linked },
149
+ tab: { linked: { slate: 'acme/files', optionList: 'tabs' } },
150
+ fileIds: { list }
151
+ });
152
+ });
153
+
154
+ it('rejects a field without an own list or a linked list', () => {
155
+ expect(() => polling().selectionOptions({ fileId: {} })).toThrow(
156
+ 'Selection field "fileId" needs its own options list, a linked option list, or both'
157
+ );
158
+ });
159
+
160
+ it('rejects linked lists that do not name a scoped slate and a list', () => {
161
+ expect(() =>
162
+ polling().selectionOptions({
163
+ fileId: { linked: { slate: 'files', optionList: 'files' } }
164
+ })
165
+ ).toThrow('must be written as <scope>/<name>');
166
+ expect(() =>
167
+ polling().selectionOptions({
168
+ fileId: { linked: { slate: 'acme/files', optionList: ' ' } }
169
+ })
170
+ ).toThrow('Selection field "fileId" must name the option list it links to');
171
+ });
172
+ });