Skip to content

Commit 7e3ef0b

Browse files
authored
Add logic to allow ISO 8601 duration strings (#2975)
Closes #2965 This eliminates the need for some util to provide devs to make it easier to schedule an event. The ISO 8601 standard already provides us with [Durations](https://en.wikipedia.org/wiki/ISO_8601#Durations). Luxon provides a class to parse them and as such we can accept duration strings as well to make it simpler to schedule an event.
1 parent 308f571 commit 7e3ef0b

File tree

5 files changed

+175
-29
lines changed

5 files changed

+175
-29
lines changed

packages/snaps-rpc-methods/jest.config.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
1010
],
1111
coverageThreshold: {
1212
global: {
13-
branches: 93.91,
14-
functions: 98.02,
15-
lines: 98.65,
16-
statements: 98.24,
13+
branches: 93.97,
14+
functions: 98.05,
15+
lines: 98.67,
16+
statements: 98.25,
1717
},
1818
},
1919
});

packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.test.ts

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,105 @@ describe('snap_scheduleBackgroundEvent', () => {
120120
});
121121
});
122122

123+
it('schedules a background event using duration', async () => {
124+
const { implementation } = scheduleBackgroundEventHandler;
125+
126+
const scheduleBackgroundEvent = jest.fn();
127+
const hasPermission = jest.fn().mockImplementation(() => true);
128+
129+
const hooks = {
130+
scheduleBackgroundEvent,
131+
hasPermission,
132+
};
133+
134+
const engine = new JsonRpcEngine();
135+
136+
engine.push(createOriginMiddleware(MOCK_SNAP_ID));
137+
engine.push((request, response, next, end) => {
138+
const result = implementation(
139+
request as JsonRpcRequest<ScheduleBackgroundEventParams>,
140+
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
141+
next,
142+
end,
143+
hooks,
144+
);
145+
146+
result?.catch(end);
147+
});
148+
149+
await engine.handle({
150+
jsonrpc: '2.0',
151+
id: 1,
152+
method: 'snap_scheduleBackgroundEvent',
153+
params: {
154+
duration: 'PT30S',
155+
request: {
156+
method: 'handleExport',
157+
params: ['p1'],
158+
},
159+
},
160+
});
161+
162+
expect(scheduleBackgroundEvent).toHaveBeenCalledWith({
163+
date: expect.any(String),
164+
request: {
165+
method: 'handleExport',
166+
params: ['p1'],
167+
},
168+
});
169+
});
170+
171+
it('throws on an invalid duration', async () => {
172+
const { implementation } = scheduleBackgroundEventHandler;
173+
174+
const scheduleBackgroundEvent = jest.fn();
175+
const hasPermission = jest.fn().mockImplementation(() => true);
176+
177+
const hooks = {
178+
scheduleBackgroundEvent,
179+
hasPermission,
180+
};
181+
182+
const engine = new JsonRpcEngine();
183+
184+
engine.push(createOriginMiddleware(MOCK_SNAP_ID));
185+
engine.push((request, response, next, end) => {
186+
const result = implementation(
187+
request as JsonRpcRequest<ScheduleBackgroundEventParams>,
188+
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
189+
next,
190+
end,
191+
hooks,
192+
);
193+
194+
result?.catch(end);
195+
});
196+
197+
const response = await engine.handle({
198+
jsonrpc: '2.0',
199+
id: 1,
200+
method: 'snap_scheduleBackgroundEvent',
201+
params: {
202+
duration: 'PQ30S',
203+
request: {
204+
method: 'handleExport',
205+
params: ['p1'],
206+
},
207+
},
208+
});
209+
210+
expect(response).toStrictEqual({
211+
error: {
212+
code: -32602,
213+
message:
214+
'Invalid params: At path: duration -- Not a valid ISO 8601 duration.',
215+
stack: expect.any(String),
216+
},
217+
id: 1,
218+
jsonrpc: '2.0',
219+
});
220+
});
221+
123222
it('throws if a snap does not have the "endowment:cronjob" permission', async () => {
124223
const { implementation } = scheduleBackgroundEventHandler;
125224

@@ -171,7 +270,7 @@ describe('snap_scheduleBackgroundEvent', () => {
171270
});
172271
});
173272

