Skip to content

Commit

Permalink
Add logic to allow ISO 8601 duration strings (#2975)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hmalik88 authored Jan 7, 2025
1 parent 308f571 commit 7e3ef0b
Show file tree
Hide file tree
Showing 5 changed files with 175 additions and 29 deletions.
8 changes: 4 additions & 4 deletions packages/snaps-rpc-methods/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ module.exports = deepmerge(baseConfig, {
],
coverageThreshold: {
global: {
branches: 93.91,
functions: 98.02,
lines: 98.65,
statements: 98.24,
branches: 93.97,
functions: 98.05,
lines: 98.67,
statements: 98.25,
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,105 @@ describe('snap_scheduleBackgroundEvent', () => {
});
});

it('schedules a background event using duration', async () => {
const { implementation } = scheduleBackgroundEventHandler;

const scheduleBackgroundEvent = jest.fn();
const hasPermission = jest.fn().mockImplementation(() => true);

const hooks = {
scheduleBackgroundEvent,
hasPermission,
};

const engine = new JsonRpcEngine();

engine.push(createOriginMiddleware(MOCK_SNAP_ID));
engine.push((request, response, next, end) => {
const result = implementation(
request as JsonRpcRequest<ScheduleBackgroundEventParams>,
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
next,
end,
hooks,
);

result?.catch(end);
});

await engine.handle({
jsonrpc: '2.0',
id: 1,
method: 'snap_scheduleBackgroundEvent',
params: {
duration: 'PT30S',
request: {
method: 'handleExport',
params: ['p1'],
},
},
});

expect(scheduleBackgroundEvent).toHaveBeenCalledWith({
date: expect.any(String),
request: {
method: 'handleExport',
params: ['p1'],
},
});
});

it('throws on an invalid duration', async () => {
const { implementation } = scheduleBackgroundEventHandler;

const scheduleBackgroundEvent = jest.fn();
const hasPermission = jest.fn().mockImplementation(() => true);

const hooks = {
scheduleBackgroundEvent,
hasPermission,
};

const engine = new JsonRpcEngine();

engine.push(createOriginMiddleware(MOCK_SNAP_ID));
engine.push((request, response, next, end) => {
const result = implementation(
request as JsonRpcRequest<ScheduleBackgroundEventParams>,
response as PendingJsonRpcResponse<ScheduleBackgroundEventResult>,
next,
end,
hooks,
);

result?.catch(end);
});

const response = await engine.handle({
jsonrpc: '2.0',
id: 1,
method: 'snap_scheduleBackgroundEvent',
params: {
duration: 'PQ30S',
request: {
method: 'handleExport',
params: ['p1'],
},
},
});

expect(response).toStrictEqual({
error: {
code: -32602,
message:
'Invalid params: At path: duration -- Not a valid ISO 8601 duration.',
stack: expect.any(String),
},
id: 1,
jsonrpc: '2.0',
});
});

it('throws if a snap does not have the "endowment:cronjob" permission', async () => {
const { implementation } = scheduleBackgroundEventHandler;

Expand Down Expand Up @@ -171,7 +270,7 @@ describe('snap_scheduleBackgroundEvent', () => {
});
});

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

const scheduleBackgroundEvent = jest.fn();
Expand Down Expand Up @@ -214,7 +313,7 @@ describe('snap_scheduleBackgroundEvent', () => {
error: {
code: -32602,
message:
'Invalid params: At path: date -- ISO 8601 string must have timezone information.',
'Invalid params: At path: date -- ISO 8601 date must have timezone information.',
stack: expect.any(String),
},
id: 1,
Expand Down Expand Up @@ -265,7 +364,7 @@ describe('snap_scheduleBackgroundEvent', () => {
error: {
code: -32602,
message:
'Invalid params: At path: date -- Not a valid ISO 8601 string.',
'Invalid params: At path: date -- Not a valid ISO 8601 date.',
stack: expect.any(String),
},
id: 1,
Expand Down
74 changes: 57 additions & 17 deletions packages/snaps-rpc-methods/src/permitted/scheduleBackgroundEvent.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine';
import type { PermittedHandlerExport } from '@metamask/permission-controller';
import { providerErrors, rpcErrors } from '@metamask/rpc-errors';
import type {
JsonRpcRequest,
ScheduleBackgroundEventParams,
ScheduleBackgroundEventResult,
import {
selectiveUnion,
type JsonRpcRequest,
type ScheduleBackgroundEventParams,
type ScheduleBackgroundEventResult,
} from '@metamask/snaps-sdk';
import type { CronjobRpcRequest } from '@metamask/snaps-utils';
import {
Expand All @@ -18,8 +19,12 @@ import {
refine,
string,
} from '@metamask/superstruct';
import { assert, type PendingJsonRpcResponse } from '@metamask/utils';
import { DateTime } from 'luxon';
import {
assert,
hasProperty,
type PendingJsonRpcResponse,
} from '@metamask/utils';
import { DateTime, Duration } from 'luxon';

import { SnapEndowments } from '../endowments';
import type { MethodHooksObject } from '../utils';
Expand Down Expand Up @@ -55,26 +60,61 @@ export const scheduleBackgroundEventHandler: PermittedHandlerExport<
};

const offsetRegex = /Z|([+-]\d{2}:?\d{2})$/u;
const ScheduleBackgroundEventsParametersStruct = object({

const ScheduleBackgroundEventParametersWithDateStruct = object({
date: refine(string(), 'date', (val) => {
const date = DateTime.fromISO(val);
if (date.isValid) {
// Luxon doesn't have a reliable way to check if timezone info was not provided
if (!offsetRegex.test(val)) {
return 'ISO 8601 string must have timezone information';
return 'ISO 8601 date must have timezone information';
}
return true;
}
return 'Not a valid ISO 8601 string';
return 'Not a valid ISO 8601 date';
}),
request: CronjobRpcRequestStruct,
});

const ScheduleBackgroundEventParametersWithDurationStruct = object({
duration: refine(string(), 'duration', (val) => {
const duration = Duration.fromISO(val);
if (!duration.isValid) {
return 'Not a valid ISO 8601 duration';
}
return true;
}),
request: CronjobRpcRequestStruct,
});

const ScheduleBackgroundEventParametersStruct = selectiveUnion((val) => {
if (hasProperty(val, 'date')) {
return ScheduleBackgroundEventParametersWithDateStruct;
}
return ScheduleBackgroundEventParametersWithDurationStruct;
});

export type ScheduleBackgroundEventParameters = InferMatching<
typeof ScheduleBackgroundEventsParametersStruct,
typeof ScheduleBackgroundEventParametersStruct,
ScheduleBackgroundEventParams
>;

/**
* Generates a `DateTime` object based on if a duration or date is provided.
*
* @param params - The validated params from the `snap_scheduleBackgroundEvent` call.
* @returns A `DateTime` object.
*/
function getStartDate(params: ScheduleBackgroundEventParams) {
if ('duration' in params) {
return DateTime.fromJSDate(new Date())
.toUTC()
.plus(Duration.fromISO(params.duration));
}

return DateTime.fromISO(params.date, { setZone: true });
}

/**
* The `snap_scheduleBackgroundEvent` method implementation.
*
Expand Down Expand Up @@ -107,14 +147,14 @@ async function getScheduleBackgroundEventImplementation(
try {
const validatedParams = getValidatedParams(params);

const { date, request } = validatedParams;
const { request } = validatedParams;

const date = getStartDate(validatedParams);

// Make sure any millisecond precision is removed.
const truncatedDate = DateTime.fromISO(date, { setZone: true })
.startOf('second')
.toISO({
suppressMilliseconds: true,
});
const truncatedDate = date.startOf('second').toISO({
suppressMilliseconds: true,
});

assert(truncatedDate);

Expand All @@ -138,7 +178,7 @@ function getValidatedParams(
params: unknown,
): ScheduleBackgroundEventParameters {
try {
return create(params, ScheduleBackgroundEventsParametersStruct);
return create(params, ScheduleBackgroundEventParametersStruct);
} catch (error) {
if (error instanceof StructError) {
throw rpcErrors.invalidParams({
Expand Down
2 changes: 2 additions & 0 deletions packages/snaps-sdk/src/types/methods/get-background-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { SnapId } from '../snap';
/**
* Background event type
*
* Note: The date generated when scheduling an event with a duration will be represented in UTC.
*
* @property id - The unique id representing the event.
* @property scheduledAt - The ISO 8601 time stamp of when the event was scheduled.
* @property snapId - The id of the snap that scheduled the event.
Expand Down
15 changes: 10 additions & 5 deletions packages/snaps-sdk/src/types/methods/schedule-background-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import type { Cronjob } from '../permissions';
/**
* The request parameters for the `snap_scheduleBackgroundEvent` method.
*
* @property date - The ISO8601 date of when to fire the background event.
* Note: The date generated from a duration will be represented in UTC.
*
* @property date - The ISO 8601 date of when to fire the background event.
* @property duration - The ISO 8601 duration of when to fire the background event.
* @property request - The request to be called when the event fires.
*/
export type ScheduleBackgroundEventParams = {
date: string;
request: Cronjob['request'];
};
export type ScheduleBackgroundEventParams =
| {
date: string;
request: Cronjob['request'];
}
| { duration: string; request: Cronjob['request'] };

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

0 comments on commit 7e3ef0b

Please sign in to comment.