Skip to content

Commit

Permalink
feat: multi-actor config (#916)
Browse files Browse the repository at this point in the history
  • Loading branch information
krpeacock authored Sep 10, 2024
1 parent 7e992e3 commit 19e2f8f
Show file tree
Hide file tree
Showing 10 changed files with 203 additions and 25 deletions.
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added

- feat: allow creation of multiple Actors in `useAuthClient` by passing a record to `actorOptions` with the actor name as the key, and `CreateActorOptions` as the value
- feat: sync_call support in HttpAgent and Actor
- Skips polling if the sync call succeeds and provides a certificate
- Falls back to v2 api if the v3 endpoint 404's
Expand Down
22 changes: 22 additions & 0 deletions packages/use-auth-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@ const App = () => {
}
```

## Multiple Actors

If you have multiple actors, you can pass a record of `actorOptions` to the `useAuthClient` hook. The keys of the record will be the names of the actors, and the values will be the `actorOptions` for that actor. It will look something like this:

```ts
const { isAuthenticated, login, logout, actors } = useAuthClient({
actors: {
actor1: {
canisterId: canisterId1,
idlFactory: idlFactory1,
},
actor2: {
canisterId: canisterId2,
idlFactory: idlFactory2,
},
},
});

const { actor1, actor2 } = actors;

```

There is a live demo at https://5ibdo-haaaa-aaaab-qajia-cai.icp0.io/

Additional generated documentaion is available at https://agent-js.icp.xyz/use-auth-client/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
service : {
whoami: () -> (principal) query;
}
8 changes: 8 additions & 0 deletions packages/use-auth-client/examples/auth-demo/deps/init.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"canisters": {
"ivcos-eqaaa-aaaab-qablq-cai": {
"arg_str": null,
"arg_raw": null
}
}
}
13 changes: 13 additions & 0 deletions packages/use-auth-client/examples/auth-demo/deps/pulled.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"canisters": {
"ivcos-eqaaa-aaaab-qablq-cai": {
"name": "whoami",
"wasm_hash": "a5af74d01aec228c5a717dfb43f773917e1a9138e512431aafcd225ad0001a8b",
"wasm_hash_download": "88f0162fa446ad32e08e7ee513a85a82f145d16a9ebb6d2adfa9bc5ff5605f74",
"init_guide": "null",
"init_arg": null,
"candid_args": "()",
"gzip": false
}
}
}
6 changes: 5 additions & 1 deletion packages/use-auth-client/examples/auth-demo/dfx.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"type": "assets",
"workspace": "auth-demo-frontend"
},
"whoami": {
"type": "pull",
"id": "ivcos-eqaaa-aaaab-qablq-cai"
},
"internet_identity": {
"candid": "https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity.did",
"frontend": {},
Expand All @@ -34,4 +38,4 @@
},
"output_env_file": ".env",
"version": 1
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
import { useState } from 'react';
import { idlFactory, canisterId } from 'declarations/auth-demo-backend';
import { FormEvent, useState } from 'react';
import {
idlFactory as helloFactory,
canisterId as helloId,
} from '../../declarations/auth-demo-backend';
import { _SERVICE as HELLO_SERVICE } from '../../declarations/auth-demo-backend/auth-demo-backend.did';
import {
idlFactory as whoamiFactory,
canisterId as whoamiId,
} from '../../declarations/auth-demo-backend';
import { _SERVICE as WHOAMI_SERVICE } from '../../declarations/auth-demo-backend/auth-demo-backend.did';
import { useAuthClient } from '../../../../../src/index';
import type { ActorSubclass } from '@dfinity/agent';
import IILogo from './IILogo.svg';

export interface ProcessEnv {
[key: string]: string | undefined;
}

declare var process: {
env: ProcessEnv;
};

/**
*
*
* @returns app
*/
function App() {
Expand All @@ -15,23 +33,34 @@ function App() {
`http://${process.env.CANISTER_ID_INTERNET_IDENTITY}.localhost:4943`
: 'https://identity.ic0.app';

const { isAuthenticated, login, logout, actor } = useAuthClient({
const { isAuthenticated, login, logout, actors } = useAuthClient({
loginOptions: {
identityProvider,
},
actorOptions: {
canisterId,
idlFactory,
whoami_canister: {
canisterId: whoamiId,
idlFactory: whoamiFactory,
},
greet_canister: {
canisterId: helloId,
idlFactory: helloFactory,
},
},
});

const { whoami_canister, greet_canister } = actors as unknown as {
whoami_canister: ActorSubclass<WHOAMI_SERVICE>;
greet_canister: ActorSubclass<HELLO_SERVICE>;
};

const [greeting, setGreeting] = useState('');
const [whoamiText, setWhoamiText] = useState('');

function handleSubmit(event) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const name = event.target.elements.name.value;
actor.greet(name).then(greeting => {
const name = (event.target as HTMLFormElement).querySelector('input')?.value || '';
greet_canister.greet(name).then(greeting => {
setGreeting(greeting);
});
return false;
Expand Down Expand Up @@ -61,8 +90,8 @@ function App() {
<p>{isAuthenticated ? 'You are logged in' : 'You are not logged in'}</p>
<button
onClick={async () => {
const whoami = await actor.whoami();
setWhoamiText(whoami);
const whoami = await whoami_canister.whoami();
setWhoamiText(whoami.toString());
}}
>
Whoami
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": ["vite/client"]
"types": ["vite/client", "./src/vite-env.d.ts"]
},
"include": ["src"]
}
63 changes: 54 additions & 9 deletions packages/use-auth-client/src/use-auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ActorConfig,
HttpAgent,
Actor,
ActorSubclass,
} from '@dfinity/agent';
import type { IDL } from '@dfinity/candid';
import { Principal } from '@dfinity/principal';
Expand Down Expand Up @@ -39,6 +40,7 @@ export interface CreateActorOptions {
* Options for the useAuthClient hook
*/
export type UseAuthClientOptions = {
createSync?: boolean;
/**
* Options passed during the creation of the auth client
*/
Expand All @@ -50,7 +52,7 @@ export type UseAuthClientOptions = {
/**
* Options to create an actor using the auth client identity
*/
actorOptions?: CreateActorOptions;
actorOptions?: CreateActorOptions | Record<string, CreateActorOptions>;
};

/**
Expand All @@ -63,7 +65,8 @@ export type UseAuthClientOptions = {
export function useAuthClient(options?: UseAuthClientOptions) {
const [authClient, setAuthClient] = React.useState<AuthClient | null>(null);
const [identity, setIdentity] = React.useState<Identity | null>(null);
const [actor, setActor] = React.useState<Actor | null>(null);
const [actor, setActor] = React.useState<ActorSubclass | null>();
const [actors, setActors] = React.useState<Record<string, ActorSubclass>>({});
const [isAuthenticated, setIsAuthenticated] = React.useState<boolean>(false);

// load the auth client on mount
Expand All @@ -87,12 +90,33 @@ export function useAuthClient(options?: UseAuthClientOptions) {

React.useEffect(() => {
if (identity && options?.actorOptions) {
createActor({
...options.actorOptions,
agentOptions: { ...options?.actorOptions?.agentOptions, identity },
}).then(actor => {
setActor(actor);
});
// if the options is for a single actor, it will have a canisterId
if ('canisterId' in options.actorOptions) {
const createActorOptions = options.actorOptions as CreateActorOptions;
createActor({
...createActorOptions,
agentOptions: { ...createActorOptions?.agentOptions, identity },
}).then(actor => {
// set the actor service

setActor(actor);
});
} else {
// if the options is for multiple actors, it will have a key value pair of an identifier and CreateActorOptions
const actorOptions = options.actorOptions as Record<string, CreateActorOptions>;
const actorPromises = Object.entries(actorOptions).map(
async ([canisterId, createActorOptions]) => {
const actor = await createActor({
...createActorOptions,
agentOptions: { ...createActorOptions?.agentOptions, identity },
});
return [canisterId, actor];
},
);
Promise.all(actorPromises).then(actors => {
setActors(Object.fromEntries(actors));
});
}
}
}, [identity]);

Expand Down Expand Up @@ -132,12 +156,32 @@ export function useAuthClient(options?: UseAuthClientOptions) {
setIsAuthenticated(false);
setIdentity(null);
await authClient.logout();
setActor(await createActor(options?.actorOptions));
if (options?.actorOptions) {
if ('canisterId' in options.actorOptions) {
setActor(await createActor(options.actorOptions as CreateActorOptions));
} else {
const actorOptions = options.actorOptions as Record<string, CreateActorOptions>;
const actorPromises = Object.entries(actorOptions).map(
async ([canisterId, createActorOptions]) => {
// Initialize with anonymous identity
const actor = await createActor(createActorOptions);
return [canisterId, actor];
},
);
Promise.all(actorPromises).then(actors => {
setActors(Object.fromEntries(actors));
});
}
} else {
setActor(null);
setActors({});
}
}
}

return {
actor,
actors,
authClient,
identity,
isAuthenticated,
Expand All @@ -147,6 +191,7 @@ export function useAuthClient(options?: UseAuthClientOptions) {
}

const createActor = async (options: CreateActorOptions) => {
options;
const agent = options.agent || (await HttpAgent.create({ ...options.agentOptions }));

if (options.agent && options.agentOptions) {
Expand Down
59 changes: 56 additions & 3 deletions packages/use-auth-client/test/use-auth-client.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, cleanup } from '@testing-library/react';
import { useAuthClient } from '../src/use-auth-client';
import { describe, it } from '@jest/globals';
import { describe, it, jest, afterEach } from '@jest/globals';

afterEach(cleanup);

describe('useAuthClient', () => {
it('should return an authClient object with the expected properties', async () => {
Expand All @@ -18,7 +20,6 @@ describe('useAuthClient', () => {
</div>
);
};
// disable typescript
render(<Component />);

(
Expand All @@ -27,4 +28,56 @@ describe('useAuthClient', () => {
}
).toHaveTextContent('false');
});
it('should support configs for multiple actors', async () => {
interface Props {
children?: React.ReactNode;
}

const idlFactory = ({ IDL }) => {
return IDL.Service({ whoami: IDL.Func([], [IDL.Principal], ['query']) });
};
const Component: React.FC<Props> = () => {
const { actors } = useAuthClient({
actorOptions: {
actor1: {
agentOptions: {
fetch: jest.fn() as typeof globalThis.fetch,
},
canisterId: 'rrkah-fqaaa-aaaaa-aaaaq-cai',
idlFactory,
},
actor2: {
agentOptions: {
fetch: jest.fn() as typeof globalThis.fetch,
},
canisterId: 'rrkah-fqaaa-aaaaa-aaaaq-cai',
idlFactory,
},
},
});
return (
<div>
<div data-testid="actors">{JSON.stringify(actors)}</div>
</div>
);
};
render(<Component />);

(
expect(screen.getByTestId('actors')) as unknown as {
toHaveTextContent: (str: string) => boolean;
}
).toHaveTextContent('{}');

jest.useRealTimers();

// wait for the actors to be set
await new Promise(resolve => setTimeout(resolve, 1000));

(
expect(screen.getByTestId('actors')) as unknown as {
toHaveTextContent: (str: string) => boolean;
}
).toHaveTextContent('{"actor1":{},"actor2":{}}');
});
});

0 comments on commit 19e2f8f

Please sign in to comment.