From 31ba7a2d35c550668bb05ea435cd763a60e20848 Mon Sep 17 00:00:00 2001 From: Elliot Winkler Date: Mon, 1 Jun 2026 13:24:40 -0600 Subject: [PATCH] Extend DS tutorial to cover state-mutating requests Requests that change state on the server needs special consideration as we do not want to cache these types of requests. This part of the tutorial walks through how to do this and how to test that caching does not occur. As usual you can view this locally by running: yarn workspace @metamask/wallet-framework-docs run start-dev --- .../03-state-mutating-requests/README.mdx | 531 +++++++++++++++ .../orders-service-method-action-types.ts | 68 ++ .../orders-service.test.ts | 609 ++++++++++++++++++ .../orders-service.ts | 390 +++++++++++ .../writing-data-services/README.md | 2 +- 5 files changed, 1599 insertions(+), 1 deletion(-) create mode 100644 packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx create mode 100644 packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts create mode 100644 packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts create mode 100644 packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx new file mode 100644 index 0000000000..c58167e8e0 --- /dev/null +++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/README.mdx @@ -0,0 +1,531 @@ +--- +sidebar_label: '3. Making State-Mutating Requests' +--- + +import CodeBlock from '@theme/CodeBlock'; +import ordersService2 from '!!raw-loader!../02-read-only-requests/orders-service.ts'; +import ordersServiceMethodActionTypes2 from '!!raw-loader!../02-read-only-requests/orders-service-method-action-types.ts'; +import ordersServiceTest2 from '!!raw-loader!../02-read-only-requests/orders-service.test.ts'; +import ordersService3 from '!!raw-loader!./orders-service.ts'; +import ordersServiceMethodActionTypes3 from '!!raw-loader!./orders-service-method-action-types.ts'; +import ordersServiceTest3 from '!!raw-loader!./orders-service.test.ts'; + +# Writing a Data Service, Part 3: Making State-Mutating Requests + +In [part 1](../01-getting-started) and [part 2](../02-read-only-requests) of this tutorial we've created a service class that handles the following API: + +- **GET `/v1/orders`**: Retrieve a paginated list of orders, limited to 100 at a time (latest first by default). +- **GET `/v1/orders/:id`**: Retrieve data about an order. + +Here's what we have so far: + +
+ View code + + {ordersService2} + + + {ordersServiceMethodActionTypes2} + + + {ordersServiceTest2} + +
+ +Let's say that our API now allows us to place and cancel orders as needed. Now we have the following operations: + +- **POST `/v1/orders`**: Enqueue a new order for processing. +- **DELETE `/v1/orders/:id`**: Cancel a pending order. + +Up to now, the operations we've supported are read-only, but the new operations are different, because they can change data on the server side. + +Let's now add these to our data service. + +## Creating a new order + +### Implementing the new method + +Starting with the `POST` endpoint, we'll add some types: + +```typescript title="packages/orders-service/src/orders-service.ts" +/** + * An order object that the Orders API returns. + */ +export type ResponseOrder = Infer; + +// highlight-start +/** + * The arguments for `createOrder`. + */ +export type CreateOrderParams = Omit< + ResponseOrder, + 'createdTime' | 'orderId' | 'status' | 'updatedTime' +>; +// highlight-end +``` + +Now we'll add a new method. Note the following: + +- We pass `method` and `body` to the `fetch` function. +- We pass a `staleTime` of 0 to `fetchQuery`. This instructs TanStack Query not to cache these kinds of requests. +- We reuse `FetchOrderResponse` and `FetchOrderResponseStruct`, which we [previously defined for `fetchOrder`](../02-read-only-requests#handling-the-response). + +```typescript title="packages/orders-service/src/orders-service.ts" +export class OrdersService extends BaseDataService { + // ... + + /** + * Creates an order. + * + * @param params - The params. + * @param params.details - Extra data with which to create the order. + * @param params.from - The sender. + * @param params.objectId - The ID of the object being sent. If `type` is + * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type. + * @param params.to - The recipient. + * @param params.type - The type of object being sent (either "asset" or + * "token"). + * @returns The created order. + */ + async createOrder(params: CreateOrderParams): Promise { + const url = new URL(`/v1/orders`, BASE_URL); + + const responseData = await this.fetchQuery({ + queryKey: [`${this.name}:createOrder`, url.toString()], + queryFn: async () => { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + staleTime: 0, + }); + + const [error, validatedResponseData] = validate( + responseData, + FetchOrderResponseStruct, + ); + if (error) { + throw new Error( + `Malformed response received from Orders API (${error.toString()})`, + ); + } + + return validatedResponseData; + } +} +``` + +As with `fetchOrders` and `fetchOrder`, we'll register an action for the new method: + +```typescript title="packages/orders-service/src/orders-service.ts" +const MESSENGER_EXPOSED_METHODS = [ + 'fetchOrders', + 'fetchOrder', + // highlight-next-line + 'createOrder', +] as const; +``` + +We'll run `yarn workspace @metamask/orders-service run generate-action-types` and see that `packages/orders-service/src/orders-service-method-action-types.ts` has this additional content: + +```typescript title="packages/orders-service/src/orders-service.ts" +// highlight-start +/** + * Retrieves details about an order. + * + * @param params - The order ID. + * @returns The requested order. + */ +export type OrdersServiceCreateOrderAction = { + type: `OrdersService:createOrder`; + handler: OrdersService['createOrder']; +}; +// highlight-end + +/** + * Union of all OrdersService action types. + */ +export type OrdersServiceMethodActions = + | OrdersServiceFetchOrdersAction + | OrdersServiceFetchOrderAction + // highlight-next-line + | OrdersServiceCreateOrderAction; +``` + +Finally, we'll write tests. We'll update the mock objects at the top of the test: + +```typescript title="packages/orders-service/src/orders-service.test.ts" +// ::diff-added-start:: +const MOCK_ORDER = { + details: { + amount: '0xde0b6b3a7640000', + }, + from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + type: 'token', +} satisfies CreateOrderParams; +// ::diff-added-end:: + +const MOCK_VALID_ORDER_RESPONSE_DATA = { + order: { + // ::diff-added-next:: + ...MOCK_ORDER, + createdTime: 1747526400, + // ::diff-deleted-start:: + details: { + amount: '0xde0b6b3a7640000', + }, + from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + // ::diff-deleted-end:: + orderId: '0000000000000000001', + status: 'pending', + // ::diff-deleted-start:: + to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + type: 'token', + // ::diff-deleted-end:: + updatedTime: 1747526400, + }, +} satisfies FetchOrderResponse; +``` + +### Adding tests + +Then we'll add the new tests: + +```typescript title="packages/orders-service/src/orders-service.test.ts" +describe('OrdersService', () => { + // ... + + describe('OrdersService:createOrder', () => { + it('creates an order', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:createOrder', + MOCK_ORDER, + ); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:createOrder', MOCK_ORDER), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it.each([ + 'not an object', + { missing: 'order' }, + { order: 'not an array' }, + { order: ['not an object'] }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 2 ** 53 - 1, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + details: 'not an object', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + from: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + orderId: { + not: 'a string', + }, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + status: 'not a valid status', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + to: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + updatedTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + objectId: 'not a CAIP asset type', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + type: 'not a valid type', + }, + }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:createOrder', MOCK_ORDER), + ).rejects.toThrow('Malformed response received from Orders API'); + }, + ); + + it('does not cache requests', async () => { + const scope = nock('https://api.example.com') + .post('/v1/orders') + .times(2) + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER); + await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER); + expect(scope.isDone()).toBe(true); + }); + }); + + describe('createOrder', () => { + it('creates an order, same as the messenger action', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { service } = createService(); + + const responseData = await service.createOrder(MOCK_ORDER); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + }); +}); +``` + +## Deleting an order + +### Implementing the method + +Let's also implement the `DELETE` endpoint. This is similar to `createOrder` except that we don't need to capture the response data (we will assume there is none). + +```typescript title="packages/orders-service/src/orders-service.ts" +export class OrdersService extends BaseDataService { + // ... + + /** + * Cancels an order. + * + * @param id - The order ID. + */ + async cancelOrder(id: string): Promise { + const url = new URL(`/v1/orders/${id}`, BASE_URL); + + await this.fetchQuery({ + queryKey: [`${this.name}:cancelOrder`, url.toString()], + queryFn: async () => { + const response = await fetch(url, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + staleTime: 0, + }); + } +} +``` + +As before we'll update `MESSENGER_EXPOSED_METHODS`: + +```typescript title="packages/orders-service/src/orders-service.ts" +const MESSENGER_EXPOSED_METHODS = [ + 'fetchOrders', + 'fetchOrder', + 'createOrder', + // highlight-next-line + 'cancelOrder', +] as const; +``` + +And we'll update the method action types file: + +```typescript title="packages/orders-service/src/orders-service.ts" +// highlight-start +/** + * Cancels an order. + * + * @param id - The order ID. + */ +export type OrdersServiceCancelOrderAction = { + type: `OrdersService:cancelOrder`; + handler: OrdersService['cancelOrder']; +}; +// highlight-end + +/** + * Union of all OrdersService action types. + */ +export type OrdersServiceMethodActions = + | OrdersServiceFetchOrdersAction + | OrdersServiceFetchOrderAction + | OrdersServiceCreateOrderAction + // highlight-next-line + | OrdersServiceCancelOrderAction; +``` + +### Adding tests + +We'll also add tests: + +```typescript title="packages/orders-service/src/orders-service.test.ts" +describe('OrdersService', () => { + // ... + + describe('OrdersService:cancelOrder', () => { + it('cancels an order', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .reply(200); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + + expect(responseData).toBeUndefined(); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:cancelOrder', '0000000000000000001'), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it('does not cache requests', async () => { + const scope = nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .times(2) + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + expect(scope.isDone()).toBe(true); + }); + }); + + describe('cancelOrder', () => { + it('cancels an order, same as the messenger action', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .reply(200); + const { service } = createService(); + + const responseData = await service.cancelOrder('0000000000000000001'); + + expect(responseData).toBeUndefined(); + }); + }); +}); +``` + +## Summary + +In this section we added two methods, `createOrder` and `deleteOrder`, which use `POST` and `DELETE` HTTP methods to change the state of the server. + +Here's what we have so far: + +
+ View code + + {ordersService3} + + + {ordersServiceMethodActionTypes3} + + + {ordersServiceTest3} + +
+ +What else might we want to add to our data service? In the next section (upcoming), we'll discuss how to subscribe to a feed of orders as they are created. diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts new file mode 100644 index 0000000000..8cb33dd870 --- /dev/null +++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service-method-action-types.ts @@ -0,0 +1,68 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { OrdersService } from './orders-service'; + +/** + * Retrieves orders. + * + * @param params - Parameters to qualify the request. + * @param params.sortField - The field by which to sort the list of orders. + * @param params.sortOrder - The direction in which to sort the list of + * orders. + * @returns The orders from the API. + */ +export type OrdersServiceFetchOrdersAction = { + type: `OrdersService:fetchOrders`; + handler: OrdersService['fetchOrders']; +}; + +/** + * Retrieves details about an order. + * + * @param id - The order ID. + * @returns The requested order. + */ +export type OrdersServiceFetchOrderAction = { + type: `OrdersService:fetchOrder`; + handler: OrdersService['fetchOrder']; +}; + +/** + * Creates an order. + * + * @param params - The params. + * @param params.details - Extra data with which to create the order. + * @param params.from - The sender. + * @param params.objectId - The ID of the object being sent. If `type` is + * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type. + * @param params.to - The recipient. + * @param params.type - The type of object being sent (either "asset" or + * "token"). + * @returns The created order. + */ +export type OrdersServiceCreateOrderAction = { + type: `OrdersService:createOrder`; + handler: OrdersService['createOrder']; +}; + +/** + * Cancels an order. + * + * @param id - The order ID. + */ +export type OrdersServiceCancelOrderAction = { + type: `OrdersService:cancelOrder`; + handler: OrdersService['cancelOrder']; +}; + +/** + * Union of all OrdersService action types. + */ +export type OrdersServiceMethodActions = + | OrdersServiceFetchOrdersAction + | OrdersServiceFetchOrderAction + | OrdersServiceCreateOrderAction + | OrdersServiceCancelOrderAction; diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts new file mode 100644 index 0000000000..bf61bcffec --- /dev/null +++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.test.ts @@ -0,0 +1,609 @@ +import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock from 'nock'; + +import type { + CreateOrderParams, + FetchOrderResponse, + FetchOrdersResponse, + OrdersServiceMessenger, +} from './orders-service'; +import { OrdersService } from './orders-service'; + +const MOCK_VALID_ORDERS_RESPONSE_DATA = { + orders: [ + { + createdTime: 1747526400, + details: { + amount: '0xde0b6b3a7640000', + }, + from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + orderId: '0000000000000000001', + status: 'pending', + to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + type: 'token', + updatedTime: 1747526400, + }, + { + createdTime: 1747440000, + from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + objectId: + 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d/771769', + orderId: '0000000000000000002', + status: 'completed', + to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + type: 'asset', + updatedTime: 1747526400, + }, + ], +} satisfies FetchOrdersResponse; + +const MOCK_ORDER = { + details: { + amount: '0xde0b6b3a7640000', + }, + from: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + objectId: 'eip155:1/erc721:0x06012c8cf97BEaD5deAe237070F9587f8E7A266d', + to: 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + type: 'token', +} satisfies CreateOrderParams; + +const MOCK_VALID_ORDER_RESPONSE_DATA = { + order: { + ...MOCK_ORDER, + orderId: '0000000000000000001', + createdTime: 1747526400, + updatedTime: 1747526400, + status: 'pending', + }, +} satisfies FetchOrderResponse; + +describe('OrdersService', () => { + describe('OrdersService:fetchOrders', () => { + it('requests orders with the default sortField and sortOrder', async () => { + nock('https://api.example.com') + .get('/v1/orders') + .query({ sortField: 'createdTime', sortOrder: 'asc' }) + .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:fetchOrders', + ); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA); + }); + + it('requests orders with the given sortField and sortOrder', async () => { + nock('https://api.example.com') + .get('/v1/orders') + .query({ sortField: 'updatedTime', sortOrder: 'desc' }) + .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:fetchOrders', + { + sortField: 'updatedTime', + sortOrder: 'desc', + }, + ); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .get('/v1/orders') + .query({ sortField: 'createdTime', sortOrder: 'asc' }) + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:fetchOrders'), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it.each([ + 'not an object', + { missing: 'orders' }, + { orders: 'not an array' }, + { orders: ['not an object'] }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + createdTime: 'not a timestamp', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + createdTime: 2 ** 53 - 1, + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + details: 'not an object', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + from: 'not a CAIP account ID', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + orderId: { + not: 'a string', + }, + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + status: 'not a valid status', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + to: 'not a CAIP account ID', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + updatedTime: 'not a timestamp', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + objectId: 'not a CAIP asset type', + }, + ], + }, + { + orders: [ + { + ...MOCK_VALID_ORDERS_RESPONSE_DATA.orders[0], + type: 'not a valid type', + }, + ], + }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock('https://api.example.com') + .get('/v1/orders') + .query({ sortField: 'createdTime', sortOrder: 'asc' }) + .reply(200, JSON.stringify(response)); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:fetchOrders'), + ).rejects.toThrow('Malformed response received from Orders API'); + }, + ); + }); + + describe('fetchOrders', () => { + it('requests orders from the API, same as the messenger action', async () => { + nock('https://api.example.com') + .get('/v1/orders') + .query({ sortField: 'createdTime', sortOrder: 'asc' }) + .reply(200, MOCK_VALID_ORDERS_RESPONSE_DATA); + const { service } = createService(); + + const responseData = await service.fetchOrders(); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDERS_RESPONSE_DATA); + }); + }); + + describe('OrdersService:fetchOrder', () => { + it('requests an order with the default sortField and sortOrder', async () => { + nock('https://api.example.com') + .get('/v1/orders/AAAA-BBBB-CCCC-DDDD') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:fetchOrder', + 'AAAA-BBBB-CCCC-DDDD', + ); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .get('/v1/orders/AAAA-BBBB-CCCC-DDDD') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:fetchOrder', 'AAAA-BBBB-CCCC-DDDD'), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it.each([ + 'not an object', + { missing: 'order' }, + { order: 'not an array' }, + { order: ['not an object'] }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 2 ** 53 - 1, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + details: 'not an object', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + from: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + orderId: { + not: 'a string', + }, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + status: 'not a valid status', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + to: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + updatedTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + objectId: 'not a CAIP asset type', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + type: 'not a valid type', + }, + }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock('https://api.example.com') + .get('/v1/orders/AAAA-BBBB-CCCC-DDDD') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:fetchOrder', 'AAAA-BBBB-CCCC-DDDD'), + ).rejects.toThrow('Malformed response received from Orders API'); + }, + ); + }); + + describe('fetchOrder', () => { + it('requests an order from the API, same as the messenger action', async () => { + nock('https://api.example.com') + .get('/v1/orders/AAAA-BBBB-CCCC-DDDD') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { service } = createService(); + + const responseData = await service.fetchOrder('AAAA-BBBB-CCCC-DDDD'); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + }); + + describe('OrdersService:createOrder', () => { + it('creates an order', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:createOrder', + MOCK_ORDER, + ); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:createOrder', MOCK_ORDER), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it.each([ + 'not an object', + { missing: 'order' }, + { order: 'not an array' }, + { order: ['not an object'] }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + createdTime: 2 ** 53 - 1, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + details: 'not an object', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + from: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + orderId: { + not: 'a string', + }, + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + status: 'not a valid status', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + to: 'not a CAIP account ID', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + updatedTime: 'not a timestamp', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + objectId: 'not a CAIP asset type', + }, + }, + { + order: { + ...MOCK_VALID_ORDER_RESPONSE_DATA.order, + type: 'not a valid type', + }, + }, + ])( + 'throws if the API returns a malformed response %o', + async (response) => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, JSON.stringify(response)); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:createOrder', MOCK_ORDER), + ).rejects.toThrow('Malformed response received from Orders API'); + }, + ); + + it('does not cache requests', async () => { + const scope = nock('https://api.example.com') + .post('/v1/orders') + .times(2) + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER); + await rootMessenger.call('OrdersService:createOrder', MOCK_ORDER); + expect(scope.isDone()).toBe(true); + }); + }); + + describe('createOrder', () => { + it('creates an order, same as the messenger action', async () => { + nock('https://api.example.com') + .post('/v1/orders') + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { service } = createService(); + + const responseData = await service.createOrder(MOCK_ORDER); + + expect(responseData).toStrictEqual(MOCK_VALID_ORDER_RESPONSE_DATA); + }); + }); + + describe('OrdersService:cancelOrder', () => { + it('cancels an order', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .reply(200); + const { rootMessenger } = createService(); + + const responseData = await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + + expect(responseData).toBeUndefined(); + }); + + it('throws if the API returns a non-200 status', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(500); + const { rootMessenger } = createService(); + + await expect( + rootMessenger.call('OrdersService:cancelOrder', '0000000000000000001'), + ).rejects.toThrow("Orders API failed with status '500'"); + }); + + it('does not cache requests', async () => { + const scope = nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .times(2) + .reply(200, MOCK_VALID_ORDER_RESPONSE_DATA); + const { rootMessenger } = createService(); + + await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + await rootMessenger.call( + 'OrdersService:cancelOrder', + '0000000000000000001', + ); + expect(scope.isDone()).toBe(true); + }); + }); + + describe('cancelOrder', () => { + it('cancels an order, same as the messenger action', async () => { + nock('https://api.example.com') + .delete('/v1/orders/0000000000000000001') + .reply(200); + const { service } = createService(); + + const responseData = await service.cancelOrder('0000000000000000001'); + + expect(responseData).toBeUndefined(); + }); + }); +}); + +/** + * The type of the messenger populated with all external actions and events + * required by the service under test. + */ +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the messenger populated with all external actions and events + * required by the service under test. + * + * @returns The root messenger. + */ +function createRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for the service under test. + * + * @param rootMessenger - The root messenger, with all external actions and + * events required by the controller's messenger. + * @returns The service-specific messenger. + */ +function createServiceMessenger( + rootMessenger: RootMessenger, +): OrdersServiceMessenger { + return new Messenger({ + namespace: 'OrdersService', + parent: rootMessenger, + }); +} + +/** + * Constructs the service under test. + * + * @param args - The arguments to this function. + * @param args.options - The options that the service constructor takes. All are + * optional and will be filled in with defaults in as needed (including + * `messenger`). + * @returns The new service, root messenger, and service messenger. + */ +function createService({ + options = {}, +}: { + options?: Partial[0]>; +} = {}): { + service: OrdersService; + rootMessenger: RootMessenger; + messenger: OrdersServiceMessenger; +} { + const rootMessenger = createRootMessenger(); + const messenger = createServiceMessenger(rootMessenger); + const service = new OrdersService({ + messenger, + ...options, + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts new file mode 100644 index 0000000000..3472897836 --- /dev/null +++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/03-state-mutating-requests/orders-service.ts @@ -0,0 +1,390 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { Infer } from '@metamask/superstruct'; +import { + array, + intersection, + literal, + number, + optional, + record, + refine, + string, + type, + union, + unknown, + validate, +} from '@metamask/superstruct'; +import { + CaipAccountIdStruct, + CaipAssetIdStruct, + CaipAssetTypeStruct, +} from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; + +import type { OrdersServiceMethodActions } from './orders-service-method-action-types'; + +/** + * The name of the {@link OrdersService}, used to namespace the service's + * actions and events. + */ +export const DATA_SERVICE_NAME = 'OrdersService'; + +/** + * All of the methods within {@link OrdersService} that are exposed via the + * messenger. + */ +const MESSENGER_EXPOSED_METHODS = [ + 'fetchOrders', + 'fetchOrder', + 'createOrder', + 'cancelOrder', +] as const; + +/** + * Invalidates cached queries for {@link OrdersService}. + */ +export type OrdersServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + +/** + * Actions that {@link OrdersService} exposes to other consumers. + */ +export type OrdersServiceActions = + | OrdersServiceInvalidateQueriesAction + | OrdersServiceMethodActions; + +/** + * Actions from other messengers that {@link OrdersService} calls. + */ +type AllowedActions = never; + +/** + * Published when {@link OrdersService}'s cache is updated. + */ +export type OrdersServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof DATA_SERVICE_NAME +>; + +/** + * Published when a key within {@link OrdersService}'s cache is updated. + */ +export type OrdersServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** + * Events that {@link OrdersService} exposes to other consumers. + */ +export type OrdersServiceEvents = + | OrdersServiceCacheUpdatedEvent + | OrdersServiceGranularCacheUpdatedEvent; + +/** + * Events from other messengers that {@link OrdersService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger which is restricted to actions and events accessed by {@link + * OrdersService}. + */ +export type OrdersServiceMessenger = Messenger< + typeof DATA_SERVICE_NAME, + OrdersServiceActions | AllowedActions, + OrdersServiceEvents | AllowedEvents +>; + +/** + * A struct that represents a timestamp (number of seconds since the UNIX + * epoch). + */ +const TimestampStruct = refine(number(), 'timestamp', (value) => { + if (new Date(value).toString() === 'Invalid Date') { + return 'Expected a valid timestamp'; + } + return true; +}); + +/** + * Struct to validate an order object that the Orders API returns. + */ +const ResponseOrderStruct = intersection([ + // Need to list this first, otherwise the inferred type is never + // See: + union([ + type({ + objectId: CaipAssetTypeStruct, + type: literal('token'), + }), + type({ + objectId: CaipAssetIdStruct, + type: literal('asset'), + }), + ]), + type({ + createdTime: TimestampStruct, + details: optional(record(string(), unknown())), + from: CaipAccountIdStruct, + orderId: string(), + status: union([ + literal('pending'), + literal('completed'), + literal('canceled'), + ]), + to: CaipAccountIdStruct, + updatedTime: TimestampStruct, + }), +]); + +/** + * An order object that the Orders API returns. + */ +export type ResponseOrder = Infer; + +/** + * Struct to validate what `GET /v1/orders` returns. + */ +const FetchOrdersResponseStruct = type({ + orders: array(ResponseOrderStruct), +}); + +/** + * The data that `GET /v1/orders` returns. + */ +export type FetchOrdersResponse = Infer; + +/** + * Struct to validate what `GET /v1/orders/:id` returns. + */ +const FetchOrderResponseStruct = type({ + order: ResponseOrderStruct, +}); + +/** + * The data that `GET /v1/orders/:id` returns. + */ +export type FetchOrderResponse = Infer; + +/** + * The arguments for `createOrder`. + */ +export type CreateOrderParams = Omit< + ResponseOrder, + 'createdTime' | 'orderId' | 'status' | 'updatedTime' +>; + +/** + * The base URL of the API that the service represents. + */ +const BASE_URL = 'https://api.example.com'; + +/** + * This service wraps the Orders API. + */ +export class OrdersService extends BaseDataService< + typeof DATA_SERVICE_NAME, + OrdersServiceMessenger +> { + /** + * Constructs a new OrdersService object. + * + * @param args - The constructor arguments. + * @param args.messenger - The messenger suited for this service. + * @param args.queryClientConfig - Configuration for the underlying TanStack + * Query client. + * @param args.policyOptions - Options to pass to `createServicePolicy`, which + * is used to wrap each request. See {@link CreateServicePolicyOptions}. + */ + constructor({ + messenger, + queryClientConfig = {}, + policyOptions = {}, + }: { + messenger: OrdersServiceMessenger; + queryClientConfig?: QueryClientConfig; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ + name: DATA_SERVICE_NAME, + messenger, + queryClientConfig, + policyOptions, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Retrieves orders. + * + * @param params - Parameters to qualify the request. + * @param params.sortField - The field by which to sort the list of orders. + * @param params.sortOrder - The direction in which to sort the list of + * orders. + * @returns The orders from the API. + */ + async fetchOrders({ + sortField = 'createdTime', + sortOrder = 'asc', + }: { + sortField?: 'createdTime' | 'updatedTime'; + sortOrder?: 'asc' | 'desc'; + } = {}): Promise { + const url = new URL('/v1/orders', BASE_URL); + url.searchParams.append('sortField', sortField); + url.searchParams.append('sortOrder', sortOrder); + + const responseData = await this.fetchQuery({ + queryKey: [`${this.name}:fetchOrders`, url.toString()], + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + const [error, validatedResponseData] = validate( + responseData, + FetchOrdersResponseStruct, + ); + if (error) { + throw new Error( + `Malformed response received from Orders API (${error.toString()})`, + ); + } + + return validatedResponseData; + } + + /** + * Retrieves details about an order. + * + * @param id - The order ID. + * @returns The requested order. + */ + async fetchOrder(id: string): Promise { + const url = new URL(`/v1/orders/${id}`, BASE_URL); + + const responseData = await this.fetchQuery({ + queryKey: [`${this.name}:fetchOrder`, url.toString()], + queryFn: async () => { + const response = await fetch(url); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + }); + + const [error, validatedResponseData] = validate( + responseData, + FetchOrderResponseStruct, + ); + if (error) { + throw new Error( + `Malformed response received from Orders API (${error.toString()})`, + ); + } + + return validatedResponseData; + } + + /** + * Creates an order. + * + * @param params - The params. + * @param params.details - Extra data with which to create the order. + * @param params.from - The sender. + * @param params.objectId - The ID of the object being sent. If `type` is + * "asset", a CAIP-19 asset ID; if `type` is "token", a CAIP-19 asset type. + * @param params.to - The recipient. + * @param params.type - The type of object being sent (either "asset" or + * "token"). + * @returns The created order. + */ + async createOrder(params: CreateOrderParams): Promise { + const url = new URL(`/v1/orders`, BASE_URL); + + const responseData = await this.fetchQuery({ + queryKey: [`${this.name}:createOrder`, url.toString()], + queryFn: async () => { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(params), + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return response.json(); + }, + staleTime: 0, + }); + + const [error, validatedResponseData] = validate( + responseData, + FetchOrderResponseStruct, + ); + if (error) { + throw new Error( + `Malformed response received from Orders API (${error.toString()})`, + ); + } + + return validatedResponseData; + } + + /** + * Cancels an order. + * + * @param id - The order ID. + */ + async cancelOrder(id: string): Promise { + const url = new URL(`/v1/orders/${id}`, BASE_URL); + + await this.fetchQuery({ + queryKey: [`${this.name}:cancelOrder`, url.toString()], + queryFn: async () => { + const response = await fetch(url, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new HttpError( + response.status, + `Orders API failed with status '${response.status}'`, + ); + } + + return null; + }, + staleTime: 0, + }); + } +} diff --git a/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md b/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md index 55f048d8d9..ab96a544d4 100644 --- a/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md +++ b/packages/wallet-framework-docs/content/data-services/writing-data-services/README.md @@ -4,6 +4,6 @@ This tutorial is divided into 5 parts. You can navigate them below: 1. [Getting Started](./01-getting-started) 2. [Making Read-only Requests](./02-read-only-requests) -3. Making State-Mutating Requests +3. [Making State-Mutating Requests](./03-state-mutating-requests) 4. Data Subscriptions _(to be written)_ 5. Advanced Use Cases _(to be written)_