Skip to content

Commit

Permalink
feat: expose boundary node http headers to calls (#736)
Browse files Browse the repository at this point in the history
* feat: include boundary node http details to query and update calls

* feat: adds method for actor creation that includes boundary node http details

* refactor: avoid using global response headers object in errors

* chore: updates changelog

* chore: update readme with action http details info

* refactor: use object in create actor class to facilitate future arguments
  • Loading branch information
keplervital authored Jul 11, 2023
1 parent 8254c47 commit bd2ab1d
Show file tree
Hide file tree
Showing 12 changed files with 278 additions and 73 deletions.
7 changes: 5 additions & 2 deletions docs/generated/changelog.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ <h2>Version x.x.x</h2>
fix: fix a bug in decoding service types, when function types come after the service type
in the type table
</li>
<li>feat: support composite_query in candid</li>
<li>
feat: support composite_query in candid
fix: fix a bug in decoding service types, when function types come after the service type in the type table
fix: fix a bug in decoding service types, when function types come after the service type
in the type table
</li>
<li>feat: include boundary node http details to query and update calls</li>
<li>feat: adds method for actor creation that includes boundary node http details</li>
</ul>
<h2>Version 0.15.7</h2>
<ul>
Expand Down
6 changes: 6 additions & 0 deletions packages/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ Actor.createActor(interfaceFactory: InterfaceFactory, configuration: ActorConfig

The `interfaceFactory` is a function that returns a runtime interface that the Actor uses to strucure calls to a canister. The interfaceFactory can be written manually, but it is recommended to use the `dfx generate` command to generate the interface for your project, or to use the `didc` tool to generate the interface for your project.

Actors can also be initialized to include the boundary node http headers, This is done by calling the `Actor.createActor` constructor:

```
Actor.createActorWithHttpDetails(interfaceFactory: InterfaceFactory, configuration: ActorConfig): ActorSubclass<ActorMethodMappedWithHttpDetails<T>>
```

### Inspecting an actor's agent

Use the `Actor.agentOf` method to get the agent of an actor:
Expand Down
88 changes: 86 additions & 2 deletions packages/agent/src/actor.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { IDL } from '@dfinity/candid';
import { Principal } from '@dfinity/principal';
import { Actor, UpdateCallRejectedError } from './actor';
import { HttpAgent, Nonce, SubmitResponse } from './agent';
import { Expiry, makeNonceTransform } from './agent/http/transforms';
import { CallRequest, SubmitRequestType, UnSigned } from './agent/http/types';
import * as cbor from './cbor';
import { requestIdOf } from './request_id';
import * as pollingImport from './polling';

const importActor = async (mockUpdatePolling?: () => void) => {
jest.dontMock('./polling');
mockUpdatePolling?.();

return await import('./actor');
};

const originalDateNowFn = global.Date.now;
beforeEach(() => {
jest.resetModules();
global.Date.now = jest.fn(() => new Date(1000000).getTime());
});
afterEach(() => {
Expand All @@ -18,6 +26,7 @@ afterEach(() => {
describe('makeActor', () => {
// TODO: update tests to be compatible with changes to Certificate
it.skip('should encode calls', async () => {
const { Actor, UpdateCallRejectedError } = await importActor();
const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text]),
Expand Down Expand Up @@ -220,7 +229,82 @@ describe('makeActor', () => {
body: cbor.encode(expectedErrorCallRequest),
});
});
it('should enrich actor interface with httpDetails', async () => {
const canisterDecodedReturnValue = 'Hello, World!';
const expectedReplyArg = IDL.encode([IDL.Text], [canisterDecodedReturnValue]);
const { Actor } = await importActor(() =>
jest.doMock('./polling', () => ({
...pollingImport,
pollForResponse: jest.fn(() => expectedReplyArg),
})),
);

const mockFetch = jest.fn(resource => {
if (resource.endsWith('/call')) {
return Promise.resolve(
new Response(null, {
status: 202,
statusText: 'accepted',
}),
);
}

return Promise.resolve(
new Response(
cbor.encode({
status: 'replied',
reply: {
arg: expectedReplyArg,
},
}),
{
status: 200,
statusText: 'ok',
},
),
);
});

const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text], ['query']),
greet_update: IDL.Func([IDL.Text], [IDL.Text]),
// todo: add method to test update call after Certificate changes have been adjusted
});
};
const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://localhost' });
const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c');
const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });
const actorWithHttpDetails = Actor.createActorWithHttpDetails(actorInterface, {
canisterId,
agent: httpAgent,
});

const reply = await actor.greet('test');
const replyUpdate = await actor.greet_update('test');
const replyWithHttpDetails = await actorWithHttpDetails.greet('test');
const replyUpdateWithHttpDetails = await actorWithHttpDetails.greet_update('test');

expect(reply).toEqual(canisterDecodedReturnValue);
expect(replyUpdate).toEqual(canisterDecodedReturnValue);
expect(replyWithHttpDetails.result).toEqual(canisterDecodedReturnValue);
expect(replyWithHttpDetails.httpDetails).toEqual({
ok: true,
status: 200,
statusText: 'ok',
headers: [],
});
expect(replyUpdateWithHttpDetails.result).toEqual(canisterDecodedReturnValue);
expect(replyUpdateWithHttpDetails.httpDetails).toEqual({
body: null,
ok: true,
status: 202,
statusText: 'accepted',
headers: [],
});
});
it('should allow its agent to be invalidated', async () => {
const { Actor } = await importActor();
const mockFetch = jest.fn();
const actorInterface = () => {
return IDL.Service({
Expand All @@ -231,7 +315,7 @@ describe('makeActor', () => {
const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c');
const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });

Actor.agentOf(actor).invalidateIdentity();
httpAgent.invalidateIdentity();

try {
await actor.greet('test');
Expand Down
68 changes: 63 additions & 5 deletions packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Buffer } from 'buffer/';
import {
Agent,
getDefaultAgent,
HttpDetailsResponse,
QueryResponseRejected,
QueryResponseStatus,
ReplicaRejectCode,
Expand Down Expand Up @@ -145,11 +146,30 @@ export type ActorSubclass<T = Record<string, ActorMethod>> = Actor & T;
/**
* An actor method type, defined for each methods of the actor service.
*/
export interface ActorMethod<Args extends unknown[] = unknown[], Ret extends unknown = unknown> {
export interface ActorMethod<Args extends unknown[] = unknown[], Ret = unknown> {
(...args: Args): Promise<Ret>;
withOptions(options: CallConfig): (...args: Args) => Promise<Ret>;
}

/**
* An actor method type, defined for each methods of the actor service.
*/
export interface ActorMethodWithHttpDetails<Args extends unknown[] = unknown[], Ret = unknown>
extends ActorMethod {
(...args: Args): Promise<{ httpDetails: HttpDetailsResponse; result: Ret }>;
}

export type FunctionWithArgsAndReturn<Args extends unknown[] = unknown[], Ret = unknown> = (
...args: Args
) => Ret;

// Update all entries of T with the extra information from ActorMethodWithInfo
export type ActorMethodMappedWithHttpDetails<T> = {
[K in keyof T]: T[K] extends FunctionWithArgsAndReturn<infer Args, infer Ret>
? ActorMethodWithHttpDetails<Args, Ret>
: never;
};

/**
* The mode used when installing a canister.
*/
Expand All @@ -172,6 +192,10 @@ interface ActorMetadata {

const metadataSymbol = Symbol.for('ic-agent-metadata');

export interface CreateActorClassOpts {
httpDetails?: boolean;
}

/**
* An actor base class. An actor is an object containing only functions that will
* return a promise. These functions are derived from the IDL definition.
Expand Down Expand Up @@ -251,7 +275,10 @@ export class Actor {
return this.createActor(interfaceFactory, { ...config, canisterId });
}

public static createActorClass(interfaceFactory: IDL.InterfaceFactory): ActorConstructor {
public static createActorClass(
interfaceFactory: IDL.InterfaceFactory,
options?: CreateActorClassOpts,
): ActorConstructor {
const service = interfaceFactory({ IDL });

class CanisterActor extends Actor {
Expand All @@ -273,6 +300,10 @@ export class Actor {
});

for (const [methodName, func] of service._fields) {
if (options?.httpDetails) {
func.annotations.push(ACTOR_METHOD_WITH_HTTP_DETAILS);
}

this[methodName] = _createActorMethod(this, methodName, func, config.blsVerify);
}
}
Expand All @@ -290,6 +321,15 @@ export class Actor {
) as unknown as ActorSubclass<T>;
}

public static createActorWithHttpDetails<T = Record<string, ActorMethod>>(
interfaceFactory: IDL.InterfaceFactory,
configuration: ActorConfig,
): ActorSubclass<ActorMethodMappedWithHttpDetails<T>> {
return new (this.createActorClass(interfaceFactory, { httpDetails: true }))(
configuration,
) as unknown as ActorSubclass<ActorMethodMappedWithHttpDetails<T>>;
}

private [metadataSymbol]: ActorMetadata;

protected constructor(metadata: ActorMetadata) {
Expand Down Expand Up @@ -318,6 +358,8 @@ const DEFAULT_ACTOR_CONFIG = {

export type ActorConstructor = new (config: ActorConfig) => ActorSubclass;

export const ACTOR_METHOD_WITH_HTTP_DETAILS = 'http-details';

function _createActorMethod(
actor: Actor,
methodName: string,
Expand Down Expand Up @@ -347,7 +389,12 @@ function _createActorMethod(
throw new QueryCallRejectedError(cid, methodName, result);

case QueryResponseStatus.Replied:
return decodeReturnValue(func.retTypes, result.reply.arg);
return func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS)
? {
httpDetails: result.httpDetails,
result: decodeReturnValue(func.retTypes, result.reply.arg),
}
: decodeReturnValue(func.retTypes, result.reply.arg);
}
};
} else {
Expand Down Expand Up @@ -382,11 +429,22 @@ function _createActorMethod(

const pollStrategy = pollingStrategyFactory();
const responseBytes = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify);
const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS);

if (responseBytes !== undefined) {
return decodeReturnValue(func.retTypes, responseBytes);
return shouldIncludeHttpDetails
? {
httpDetails: response,
result: decodeReturnValue(func.retTypes, responseBytes),
}
: decodeReturnValue(func.retTypes, responseBytes);
} else if (func.retTypes.length === 0) {
return undefined;
return shouldIncludeHttpDetails
? {
httpDetails: response,
result: undefined,
}
: undefined;
} else {
throw new Error(`Call was returned undefined, but type [${func.retTypes.join(',')}].`);
}
Expand Down
18 changes: 17 additions & 1 deletion packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Principal } from '@dfinity/principal';
import { RequestId } from '../request_id';
import { JsonObject } from '@dfinity/candid';
import { Identity } from '../auth';
import { HttpHeaderField } from './http/types';

/**
* Codes used by the replica for rejecting a message.
Expand Down Expand Up @@ -35,6 +36,15 @@ export const enum QueryResponseStatus {
Rejected = 'rejected',
}

export interface HttpDetailsResponse {
ok: boolean;
status: number;
statusText: string;
headers: HttpHeaderField[];
}

export type ApiQueryResponse = QueryResponse & { httpDetails: HttpDetailsResponse };

export interface QueryResponseBase {
status: QueryResponseStatus;
}
Expand Down Expand Up @@ -101,6 +111,7 @@ export interface SubmitResponse {
reject_code: number;
reject_message: string;
} | null;
headers: HttpHeaderField[];
};
}

Expand Down Expand Up @@ -161,11 +172,16 @@ export interface Agent {
* @param canisterId The Principal of the Canister to send the query to. Sending a query to
* the management canister is not supported (as it has no meaning from an agent).
* @param options Options to use to create and send the query.
* @param identity Sender principal to use when sending the query.
* @returns The response from the replica. The Promise will only reject when the communication
* failed. If the query itself failed but no protocol errors happened, the response will
* be of type QueryResponseRejected.
*/
query(canisterId: Principal | string, options: QueryFields): Promise<QueryResponse>;
query(
canisterId: Principal | string,
options: QueryFields,
identity?: Identity | Promise<Identity>,
): Promise<ApiQueryResponse>;

/**
* By default, the agent is configured to talk to the main Internet Computer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,10 @@ Object {
"requestId": ArrayBuffer [],
"response": Object {
"body": null,
"headers": Array [],
"ok": true,
"status": 200,
"statusText": "success!",
},
}
`;

exports[`retry failures should throw errors immediately if retryTimes is set to 0 1`] = `
"Server returned an error:
Code: 500 (Internal Server Error)
Body: Error
"
`;
9 changes: 9 additions & 0 deletions packages/agent/src/agent/http/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { HttpDetailsResponse } from '../api';

export class AgentHTTPResponseError extends Error {
constructor(message: string, public readonly response: HttpDetailsResponse) {
super(message);
this.name = this.constructor.name;
Object.setPrototypeOf(this, new.target.prototype);
}
}
Loading

0 comments on commit bd2ab1d

Please sign in to comment.