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

feat: add Agent Manager to utils package #731

Merged
merged 19 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## Features

- Add support for `get_default_subnets` to `@dfinity/cmc`.
- Add class `AgentManager` in `@dfinity/utils` which scope is to manages HttpAgent instances for different identities.
peterpeterparker marked this conversation as resolved.
Show resolved Hide resolved

# 2024.10.09-1140Z

Expand Down
64 changes: 64 additions & 0 deletions packages/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,70 @@ Parameters:

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/services/canister.ts#L4)

### :factory: AgentManager

AgentManager class manages HttpAgent instances for different identities.

It caches agents by identity to optimise resource usage and avoid unnecessary agent creation.
Provides functionality to create new agents, retrieve cached agents, and clear the cache when needed.

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/agent.utils.ts#L53)

#### Methods

- [create](#gear-create)
- [getAgent](#gear-getagent)
- [clearAgents](#gear-clearagents)

##### :gear: create

Static factory method to create a new AgentManager instance.

This method serves as an alternative to directly using the private constructor,
making it more convenient to create instances of `AgentManager` using a simple and clear method.

| Method | Type |
| -------- | ---------------------------------------------- |
| `create` | `(config: AgentManagerConfig) => AgentManager` |

Parameters:

- `config`: - Configuration options for the AgentManager instance.
- `config.fetchRootKey`: - Whether to fetch the root key for certificate validation.
- `config.host`: - The host to connect to.

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/agent.utils.ts#L71)

##### :gear: getAgent

Get or create an HTTP agent for a given identity.

If the agent for the specified identity has been created and cached, it is retrieved from the cache.
If no agent exists for the identity, a new one is created, cached, and then returned.

| Method | Type |
| ---------- | ---------------------------------------------------------------- |
| `getAgent` | `({ identity, }: { identity: Identity; }) => Promise<HttpAgent>` |

Parameters:

- `identity`: - The identity to be used to create the agent.

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/agent.utils.ts#L84)

##### :gear: clearAgents

Clear the cache of HTTP agents.

This method removes all cached agents, forcing new agent creation on the next request for any identity.
Useful when identities have changed or if you want to reset all active connections.

| Method | Type |
| ------------- | ------------ |
| `clearAgents` | `() => void` |

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/agent.utils.ts#L116)

### :factory: InvalidPercentageError

[:link: Source](https://github.com/dfinity/ic-js/tree/main/packages/utils/src/utils/asserts.utils.ts#L1)
Expand Down
39 changes: 39 additions & 0 deletions packages/utils/src/mocks/agent.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { HttpAgent } from "@dfinity/agent";
import { AgentManagerConfig } from "../utils/agent.utils";

export const mockHttpAgent = {
call: jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: "mocked call result" }),
}),
query: jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: "mocked query result" }),
}),
fetchRootKey: jest.fn().mockResolvedValue(undefined),
} as unknown as HttpAgent;

export const mockHttpAgent2 = {
call: jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: "mocked call result" }),
}),
query: jest.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
json: async () => ({ data: "mocked query result" }),
}),
fetchRootKey: jest.fn().mockResolvedValue(undefined),
} as unknown as HttpAgent;

export const mockAgentManagerConfig: AgentManagerConfig = {
fetchRootKey: false,
host: "https://icp-api.io",
};
20 changes: 20 additions & 0 deletions packages/utils/src/mocks/identity.mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Identity } from "@dfinity/agent";
import { Principal } from "@dfinity/principal";

export const mockPrincipalText =
"xlmdg-vkosz-ceopx-7wtgu-g3xmd-koiyc-awqaq-7modz-zf6r6-364rh-oqe";

export const mockPrincipal = Principal.fromText(mockPrincipalText);

export const mockIdentity = {
getPrincipal: () => mockPrincipal,
} as unknown as Identity;

export const mockPrincipalText2 =
"5uuwe-ggtgm-fonrs-rblmx-cfc23-pb3dg-iyfk2-dle5w-j5uev-ggmep-6ae";

export const mockPrincipal2 = Principal.fromText(mockPrincipalText2);

export const mockIdentity2 = {
getPrincipal: () => mockPrincipal2,
} as unknown as Identity;
86 changes: 86 additions & 0 deletions packages/utils/src/utils/agent.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { HttpAgent } from "@dfinity/agent";
import { describe, expect } from "@jest/globals";
import {
mockAgentManagerConfig,
mockHttpAgent,
mockHttpAgent2,
} from "../mocks/agent.mock";
import { mockIdentity, mockIdentity2 } from "../mocks/identity.mock";
import { AgentManager } from "./agent.utils";

jest.mock("@dfinity/agent", () => ({
HttpAgent: {
create: jest.fn(),
},
}));

