|
| 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. |
0 commit comments