Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: updating Agent generic interface #536

Merged
merged 3 commits into from Mar 23, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/agent/README.md
Expand Up @@ -33,3 +33,33 @@ import { Actor, HttpAgent } from "@dfinity/agent";
```
const actor = require("@dfinity/agent");
```

## Using an Agent

The agent is a low-level interface that the Actor uses to encode and decode messages to the Internet Computer. It provides `call`, `query` and `readState` methods to the Actor, as well as a few additional utilities. For the most part, calls through the agent are intended to be structured through an Actor, configured with a canister interface that can be automatically generated from a [Candid](https://github.com/dfinity/candid) interface.

## Initializing an Actor

The most common use for the agent is to create an actor. This is done by calling the `Actor.createActor` constructor:

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

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.

### Inspecting an actor's agent

Use the `Actor.agentOf` method to get the agent of an actor:

```
const defaultAgent = Actor.agentOf(defaultActor);
```

This is useful if you need to replace or invalidate the identity used by an actor's agent.

For example, if you want to replace the identity of an actor's agent with a newly authenticated identity from [Internet Identity](https://identity.ic0.app), you can do so by calling the `Actor.replaceAgent` method:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does this identity comes from in the first place? Is an identity used when creating an actor for the first time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, an AnonymousIdentity is initialized during the constructor if no other identity is provided


```
defaultAgent.replaceIdentity(await authClient.getIdentity());
```
286 changes: 155 additions & 131 deletions packages/agent/src/actor.test.ts
Expand Up @@ -15,112 +15,145 @@ afterEach(() => {
global.Date.now = originalDateNowFn;
});

test.skip('makeActor', async () => {
const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text]),
});
};

const expectedReplyArg = IDL.encode([IDL.Text], ['Hello, World!']);

const mockFetch: jest.Mock = jest
.fn()
.mockImplementationOnce((/*resource, init*/) => {
return Promise.resolve(
new Response(null, {
status: 202,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({ status: 'received' });
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({ status: 'processing' });
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({
status: 'replied',
reply: {
arg: expectedReplyArg,
},
describe('makeActor', () => {
// TODO: update tests to be compatible with changes to Certificate
it.skip('should encode calls', async () => {
const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text]),
});
};

const expectedReplyArg = IDL.encode([IDL.Text], ['Hello, World!']);

const mockFetch: jest.Mock = jest
.fn()
.mockImplementationOnce((/*resource, init*/) => {
return Promise.resolve(
new Response(null, {
status: 202,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({ status: 'received' });
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({ status: 'processing' });
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
})
.mockImplementationOnce((resource, init) => {
const body = cbor.encode({
status: 'replied',
reply: {
arg: expectedReplyArg,
},
});
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
});
return Promise.resolve(
new Response(body, {
status: 200,
}),
);
});

const methodName = 'greet';
const argValue = 'Name';
const methodName = 'greet';
const argValue = 'Name';

const arg = IDL.encode([IDL.Text], [argValue]);
const arg = IDL.encode([IDL.Text], [argValue]);

const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c');
const principal = await Principal.anonymous();
const sender = principal.toUint8Array();
const canisterId = Principal.fromText('2chl6-4hpzw-vqaaa-aaaaa-c');
const principal = await Principal.anonymous();
const sender = principal.toUint8Array();

const nonces = [
new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce,
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]) as Nonce,
new Uint8Array([2, 3, 4, 5, 6, 7, 8, 9]) as Nonce,
new Uint8Array([3, 4, 5, 6, 7, 8, 9, 0]) as Nonce,
new Uint8Array([4, 5, 6, 7, 8, 9, 0, 1]) as Nonce,
];
const nonces = [
new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce,
new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]) as Nonce,
new Uint8Array([2, 3, 4, 5, 6, 7, 8, 9]) as Nonce,
new Uint8Array([3, 4, 5, 6, 7, 8, 9, 0]) as Nonce,
new Uint8Array([4, 5, 6, 7, 8, 9, 0, 1]) as Nonce,
];

const expectedCallRequest = {
content: {
request_type: SubmitRequestType.Call,
canister_id: canisterId,
method_name: methodName,
arg,
nonce: nonces[0],
sender,
ingress_expiry: new Expiry(300000),
},
} as UnSigned<CallRequest>;
const expectedCallRequest = {
content: {
request_type: SubmitRequestType.Call,
canister_id: canisterId,
method_name: methodName,
arg,
nonce: nonces[0],
sender,
ingress_expiry: new Expiry(300000),
},
} as UnSigned<CallRequest>;

const expectedCallRequestId = await requestIdOf(expectedCallRequest.content);

const expectedCallRequestId = await requestIdOf(expectedCallRequest.content);
let nonceCount = 0;

let nonceCount = 0;
const httpAgent = new HttpAgent({ fetch: mockFetch });
httpAgent.addTransform(makeNonceTransform(() => nonces[nonceCount++]));

const httpAgent = new HttpAgent({ fetch: mockFetch });
httpAgent.addTransform(makeNonceTransform(() => nonces[nonceCount++]));
const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });
const reply = await actor.greet(argValue);

const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });
const reply = await actor.greet(argValue);
expect(reply).toEqual(IDL.decode([IDL.Text], expectedReplyArg)[0]);

