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)_