diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 1ed92c0f87..e227f0914d 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `executeMutation` to `BaseDataService` to allow for making state-mutating requests ([#9324](https://github.com/MetaMask/core/pull/9324)) + ### Changed - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index 519e4460e8..f0611e2eae 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -5,6 +5,7 @@ import { cleanAll } from 'nock'; import { ExampleDataService, serviceName } from '../tests/ExampleDataService'; import { + mockAddFollowerRequest, mockAssets, mockTransactionsPage1, mockTransactionsPage2, @@ -23,6 +24,7 @@ const MOCK_ASSETS = [ describe('BaseDataService', () => { beforeEach(() => { + mockAddFollowerRequest(); mockAssets(); mockTransactionsPage1(); mockTransactionsPage2(); @@ -161,6 +163,22 @@ describe('BaseDataService', () => { ); }); + it('handles mutations', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + + expect(await service.addFollower('1')).toStrictEqual({ + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }); + }); + it('emits `:cacheUpdated` events when cache entry is removed', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); @@ -186,6 +204,16 @@ describe('BaseDataService', () => { ); }); + it('does not emit `:cacheUpdated` when a mutation is executed', async () => { + const messenger = new Messenger({ namespace: serviceName }); + const service = new ExampleDataService(messenger); + const publishSpy = jest.spyOn(messenger, 'publish'); + + await service.addFollower('1'); + + expect(publishSpy).not.toHaveBeenCalled(); + }); + it('does not emit events after being destroyed', async () => { const messenger = new Messenger({ namespace: serviceName }); const service = new ExampleDataService(messenger); diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index fdd2896e2b..d6abf659e5 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -18,6 +18,7 @@ import { InfiniteData, InvalidateOptions, InvalidateQueryFilters, + MutationOptions, OmitKeyof, QueryClient, QueryClientConfig, @@ -250,6 +251,37 @@ export class BaseDataService< return result.pages[pageIndex]; } + /** + * Execute a mutation (a request that is expected to change the state of a server). + * Unlike `fetchQuery`, the request will not be cached. + * + * @param options - The options defining the mutation. Keep in mind that `mutationKey` and `mutationFn` are required when using data services. + * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. + * @returns The mutation results. + */ + protected async executeMutation< + TData extends Json, + TError = unknown, + TVariables = void, + TContext = unknown, + >( + options: WithRequired< + OmitKeyof< + MutationOptions, + 'retry' | 'retryDelay' + >, + 'mutationKey' | 'mutationFn' + >, + ): Promise { + const mutationCache = this.#queryClient.getMutationCache(); + const mutation = mutationCache.build(this.#queryClient, { + ...options, + mutationFn: (context) => + this.#policy.execute(() => options.mutationFn(context)), + }); + return await mutation.execute(); + } + /** * Invalidate queries serviced by this data service. * diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index 8398f65a52..b9553c1e6d 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -44,6 +44,15 @@ export type GetActivityResponse = { }; }; +export type AddFollowerResponse = { + followed: { + profileId: string; + address: string; + name: string; + imageUrl?: string | null; + }[]; +}; + export type PageParam = | { before: string; @@ -60,6 +69,8 @@ export class ExampleDataService extends BaseDataService< readonly #tokensBaseUrl = 'https://tokens.api.cx.metamask.io'; + readonly #socialBaseUrl = 'https://social.api.cx.metamask.io'; + constructor(messenger: ExampleMessenger) { super({ name: serviceName, @@ -139,6 +150,29 @@ export class ExampleDataService extends BaseDataService< ); } + async addFollower(followerId: string): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:addFollower`, followerId], + mutationFn: async () => { + const url = new URL(`${this.#socialBaseUrl}/api/v1/users/me/follows`); + + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ followerId }), + }); + + if (!response.ok) { + throw new Error( + `Mutation failed with status code: ${response.status}.`, + ); + } + + return response.json(); + }, + }); + } + destroy(): void { super.destroy(); } diff --git a/packages/base-data-service/tests/mocks.ts b/packages/base-data-service/tests/mocks.ts index 82341e0672..7ea329eb81 100644 --- a/packages/base-data-service/tests/mocks.ts +++ b/packages/base-data-service/tests/mocks.ts @@ -5,6 +5,26 @@ type MockReply = { body?: nock.Body; }; +export function mockAddFollowerRequest(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: { + followed: [ + { + profileId: '550e8400-e29b-41d4-a716-446655440000', + address: '0x1234567890abcdef1234567890abcdef12345678', + name: 'TraderAlice', + imageUrl: 'https://example.com/avatar.png', + }, + ], + }, + }; + + return nock('https://social.api.cx.metamask.io:443') + .put('/api/v1/users/me/follows', { followerId: '1' }) + .reply(reply.status, reply.body); +} + export function mockAssets(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200,