expect(reply).toEqual(IDL.decode([IDL.Text], expectedReplyArg)[0]);
const { calls } = mockFetch.mock;

const { calls } = mockFetch.mock;
expect(calls.length).toBe(5);
expect(calls[0]).toEqual([
`http://localhost/api/v2/canister/${canisterId.toText()}/call`,
{
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode(expectedCallRequest),
},
]);

expect(calls[1]).toEqual([
`http://localhost/api/v2/canister/${canisterId.toText()}/read_state`,
{
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
},
}),
},
]);

expect(calls.length).toBe(5);
expect(calls[0]).toEqual([
`http://localhost/api/v2/canister/${canisterId.toText()}/call`,
{
expect(calls[2][0]).toBe('http://localhost/api/v1/read');
expect(calls[2][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode(expectedCallRequest),
},
]);
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
},
}),
});

expect(calls[1]).toEqual([
`http://localhost/api/v2/canister/${canisterId.toText()}/read_state`,
{
expect(calls[3][0]).toBe('http://localhost/api/v1/read');
expect(calls[3][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
Expand All @@ -132,52 +165,43 @@ test.skip('makeActor', async () => {
ingress_expiry: new Expiry(300000),
},
}),
},
]);

expect(calls[2][0]).toBe('http://localhost/api/v1/read');
expect(calls[2][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
},
}),
});
});

expect(calls[3][0]).toBe('http://localhost/api/v1/read');
expect(calls[3][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
expect(calls[4][0]).toBe('http://localhost/api/v1/read');
expect(calls[4][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
}),
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
},
}),
});
});

expect(calls[4][0]).toBe('http://localhost/api/v1/read');
expect(calls[4][1]).toEqual({
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
},
body: cbor.encode({
content: {
request_type: 'request_status',
request_id: expectedCallRequestId,
ingress_expiry: new Expiry(300000),
},
}),
it('should allow its agent to be invalidated', async () => {
const mockFetch = jest.fn();
const actorInterface = () => {
return IDL.Service({
greet: IDL.Func([IDL.Text], [IDL.Text]),
});
};
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 });

Actor.agentOf(actor).invalidateIdentity();

try {
await actor.greet('test');
} catch (error) {
expect(error.message).toBe(
"This identity has expired due this application's security policy. Please refresh your authentication.",
);
}
});
});

Expand Down
17 changes: 17 additions & 0 deletions packages/agent/src/agent/api.ts
@@ -1,6 +1,7 @@
import { Principal } from '@dfinity/principal';
import { RequestId } from '../request_id';
import { JsonObject } from '@dfinity/candid';
import { Identity } from '..';

/**
* Codes used by the replica for rejecting a message.
Expand Down Expand Up @@ -158,4 +159,20 @@ export interface Agent {
* function by default.
*/
fetchRootKey(): Promise<ArrayBuffer>;
/**
* If an application needs to invalidate an identity under certain conditions, an `Agent` may expose an `invalidateIdentity` method.
* Invoking this method will set the inner identity used by the `Agent` to `null`.
*
* A use case for this would be - after a certain period of inactivity, a secure application chooses to invalidate the identity of any `HttpAgent` instances. An invalid identity can be replaced by `Agent.replaceIdentity`
*/
invalidateIdentity?(): void;
/**
* If an application needs to replace an identity under certain conditions, an `Agent` may expose a `replaceIdentity` method.
* Invoking this method will set the inner identity used by the `Agent` to a newly provided identity.
*
* A use case for this would be - after authenticating using `@dfinity/auth-client`, you can replace the `AnonymousIdentity` of your `Actor` with a `DelegationIdentity`.
*
* ```Actor.agentOf(defaultActor).replaceIdentity(await authClient.getIdentity());```
*/
replaceIdentity?(identity: Identity): void;
}