174-
it('throws if no timezone information is provided in the ISO8601 string', async () => {
273+
it('throws if no timezone information is provided in the ISO 8601 date', async () => {
175274
const { implementation } = scheduleBackgroundEventHandler;
176275

177276
const scheduleBackgroundEvent = jest.fn();
@@ -214,7 +313,7 @@ describe('snap_scheduleBackgroundEvent', () => {
214313
error: {
215314
code: -32602,
216315
message:
217-
'Invalid params: At path: date -- ISO 8601 string must have timezone information.',
316+
'Invalid params: At path: date -- ISO 8601 date must have timezone information.',
218317
stack: expect.any(String),
219318
},
220319
id: 1,
@@ -265,7 +364,7 @@ describe('snap_scheduleBackgroundEvent', () => {
265364
error: {
266365
code: -32602,
267366
message:
268-
'Invalid params: At path: date -- Not a valid ISO 8601 string.',
367+
'Invalid params: At path: date -- Not a valid ISO 8601 date.',
269368
stack: expect.any(String),
270369
},
271370
id: 1,

packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts

Lines changed: 57 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine';
22
import type { PermittedHandlerExport } from '@metamask/permission-controller';
33
import { providerErrors, rpcErrors } from '@metamask/rpc-errors';
4-
import type {
5-
JsonRpcRequest,
6-
ScheduleBackgroundEventParams,
7-
ScheduleBackgroundEventResult,
4+
import {
5+
selectiveUnion,
6+
type JsonRpcRequest,
7+
type ScheduleBackgroundEventParams,
8+
type ScheduleBackgroundEventResult,
89
} from '@metamask/snaps-sdk';
910
import type { CronjobRpcRequest } from '@metamask/snaps-utils';
1011
import {
@@ -18,8 +19,12 @@ import {
1819
refine,
1920
string,
2021
} from '@metamask/superstruct';
21-
import { assert, type PendingJsonRpcResponse } from '@metamask/utils';
22-
import { DateTime } from 'luxon';
22+
import {
23+
assert,
24+
hasProperty,
25+
type PendingJsonRpcResponse,
26+
} from '@metamask/utils';
27+
import { DateTime, Duration } from 'luxon';
2328

2429
import { SnapEndowments } from '../endowments';
2530
import type { MethodHooksObject } from '../utils';
@@ -55,26 +60,61 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport<
5560
};
5661

5762
const offsetRegex = /Z|([+-]\d{2}:?\d{2})$/u;
58-
const ScheduleBackgroundEventsParametersStruct = object({
63+
64+
const ScheduleBackgroundEventParametersWithDateStruct = object({
5965
date: refine(string(), 'date', (val) => {
6066
const date = DateTime.fromISO(val);
6167
if (date.isValid) {
6268
// Luxon doesn't have a reliable way to check if timezone info was not provided
6369
if (!offsetRegex.test(val)) {
64-
return 'ISO 8601 string must have timezone information';
70+
return 'ISO 8601 date must have timezone information';
6571
}
6672
return true;
6773
}
68-
return 'Not a valid ISO 8601 string';
74+
return 'Not a valid ISO 8601 date';
75+
}),
76+
request: CronjobRpcRequestStruct,
77+
});
78+
79+
const ScheduleBackgroundEventParametersWithDurationStruct = object({
80+
duration: refine(string(), 'duration', (val) => {
81+
const duration = Duration.fromISO(val);
82+
if (!duration.isValid) {
83+
return 'Not a valid ISO 8601 duration';
84+
}
85+
return true;
6986
}),
7087
request: CronjobRpcRequestStruct,
7188
});
7289

90+
const ScheduleBackgroundEventParametersStruct = selectiveUnion((val) => {
91+
if (hasProperty(val, 'date')) {
92+
return ScheduleBackgroundEventParametersWithDateStruct;
93+
}
94+
return ScheduleBackgroundEventParametersWithDurationStruct;
95+
});
96+
7397
export type ScheduleBackgroundEventParameters = InferMatching<
74-
typeof ScheduleBackgroundEventsParametersStruct,
98+
typeof ScheduleBackgroundEventParametersStruct,
7599
ScheduleBackgroundEventParams
76100
>;
77101

102+
/**
103+
* Generates a `DateTime` object based on if a duration or date is provided.
104+
*
105+
* @param params - The validated params from the `snap_scheduleBackgroundEvent` call.
106+
* @returns A `DateTime` object.
107+
*/
108+
function getStartDate(params: ScheduleBackgroundEventParams) {
109+
if ('duration' in params) {
110+
return DateTime.fromJSDate(new Date())
111+
.toUTC()
112+
.plus(Duration.fromISO(params.duration));
113+
}
114+
115+
return DateTime.fromISO(params.date, { setZone: true });
116+
}
117+
78118
/**
79119
* The `snap_scheduleBackgroundEvent` method implementation.
80120
*
@@ -107,14 +147,14 @@ async function getScheduleBackgroundEventImplementation(
107147
try {
108148
const validatedParams = getValidatedParams(params);
109149

110-
const { date, request } = validatedParams;
150+
const { request } = validatedParams;
151+
152+
const date = getStartDate(validatedParams);
111153

112154
// Make sure any millisecond precision is removed.
113-
const truncatedDate = DateTime.fromISO(date, { setZone: true })
114-
.startOf('second')
115-
.toISO({
116-
suppressMilliseconds: true,
117-
});
155+
const truncatedDate = date.startOf('second').toISO({
156+
suppressMilliseconds: true,
157+
});
118158

119159
assert(truncatedDate);
120160

@@ -138,7 +178,7 @@ function getValidatedParams(
138178
params: unknown,
139179
): ScheduleBackgroundEventParameters {
140180
try {
141-
return create(params, ScheduleBackgroundEventsParametersStruct);
181+
return create(params, ScheduleBackgroundEventParametersStruct);
142182
} catch (error) {
143183
if (error instanceof StructError) {
144184
throw rpcErrors.invalidParams({

packages/snaps-sdk/src/types/methods/get-background-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import type { SnapId } from '../snap';
55
/**
66
* Background event type
77
*
8+
* Note: The date generated when scheduling an event with a duration will be represented in UTC.
9+
*
810
* @property id - The unique id representing the event.
911
* @property scheduledAt - The ISO 8601 time stamp of when the event was scheduled.
1012
* @property snapId - The id of the snap that scheduled the event.

packages/snaps-sdk/src/types/methods/schedule-background-event.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ import type { Cronjob } from '../permissions';
33
/**
44
* The request parameters for the `snap_scheduleBackgroundEvent` method.
55
*
6-
* @property date - The ISO8601 date of when to fire the background event.
6+
* Note: The date generated from a duration will be represented in UTC.
7+
*
8+
* @property date - The ISO 8601 date of when to fire the background event.
9+
* @property duration - The ISO 8601 duration of when to fire the background event.
710
* @property request - The request to be called when the event fires.
811
*/
9-
export type ScheduleBackgroundEventParams = {
10-
date: string;
11-
request: Cronjob['request'];
12-
};
12+
export type ScheduleBackgroundEventParams =
13+
| {
14+
date: string;
15+
request: Cronjob['request'];
16+
}
17+
| { duration: string; request: Cronjob['request'] };
1318

1419
/**
1520
* The result returned by the `snap_scheduleBackgroundEvent` method, which is the ID of the scheduled event.

0 commit comments

Comments
 (0)