Skip to content

Commit f8e8b24

Browse files
authored
Merge pull request #1609 from input-output-hk/feat/restore-kora-labs-handle-provider
LW-12083 feat(cardano-services-client): restore KoraLabsHandleProvider
2 parents 91a31d9 + d81aaeb commit f8e8b24

File tree

11 files changed

+342
-0
lines changed

11 files changed

+342
-0
lines changed

.github/workflows/continuous-integration-unit-tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ jobs:
4242
- name: 🔬 Test
4343
run: |
4444
yarn test --forceExit
45+
yarn test:handle
4546
env:
4647
NODE_OPTIONS: '--max_old_space_size=8192'

packages/cardano-services-client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"@cardano-sdk/core": "workspace:~",
6161
"@cardano-sdk/crypto": "workspace:~",
6262
"@cardano-sdk/util": "workspace:~",
63+
"@koralabs/handles-public-api-interfaces": "^2.13.0",
6364
"axios": "^1.7.4",
6465
"class-validator": "^0.14.0",
6566
"isomorphic-ws": "^5.0.0",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import {
2+
Asset,
3+
Cardano,
4+
HandleProvider,
5+
HandleResolution,
6+
HealthCheckResponse,
7+
ProviderError,
8+
ProviderFailure,
9+
ResolveHandlesArgs
10+
} from '@cardano-sdk/core';
11+
12+
// eslint-disable-next-line import/no-extraneous-dependencies
13+
import { IHandle } from '@koralabs/handles-public-api-interfaces';
14+
import axios, { AxiosAdapter, AxiosInstance } from 'axios';
15+
16+
/** The KoraLabsHandleProvider endpoint paths. */
17+
const paths = {
18+
handles: '/handles',
19+
healthCheck: '/health'
20+
};
21+
22+
export interface KoraLabsHandleProviderDeps {
23+
serverUrl: string;
24+
adapter?: AxiosAdapter;
25+
policyId: Cardano.PolicyId;
26+
}
27+
28+
export const toHandleResolution = ({
29+
apiResponse,
30+
policyId
31+
}: {
32+
apiResponse: IHandle;
33+
policyId: Cardano.PolicyId;
34+
}): HandleResolution => ({
35+
backgroundImage: apiResponse.bg_image ? Asset.Uri(apiResponse.bg_image) : undefined,
36+
cardanoAddress: Cardano.PaymentAddress(apiResponse.resolved_addresses.ada),
37+
handle: apiResponse.name,
38+
hasDatum: apiResponse.has_datum,
39+
image: apiResponse.image ? Asset.Uri(apiResponse.image) : undefined,
40+
policyId,
41+
profilePic: apiResponse.pfp_image ? Asset.Uri(apiResponse.pfp_image) : undefined
42+
});
43+
44+
/**
45+
* Creates a KoraLabs Provider instance to resolve Standard Handles
46+
*
47+
* @param KoraLabsHandleProviderDeps The configuration object fot the KoraLabs Handle Provider.
48+
*/
49+
export class KoraLabsHandleProvider implements HandleProvider {
50+
private axiosClient: AxiosInstance;
51+
policyId: Cardano.PolicyId;
52+
53+
constructor({ serverUrl, adapter, policyId }: KoraLabsHandleProviderDeps) {
54+
this.axiosClient = axios.create({
55+
adapter,
56+
baseURL: serverUrl
57+
});
58+
this.policyId = policyId;
59+
}
60+
61+
resolveHandles({ handles }: ResolveHandlesArgs): Promise<Array<HandleResolution | null>> {
62+
// eslint-disable-next-line unicorn/consistent-function-scoping
63+
const resolveHandle = async (handle: string) => {
64+
try {
65+
const { data } = await this.axiosClient.get<IHandle>(`${paths.handles}/${handle}`);
66+
67+
return toHandleResolution({ apiResponse: data, policyId: this.policyId });
68+
} catch (error) {
69+
if (error instanceof ProviderError) throw error;
70+
if (axios.isAxiosError(error)) {
71+
if (error.response?.status === 404) return null;
72+
if (error.request) throw new ProviderError(ProviderFailure.ConnectionFailure, error, error.code);
73+
74+
throw new ProviderError(
75+
ProviderFailure.Unhealthy,
76+
error,
77+
`Failed to resolve handles due to: ${error.message}`
78+
);
79+
}
80+
81+
throw new ProviderError(ProviderFailure.Unknown, error, 'Failed to resolve handles');
82+
}
83+
};
84+
85+
return Promise.all(handles.map((handle) => resolveHandle(handle)));
86+
}
87+
88+
async healthCheck(): Promise<HealthCheckResponse> {
89+
try {
90+
await this.axiosClient.get(`${paths.healthCheck}`);
91+
return { ok: true };
92+
} catch {
93+
return { ok: false };
94+
}
95+
}
96+
97+
async getPolicyIds(): Promise<Cardano.PolicyId[]> {
98+
return [this.policyId];
99+
}
100+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './KoraLabsHandleProvider';
12
export * from './handleHttpProvider';
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/* eslint-disable no-magic-numbers */
2+
/* eslint-disable camelcase */
3+
import { Cardano, ProviderError } from '@cardano-sdk/core';
4+
import { KoraLabsHandleProvider } from '../../src';
5+
import {
6+
getAliceHandleAPIResponse,
7+
getAliceHandleProviderResponse,
8+
getBobHandleAPIResponse,
9+
getBobHandleProviderResponse
10+
} from '../util';
11+
import MockAdapter from 'axios-mock-adapter';
12+
import axios from 'axios';
13+
14+
const config = {
15+
policyId: Cardano.PolicyId('50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb'),
16+
serverUrl: 'http://some-hostname:3000'
17+
};
18+
19+
describe('KoraLabsHandleProvider', () => {
20+
let axiosMock: MockAdapter;
21+
let provider: KoraLabsHandleProvider;
22+
23+
beforeAll(() => {
24+
axiosMock = new MockAdapter(axios);
25+
provider = new KoraLabsHandleProvider(config);
26+
});
27+
28+
afterEach(() => {
29+
axiosMock.reset();
30+
});
31+
32+
afterAll(() => {
33+
axiosMock.restore();
34+
});
35+
36+
describe('resolveHandles', () => {
37+
test('HandleProvider should resolve a single handle', async () => {
38+
axiosMock.onGet().replyOnce(200, getAliceHandleAPIResponse);
39+
const args = {
40+
handles: ['alice']
41+
};
42+
await expect(provider.resolveHandles(args)).resolves.toEqual([getAliceHandleProviderResponse]);
43+
});
44+
45+
test('HandleProvider should resolve multiple handles', async () => {
46+
axiosMock.onGet().replyOnce(200, getAliceHandleAPIResponse).onGet().replyOnce(200, getBobHandleAPIResponse);
47+
const args = {
48+
handles: ['alice', 'bob']
49+
};
50+
await expect(provider.resolveHandles(args)).resolves.toEqual([
51+
getAliceHandleProviderResponse,
52+
getBobHandleProviderResponse
53+
]);
54+
});
55+
});
56+
57+
describe('error checks', () => {
58+
test('HandleProvider should throw ProviderError with ConnectionFailure on request error', async () => {
59+
axiosMock.onGet('/handles/alice').networkError();
60+
const args = { handles: ['alice'] };
61+
await expect(provider.resolveHandles(args)).rejects.toThrowError(ProviderError);
62+
});
63+
test('HandleProvider should return null for 404 response from API', async () => {
64+
axiosMock.onGet('/handles/alice').reply(404);
65+
const args = { handles: ['alice'] };
66+
await expect(provider.resolveHandles(args)).resolves.toEqual([null]);
67+
});
68+
test('HandleProvider should throw ProviderError with Unhealthy on other Axios error', async () => {
69+
axiosMock.onGet('/handles/bob').reply(500);
70+
const args = { handles: ['bob'] };
71+
await expect(provider.resolveHandles(args)).rejects.toThrowError(ProviderError);
72+
});
73+
test('HandleProvider should throw ProviderError', async () => {
74+
axiosMock.onGet('/handles/bob').networkError();
75+
const args = { handles: ['bob'] };
76+
await expect(provider.resolveHandles(args)).rejects.toThrowError(ProviderError);
77+
});
78+
test('HandleProvider should throw ProviderError with Unknown, unable to resolve handle', async () => {
79+
axiosMock.onGet().replyOnce(304, getAliceHandleAPIResponse);
80+
const args = { handles: ['bob'] };
81+
await expect(provider.resolveHandles(args)).rejects.toThrowError(ProviderError);
82+
});
83+
});
84+
85+
describe('health checks', () => {
86+
test('HandleProvider should get ok health check', async () => {
87+
axiosMock.onGet().replyOnce(200, {});
88+
const result = await provider.healthCheck();
89+
expect(result.ok).toEqual(true);
90+
});
91+
92+
test('HandleProvider should get not ok health check', async () => {
93+
const providerWithBadConfig = new KoraLabsHandleProvider({
94+
policyId: Cardano.PolicyId('50fdcdbfa3154db86a87e4b5697ae30d272e0bbcfa8122efd3e301cb'),
95+
serverUrl: ''
96+
});
97+
const result = await providerWithBadConfig.healthCheck();
98+
expect(result.ok).toEqual(false);
99+
});
100+
});
101+
102+
describe('get policy ids', () => {
103+
test('HandleProvider should get handle policy ids', async () => {
104+
const policyIds = await provider.getPolicyIds();
105+
106+
expect(policyIds.length).toEqual(1);
107+
expect(policyIds).toEqual([config.policyId]);
108+
});
109+
});
110+
});

packages/cardano-services-client/test/util.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Asset, Cardano, ProviderError, ProviderFailure } from '@cardano-sdk/core';
22
import { AxiosError, AxiosResponse } from 'axios';
3+
import { IHandle, Rarity } from '@koralabs/handles-public-api-interfaces';
34
import { logger } from '@cardano-sdk/util-dev';
45
import { toSerializableObject } from '@cardano-sdk/util';
56