describe("AgentManager", () => {
let agentManager: AgentManager;
const mockHttpAgentCreate = HttpAgent.create as jest.Mock;

beforeEach(() => {
agentManager = AgentManager.create(mockAgentManagerConfig);
jest.clearAllMocks();

mockHttpAgentCreate.mockResolvedValueOnce(mockHttpAgent);
});

describe("getAgent", () => {
it("should create a new agent when there is none", async () => {
const agent = await agentManager.getAgent({ identity: mockIdentity });

expect(mockHttpAgentCreate).toHaveBeenCalledWith(
expect.objectContaining({ identity: mockIdentity }),
);
expect(agent).toBe(mockHttpAgent);
});

it("should return cached agent if already created", async () => {
await agentManager.getAgent({ identity: mockIdentity });

const agent = await agentManager.getAgent({ identity: mockIdentity });

expect(mockHttpAgentCreate).toHaveBeenCalledTimes(1);
expect(agent).toBe(mockHttpAgent);
});

it("should handle multiple agents for multiple identities", async () => {
await agentManager.getAgent({ identity: mockIdentity });

mockHttpAgentCreate.mockResolvedValueOnce(mockHttpAgent2);

const agent2 = await agentManager.getAgent({ identity: mockIdentity2 });
const agent1 = await agentManager.getAgent({ identity: mockIdentity });

expect(mockHttpAgentCreate).toHaveBeenCalledTimes(2);

expect(agent1).toBe(mockHttpAgent);
expect(agent1).not.toBe(mockHttpAgent2);

expect(agent2).toBe(mockHttpAgent2);
expect(agent2).not.toBe(mockHttpAgent);
});
});

describe("clearAgents", () => {
it("should clear cached agents", async () => {
await agentManager.getAgent({ identity: mockIdentity });

const agentBefore = await agentManager.getAgent({
identity: mockIdentity,
});

expect(agentBefore).toBe(mockHttpAgent);

agentManager.clearAgents();

const agentAfter = await agentManager.getAgent({
identity: mockIdentity,
});

expect(mockHttpAgentCreate).toHaveBeenCalledTimes(2);
expect(agentAfter).not.toBe(mockHttpAgent);
expect(agentAfter).toBeUndefined();
});
});
});
83 changes: 81 additions & 2 deletions packages/utils/src/utils/agent.utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Agent } from "@dfinity/agent";
import type { Agent, Identity } from "@dfinity/agent";
import { AnonymousIdentity, HttpAgent } from "@dfinity/agent";
import type { CreateAgentParams } from "../types/agent.utils";
import { nonNullish } from "./nullish.utils";
import { isNullish, nonNullish } from "./nullish.utils";

/**
* Get a default agent that connects to mainnet with the anonymous identity.
Expand Down Expand Up @@ -38,3 +38,82 @@ export const createAgent = async ({
shouldFetchRootKey: fetchRootKey,
});
};

export type AgentManagerConfig = Pick<
CreateAgentParams,
"fetchRootKey" | "host"
>;

/**
* AgentManager class manages HttpAgent instances for different identities.
*
* It caches agents by identity to optimise resource usage and avoid unnecessary agent creation.
* Provides functionality to create new agents, retrieve cached agents, and clear the cache when needed.
*/
export class AgentManager {
private agents: Record<string, HttpAgent> | undefined | null = undefined;

private constructor(private readonly config: AgentManagerConfig) {
this.config = config;
}
peterpeterparker marked this conversation as resolved.
Show resolved Hide resolved

/**
* Static factory method to create a new AgentManager instance.
*
* This method serves as an alternative to directly using the private constructor,
* making it more convenient to create instances of `AgentManager` using a simple and clear method.
*
* @param {AgentManagerConfig} config - Configuration options for the AgentManager instance.
* @param {boolean} config.fetchRootKey - Whether to fetch the root key for certificate validation.
* @param {string} config.host - The host to connect to.
* @returns {AgentManager} A new instance of `AgentManager`.
*/
public static create(config: AgentManagerConfig): AgentManager {
return new AgentManager(config);
peterpeterparker marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Get or create an HTTP agent for a given identity.
*
* If the agent for the specified identity has been created and cached, it is retrieved from the cache.
* If no agent exists for the identity, a new one is created, cached, and then returned.
*
* @param {Identity} identity - The identity to be used to create the agent.
* @returns {Promise<HttpAgent>} The HttpAgent associated with the given identity.
*/
public async getAgent({
identity,
}: {
identity: Identity;
}): Promise<HttpAgent> {
const key = identity.getPrincipal().toText();

if (isNullish(this.agents) || isNullish(this.agents[key])) {
const agent = await createAgent({
identity,
fetchRootKey: this.config.fetchRootKey,
host: this.config.host,
verifyQuerySignatures: true,
});

this.agents = {
...(this.agents ?? {}),
[key]: agent,
};

return agent;
}

return this.agents[key];
}

/**
* Clear the cache of HTTP agents.
*
* This method removes all cached agents, forcing new agent creation on the next request for any identity.
* Useful when identities have changed or if you want to reset all active connections.
*/
public clearAgents(): void {
this.agents = null;
}
}
Loading