Skip to content

Commit d9a6511

Browse files
committed
Add docs on service classes
Also update the SampleGasPricesService to conform to this documentation, and update the SampleGasPricesController to use the service.
1 parent 10309a9 commit d9a6511

12 files changed

+585
-208
lines changed

docs/services.md

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# Services
2+
3+
## What is a service?
4+
5+
A **service** is best used to wrap interactions with an external API, which might be used to sync accounts, retrieve information about NFTs, or manage feature flags.
6+
7+
## How to write a service
8+
9+
Let's say that we want to make a service that uses an API to retrieve gas prices. To do this, we will define a class which has a single method. We will then expose that method through a restricted messenger which will allow consuming code to use our service without needing direct access.
10+
11+
Assuming that we are within a package directory in the monorepo, we would start by adding `@metamask/base-controller` as a direct dependency of the package:
12+
13+
```
14+
yarn workspace <package-name> add @metamask/base-controller
15+
```
16+
17+
Then, making a new file in the `src/` directory, `gas-prices-service.ts`, we would import that package at the top of our file:
18+
19+
```typescript
20+
import { RestrictedMessenger } from '@metamask/base-controller';
21+
```
22+
23+
Next we'll define a type for the messenger. We'll first define the actions and events that our messenger shares and then all of the actions and events that it is allowed to access. Our service class will have a method called
24+
`fetchGasPrices`, so we only need one public action:
25+
26+
```typescript
27+
const SERVICE_NAME = 'GasPricesService';
28+
29+
export type GasPricesServiceFetchGasPricesAction = {
30+
type: `${typeof SERVICE_NAME}:fetchGasPrices`;
31+
handler: GasPricesService['fetchGasPrices'];
32+
};
33+
34+
export type GasPricesServiceActions = GasPricesServiceFetchGasPricesAction;
35+
36+
type AllowedActions = never;
37+
38+
export type GasPricesServiceEvents = never;
39+
40+
type AllowedEvents = never;
41+
42+
export type GasPricesServiceMessenger = RestrictedMessenger<
43+
typeof SERVICE_NAME,
44+
GasPricesServiceActions | AllowedActions,
45+
GasPricesServiceEvents | AllowedEvents,
46+
AllowedActions['type'],
47+
AllowedEvents['type']
48+
>;
49+
```
50+
51+
Next we define the type of the response that the API will have:
52+
53+
```typescript
54+
type GasPricesResponse = {
55+
data: {
56+
low: number;
57+
average: number;
58+
high: number;
59+
};
60+
};
61+
```
62+
63+
Finally we define the service class itself. We have the constructor take two arguments:
64+
65+
- The messenger that we defined above.
66+
- A fetch function so that we don't have to rely on a particular JavaScript runtime or environment where a global `fetch` function may not exist (or may be accessible using a different syntax)
67+
68+
We also add the single method that we mentioned above, and we register it as an action handler on the messenger.
69+
70+
```typescript
71+
const API_BASE_URL = 'https://example.com/gas-prices';
72+
73+
export class GasPricesService {
74+
readonly #messenger: GasPricesServiceMessenger;
75+
76+
readonly #fetch: typeof fetch;
77+
78+
constructor({
79+
messenger,
80+
fetch: fetchFunction,
81+
}: {
82+
messenger: GasPricesServiceMessenger;
83+
fetch: typeof fetch;
84+
}) {
85+
this.#messenger = messenger;
86+
this.#fetch = fetchFunction;
87+
88+
this.#messenger.registerActionHandler(
89+
`${SERVICE_NAME}:fetchGasPrices`,
90+
this.fetchGasPrices.bind(this),
91+
);
92+
}
93+
94+
async fetchGasPrices(chainId: Hex): Promise<GasPricesResponse> {
95+
const response = await this.#fetch(`${API_BASE_URL}/${chainId}`);
96+
// Type assertion: We have to assume the shape of the response data.
97+
const gasPricesResponse =
98+
(await response.json()) as unknown as GasPricesResponse;
99+
return gasPricesResponse.data;
100+
}
101+
}
102+
```
103+
104+
Finally, we go into the `index.ts` for our package and we export the various parts of the service module that consumers need. Note that we do _not_ export `AllowedActions` and `AllowedEvents`:
105+
106+
```typescript
107+
export type {
108+
GasPricesServiceActions,
109+
GasPricesServiceEvents,
110+
GasPricesServiceFetchGasPricesAction,
111+
GasPricesServiceMessenger,
112+
} from './gas-prices-service';
113+
export { GasPricesService } from './gas-prices-service';
114+
```
115+
116+
Great, we've finished the implementation. Now let's write some tests. We'll create a file `gas-prices-service.test.ts`. Note:
117+
118+
- We pass in the global `fetch` (available in Node >= 18).
119+
- We use `nock` to mock the request.
120+
- We test not only the method but also the messenger action.
121+
- We also add a function to help us build the messenger.
122+
123+
```typescript
124+
import { Messenger } from '@metamask/base-controller';
125+
import nock from 'nock';
126+
127+
import type { GasPricesServiceMessenger } from './gas-prices-service';
128+
import { GasPricesService } from './gas-prices-service';
129+
130+
describe('GasPricesService', () => {
131+
describe('fetchGasPrices', () => {
132+
it('returns a slightly cleaned up version of what the API returns', async () => {
133+
nock('https://example.com/gas-prices')
134+
.get('/0x1.json')
135+
.reply(200, {
136+
data: {
137+
low: 5,
138+
average: 10,
139+
high: 15,
140+
},
141+
});
142+
const messenger = buildMessenger();
143+
const gasPricesService = new GasPricesService({ messenger, fetch });
144+
145+
const gasPricesResponse = await gasPricesService.fetchGasPrices('0x1');
146+
147+
expect(gasPricesResponse).toStrictEqual({
148+
low: 5,
149+
average: 10,
150+
high: 15,
151+
});
152+
});
153+
});
154+
155+
describe('GasPricesService:fetchGasPrices', () => {
156+
it('returns a slightly cleaned up version of what the API returns', async () => {
157+
nock('https://example.com/gas-prices')
158+
.get('/0x1.json')
159+
.reply(200, {
160+
data: {
161+
low: 5,
162+
average: 10,
163+
high: 15,
164+
},
165+
});
166+
const messenger = buildMessenger();
167+
const gasPricesService = new GasPricesService({ messenger, fetch });
168+
169+
const gasPricesResponse = await gasPricesService.fetchGasPrices('0x1');
170+
171+
expect(gasPricesResponse).toStrictEqual({
172+
low: 5,
173+
average: 10,
174+
high: 15,
175+
});
176+
});
177+
});
178+
});
179+
180+
function buildMessenger(): GasPricesServiceMessenger {
181+
return new Messenger().getRestricted({
182+
name: 'GasPricesService',
183+
allowedActions: [],
184+
allowedEvents: [],
185+
});
186+
}
187+
```
188+
189+
And that's it!
190+
191+
## How to use a service
192+
193+
Let's say that we wanted to use our service that we built above. To do this, we will instantiate the messenger for the service — which itself relies on a global messenger — and then the service itself.
194+
195+
First we need to import the service:
196+
197+
```typescript
198+
import { GasPricesService } from '@metamask/gas-prices-service';
199+
```
200+
201+
Then we create a global messenger:
202+
203+
```typescript
204+
const globalMessenger = new Messenger();
205+
```
206+
207+
Then we create a messenger restricted to the actions and events GasPricesService exposes. In this case we don't need to specify anything for `allowedActions` and `allowedEvents` because the messenger does not need actions or events from any other messengers:
208+
209+
```typescript
210+
const gasPricesServiceMessenger = globalMessenger.getRestricted({
211+
allowedActions: [],
212+
allowedEvents: [],
213+
});
214+
```
215+
216+
Now we instantiate the service to register the action handler on the global messenger. We assume we have a global `fetch` function available:
217+
218+
```typescript
219+
const gasPricesService = new GasPricesService({
220+
messenger: gasPricesServiceMessenger,
221+
fetch,
222+
});
223+
```
224+
225+
Great! Now that we've set up the service and its messenger action, we can use it somewhere else.
226+
227+
Let's say we had a controller and we wanted to use it there. All we'd need to do is define that controller's messenger type to allow access to `GasPricesService:fetchGasPrices`. This code would probably be the controller package itself. For instance if we had a file `packages/send-controller/send-controller.ts`, we might have:
228+
229+
```typescript
230+
import { GasPricesServiceFetchGasPricesAction } from '@metamask/gas-prices-service';
231+
232+
type SendControllerActions = ...;
233+
234+
type AllowedActions = GasPricesServiceFetchGasPricesAction;
235+
236+
type SendControllerEvents = ...;
237+
238+
type AllowedEvents = ...;
239+
240+
type SendControllerMessenger = RestrictedMessenger<
241+
'SendController',
242+
SendControllerActions | AllowedActions,
243+
SendControllerEvents | AllowedEvents,
244+
AllowedActions['type'],
245+
AllowedEvents['type']
246+
>;
247+
```
248+
249+
Then, later on in our controller, we could say:
250+
251+
```typescript
252+
const gasPrices = await this.#messagingSystem.call(
253+
'GasPricesService:fetchGasPrices',
254+
);
255+
// ... use gasPrices somehow ...
256+
```
257+
258+
## Learning more
259+
260+
The [`sample-controllers` package](../packages/sample-controllers) has a full example of the service pattern, including JSDoc. Check it out and feel free to copy and paste the code you see to your own project.