@@ -61,6 +62,50 @@ export const getBobHandleProviderResponse = {
6162
profilePic: Asset.Uri('ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd1')
6263
};
6364

65+
export const getAliceHandleAPIResponse: Partial<IHandle> = {
66+
characters: 'rljm7n/23455',
67+
created_slot_number: 33,
68+
default_in_wallet: 'alice_default_hndle',
69+
has_datum: false,
70+
hex: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5',
71+
holder: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
72+
image: 'ipfs://c8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56feasd',
73+
length: 123,
74+
name: 'alice',
75+
numeric_modifiers: '-12.9',
76+
og_number: 5,
77+
rarity: Rarity.rare,
78+
resolved_addresses: {
79+
ada: 'addr_test1qqk4sr4f7vtqzd2w90d5nfu3n59jhhpawyphnek2y7er02nkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuqmcnecd'
80+
},
81+
standard_image: 'ipfs://c8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56feasdfasd',
82+
updated_slot_number: 22,
83+
utxo: 'rljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0'
84+
};
85+
86+
export const getBobHandleAPIResponse: Partial<IHandle> = {
87+
bg_image: 'ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd',
88+
characters: 'rljm7n/23455',
89+
created_slot_number: 33,
90+
default_in_wallet: 'bob_default_handle',
91+
has_datum: false,
92+
hex: '0f3abbc8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe0e78f19d9d5',
93+
holder: 'stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw',
94+
image: 'ipfs://c8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56fe',
95+
length: 123,
96+
name: 'bob',
97+
numeric_modifiers: '-12.9',
98+
og_number: 5,
99+
pfp_image: 'ipfs://zrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3yd1',
100+
rarity: Rarity.rare,
101+
resolved_addresses: {
102+
ada: 'addr_test1qzrljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0e7e2jvzg443h0ffzfwd09wpcxy2fuql9tk0g'
103+
},
104+
standard_image: 'ipfs://c8fc19c2e61bab6059bf8a466e6e754833a08a62a6c56feasdfasd',
105+
updated_slot_number: 22,
106+
utxo: 'rljm7nskakjydxlr450ktsj08zuw6aktvgfkmmyw9semrkrezryq3ydtmkg0'
107+
};
108+
64109
export const mockResponses = (request: jest.Mock, responses: [string | RegExp, unknown][]) => {
65110
request.mockImplementation(async (endpoint: string) => {
66111
for (const [match, response] of responses) {

packages/e2e/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module.exports = {
2727
projects: [
2828
{ ...project('blockfrost'), globalSetup: './test/blockfrost/setup.ts' },
2929
project('blockfrost-providers'),
30+
project('handle'),
3031
project('local-network'),
3132
project('long-running'),
3233
project('ogmios'),

packages/e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"test": "echo 'test' command not implemented yet",
2525
"test:blockfrost": "jest -c jest.config.js --forceExit --selectProjects blockfrost --runInBand --verbose",
2626
"test:utils": "jest -c jest.config.js --forceExit --selectProjects utils --verbose",
27+
"test:handle": "jest -c jest.config.js --forceExit --selectProjects handle --runInBand --verbose",
2728
"test:long-running": "jest -c jest.config.js --forceExit --selectProjects long-running --runInBand --verbose",
2829
"test:local-network": "jest -c jest.config.js --forceExit --selectProjects local-network --runInBand --verbose",
2930
"test:projection": "jest -c jest.config.js --forceExit --selectProjects projection --runInBand --verbose",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/* eslint-disable no-magic-numbers */
2+
/* eslint-disable camelcase */
3+
import { Cardano, HandleResolution } from '@cardano-sdk/core';
4+
import { KoraLabsHandleProvider } from '@cardano-sdk/cardano-services-client';
5+
6+
const handlePolicyId = 'f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a';
7+
8+
const config = {
9+
policyId: Cardano.PolicyId(handlePolicyId),
10+
serverUrl: 'https://preprod.api.handle.me/'
11+
};
12+
13+
const checkHandleResolution = (source: string, result: unknown) => {
14+
expect(typeof result).toBe('object');
15+
16+
const { backgroundImage, cardanoAddress, handle, hasDatum, image, policyId, profilePic } = result as HandleResolution;
17+
18+
expect(['string', 'undefined']).toContain(typeof backgroundImage);
19+
expect(typeof cardanoAddress).toBe('string');
20+
expect(cardanoAddress.startsWith('addr_')).toBe(true);
21+
expect(handle).toBe(source);
22+
expect(typeof hasDatum).toBe('boolean');
23+
expect(typeof image).toBe('string');
24+
expect(policyId).toBe(handlePolicyId);
25+
expect(['string', 'undefined']).toContain(typeof profilePic);
26+
};
27+
28+
describe('KoraLabsHandleProvider', () => {
29+
let provider: KoraLabsHandleProvider;
30+
31+
beforeAll(() => {
32+
provider = new KoraLabsHandleProvider(config);
33+
});
34+
35+
describe('resolveHandles', () => {
36+
test('HandleProvider should resolve a single handle', async () => {
37+
const [result] = await provider.resolveHandles({ handles: ['test_handle_1'] });
38+
39+
checkHandleResolution('test_handle_1', result);
40+
});
41+
42+
test('HandleProvider should resolve multiple handles', async () => {
43+
const [result2, result3] = await provider.resolveHandles({ handles: ['test_handle_2', 'test_handle_3'] });
44+
45+
checkHandleResolution('test_handle_2', result2);
46+
checkHandleResolution('test_handle_3', result3);
47+
});
48+
49+
test('HandleProvider should return null for for not found handle', async () => {
50+
const [resultN, result1] = await provider.resolveHandles({ handles: ['does_not_exists', 'test_handle_1'] });
51+
52+
expect(resultN).toBe(null);
53+
checkHandleResolution('test_handle_1', result1);
54+
});
55+
});
56+
57+
describe('health checks', () => {
58+
test('HandleProvider should get ok health check', async () => {
59+
const result = await provider.healthCheck();
60+
61+
expect(result.ok).toEqual(true);
62+
});
63+
});
64+
65+
describe('get policy ids', () => {
66+
test('HandleProvider should get handle policy ids', async () => {
67+
const policyIds = await provider.getPolicyIds();
68+
69+
expect(policyIds.length).toEqual(1);
70+
expect(policyIds).toEqual([config.policyId]);
71+
});
72+
});
73+
});

yarn-project.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ cacheEntries = {
493493
"@jridgewell/trace-mapping@npm:0.3.25" = { filename = "@jridgewell-trace-mapping-npm-0.3.25-c076fd2279-9d3c40d225.zip"; sha512 = "9d3c40d225e139987b50c48988f8717a54a8c994d8a948ee42e1412e08988761d0754d7d10b803061cc3aebf35f92a5dbbab493bd0e1a9ef9e89a2130e83ba34"; };
494494
"@jridgewell/trace-mapping@npm:0.3.9" = { filename = "@jridgewell-trace-mapping-npm-0.3.9-91625cd7fb-d89597752f.zip"; sha512 = "d89597752fd88d3f3480845691a05a44bd21faac18e2185b6f436c3b0fd0c5a859fbbd9aaa92050c4052caf325ad3e10e2e1d1b64327517471b7d51babc0ddef"; };
495495
"@jsdevtools/ono@npm:7.1.3" = { filename = "@jsdevtools-ono-npm-7.1.3-cb2313543b-2297fcd472.zip"; sha512 = "2297fcd472ba810bffe8519d2249171132844c7174f3a16634f9260761c8c78bc0428a4190b5b6d72d45673c13918ab9844d706c3ed4ef8f62ab11a2627a08ad"; };
496+
"@koralabs/handles-public-api-interfaces@npm:2.13.0" = { filename = "@koralabs-handles-public-api-interfaces-npm-2.13.0-33d8afa484-b28895a70e.zip"; sha512 = "b28895a70ecdb1b4643953dd0af11b406970a5388b79889050f19ef01335c7da29fb8696d26e80f645fd435ca8cc90f13b5fed0c4a5ceb0ac0d2447aad5e3e06"; };
496497
"@ledgerhq/devices@npm:8.4.4" = { filename = "@ledgerhq-devices-npm-8.4.4-9331403bf0-370fb38d48.zip"; sha512 = "370fb38d484665c92165580e285cc792e7af0bf114a5d1e855aec602c6e39592090d0de7a43addeb4c13622f734ddd4f25be82f07507e14550753ce5473eea66"; };
497498
"@ledgerhq/errors@npm:6.19.1" = { filename = "@ledgerhq-errors-npm-6.19.1-4837ba7170-f4e1cf0d6a.zip"; sha512 = "f4e1cf0d6a5808c58235c54e2a1565556de8e683552bd94d97609f790e8c25226114bfa2af9914bb329c3e7c314585b60c66ddac8d8f8f1d072b7e791a889ad8"; };
498499
"@ledgerhq/hw-transport-node-hid-noevents@npm:6.30.5" = { filename = "@ledgerhq-hw-transport-node-hid-noevents-npm-6.30.5-19d7e6c1d0-013d7d6745.zip"; sha512 = "013d7d674573b838b3e2d6a750a8e48a1f1722d986c272513de28f68c5a203576e2b39beea1f0ea7ca5e348421b50f79d8af1a30774068479b1fa6526662eae1"; };

0 commit comments

Comments
 (0)