From bce0d41b3cac1522de27c014da886624301d5106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lloren=C3=A7?= Date: Tue, 28 Jun 2022 15:04:34 +0200 Subject: [PATCH] Feat: Reuse signed request when reading state --- packages/agent/src/agent/api.ts | 16 ++++++ packages/agent/src/agent/http/http.test.ts | 61 +++++++++++++++++++++- packages/agent/src/agent/http/index.ts | 22 +++++--- packages/agent/src/polling/index.ts | 8 ++- 4 files changed, 98 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 4f8d2a6b3..6323bd914 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -111,16 +111,32 @@ export interface Agent { */ getPrincipal(): Promise; + /** + * Create the request for the read state call. + * `readState` uses this internally. + * Useful to avoid signing the same request multiple times. + */ + createReadStateRequest?( + options: ReadStateOptions, + identity?: Identity, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise; + /** * Send a read state query to the replica. This includes a list of paths to return, * and will return a Certificate. This will only reject on communication errors, * but the certificate might contain less information than requested. * @param effectiveCanisterId A Canister ID related to this call. * @param options The options for this call. + * @param identity Identity for the call. If not specified, uses the instance identity. + * @param request The request to send in case it has already been created. */ readState( effectiveCanisterId: Principal | string, options: ReadStateOptions, + identity?: Identity, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + request?: any, ): Promise; call(canisterId: Principal | string, fields: CallOptions): Promise; diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 21cf8d002..012662699 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -1,7 +1,13 @@ import { HttpAgent, Nonce } from '../index'; import * as cbor from '../../cbor'; import { Expiry, makeNonceTransform } from './transforms'; -import { CallRequest, Envelope, makeNonce, SubmitRequestType } from './types'; +import { + CallRequest, + Envelope, + HttpAgentRequestTransformFn, + makeNonce, + SubmitRequestType, +} from './types'; import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; @@ -154,6 +160,59 @@ test('queries with the same content should have the same signature', async () => expect(response3).toEqual(response4); }); +test('readState should not call transformers if request is passed', async () => { + const mockResponse = { + status: 'replied', + reply: { arg: new Uint8Array([]) }, + }; + + const mockFetch: jest.Mock = jest.fn((resource, init) => { + const body = cbor.encode(mockResponse); + return Promise.resolve( + new Response(body, { + status: 200, + }), + ); + }); + + const canisterIdent = '2chl6-4hpzw-vqaaa-aaaaa-c'; + const nonce = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce; + + const principal = await Principal.anonymous(); + + const httpAgent = new HttpAgent({ + fetch: mockFetch, + host: 'http://localhost', + disableNonce: true, + }); + httpAgent.addTransform(makeNonceTransform(() => nonce)); + const transformMock: HttpAgentRequestTransformFn = jest + .fn() + .mockImplementation(d => Promise.resolve(d)); + httpAgent.addTransform(transformMock); + + const methodName = 'greet'; + const arg = new Uint8Array([]); + + const requestId = await requestIdOf({ + request_type: SubmitRequestType.Call, + nonce, + canister_id: Principal.fromText(canisterIdent).toString(), + method_name: methodName, + arg, + sender: principal, + }); + + const paths = [ + [new TextEncoder().encode('request_status'), requestId, new TextEncoder().encode('reply')], + ]; + + const request = await httpAgent.createReadStateRequest({ paths }); + expect(transformMock).toBeCalledTimes(1); + await httpAgent.readState(canisterIdent, { paths }, undefined, request); + expect(transformMock).toBeCalledTimes(1); +}); + test('redirect avoid', async () => { function checkUrl(base: string, result: string) { const httpAgent = new HttpAgent({ host: base }); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index f59e96b53..6e140ce0d 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -352,12 +352,11 @@ export class HttpAgent implements Agent { return cbor.decode(await response.arrayBuffer()); } - public async readState( - canisterId: Principal | string, + public async createReadStateRequest( fields: ReadStateOptions, identity?: Identity | Promise, - ): Promise { - const canister = typeof canisterId === 'string' ? Principal.fromText(canisterId) : canisterId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ): Promise { const id = await (identity !== undefined ? await identity : await this._identity); if (!id) { throw new IdentityInvalidError( @@ -368,7 +367,7 @@ export class HttpAgent implements Agent { // TODO: remove this any. This can be a Signed or UnSigned request. // eslint-disable-next-line @typescript-eslint/no-explicit-any - let transformedRequest: any = await this._transform({ + const transformedRequest: any = await this._transform({ request: { method: 'POST', headers: { @@ -386,8 +385,19 @@ export class HttpAgent implements Agent { }); // Apply transform for identity. - transformedRequest = await id?.transformRequest(transformedRequest); + return id?.transformRequest(transformedRequest); + } + + public async readState( + canisterId: Principal | string, + fields: ReadStateOptions, + identity?: Identity | Promise, + // eslint-disable-next-line + request?: any, + ): Promise { + const canister = typeof canisterId === 'string' ? Principal.fromText(canisterId) : canisterId; + const transformedRequest = request ?? (await this.createReadStateRequest(fields, identity)); const body = cbor.encode(transformedRequest.body); const response = await this._fetch( diff --git a/packages/agent/src/polling/index.ts b/packages/agent/src/polling/index.ts index f71123a90..788550583 100644 --- a/packages/agent/src/polling/index.ts +++ b/packages/agent/src/polling/index.ts @@ -20,15 +20,19 @@ export type PollStrategyFactory = () => PollStrategy; * @param canisterId The effective canister ID. * @param requestId The Request ID to poll status for. * @param strategy A polling strategy. + * @param request Request for the readState call. */ export async function pollForResponse( agent: Agent, canisterId: Principal, requestId: RequestId, strategy: PollStrategy, + // eslint-disable-next-line + request?: any, ): Promise { const path = [new TextEncoder().encode('request_status'), requestId]; - const state = await agent.readState(canisterId, { paths: [path] }); + const currentRequest = request ?? (await agent.createReadStateRequest?.({ paths: [path] })); + const state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); if (agent.rootKey == null) throw new Error('Agent root key not initialized before polling'); const cert = await Certificate.create({ certificate: state.certificate, @@ -54,7 +58,7 @@ export async function pollForResponse( case RequestStatusResponseStatus.Processing: // Execute the polling strategy, then retry. await strategy(canisterId, requestId, status); - return pollForResponse(agent, canisterId, requestId, strategy); + return pollForResponse(agent, canisterId, requestId, strategy, currentRequest); case RequestStatusResponseStatus.Rejected: { const rejectCode = new Uint8Array(cert.lookup([...path, 'reject_code'])!)[0];