packages/sample-controllers/src/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ describe('@metamask/sample-controllers', () => {
66
Array [
77
"getDefaultSampleGasPricesControllerState",
88
"SampleGasPricesController",
9-
"SamplePetnamesController",
109
"SampleGasPricesService",
10+
"SamplePetnamesController",
1111
]
1212
`);
1313
});

packages/sample-controllers/src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ export {
1010
getDefaultSampleGasPricesControllerState,
1111
SampleGasPricesController,
1212
} from './sample-gas-prices-controller';
13+
export type {
14+
SampleGasPricesServiceActions,
15+
SampleGasPricesServiceEvents,
16+
SampleGasPricesServiceFetchGasPricesAction,
17+
SampleGasPricesServiceMessenger,
18+
} from './sample-gas-prices-service';
19+
export { SampleGasPricesService } from './sample-gas-prices-service';
1320
export type {
1421
SamplePetnamesControllerActions,
1522
SamplePetnamesControllerEvents,
@@ -19,7 +26,3 @@ export type {
1926
SamplePetnamesControllerStateChangeEvent,
2027
} from './sample-petnames-controller';
2128
export { SamplePetnamesController } from './sample-petnames-controller';
22-
export {
23-
SampleGasPricesService,
24-
type SampleAbstractGasPricesService,
25-
} from './sample-gas-prices-service';

0 commit comments

Comments
 (0)