Skip to content

Commit de59fcb

Browse files
committed
feat: browser compatible BlockfrostAssetProvider
replace BlockfrostAssetProvider in cardano-services with a new one that works in the browser implementation was hoisted from 'lace' repo BREAKING CHANGE: hoist BlockfrostAssetProvider to cardano-services-client
1 parent 3921646 commit de59fcb

File tree

23 files changed

+670
-355
lines changed

23 files changed

+670
-355
lines changed

packages/cardano-services-client/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@
4040
},
4141
"devDependencies": {
4242
"@cardano-sdk/util-dev": "workspace:~",
43+
"@types/lodash": "^4.14.182",
44+
"@types/node-fetch": "^2.6.12",
4345
"@types/validator": "^13.7.1",
4446
"axios-mock-adapter": "^2.0.0",
4547
"eslint": "^7.32.0",
4648
"express": "^4.17.3",
4749
"get-port-please": "^2.5.0",
4850
"jest": "^28.1.3",
4951
"madge": "^5.0.1",
52+
"node-fetch": "2",
5053
"npm-run-all": "^4.1.5",
5154
"ts-jest": "^28.0.7",
5255
"tsc-alias": "^1.8.10",
@@ -59,6 +62,9 @@
5962
"class-validator": "^0.14.0",
6063
"isomorphic-ws": "^5.0.0",
6164
"json-bigint": "~1.0.0",
65+
"lodash": "^4.17.21",
66+
"rxjs": "^7.4.0",
67+
"ts-custom-error": "^3.2.0",
6268
"ts-log": "^2.2.4",
6369
"ws": "^8.17.1"
6470
},
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { Asset, AssetProvider, Cardano, GetAssetArgs, GetAssetsArgs } from '@cardano-sdk/core';
2+
import { BlockfrostClient } from '../blockfrost/BlockfrostClient';
3+
import { BlockfrostProvider } from '../blockfrost/BlockfrostProvider';
4+
import { Logger } from 'ts-log';
5+
import { isNotNil } from '@cardano-sdk/util';
6+
import omit from 'lodash/omit.js';
7+
import type { Responses } from '@blockfrost/blockfrost-js';
8+
9+
export class BlockfrostAssetProvider extends BlockfrostProvider implements AssetProvider {
10+
constructor(client: BlockfrostClient, logger: Logger) {
11+
super(client, logger);
12+
}
13+
14+
private mapNftMetadata(asset: Responses['asset']): Asset.NftMetadata | null {
15+
const image = this.metadatumToString(
16+
(asset.onchain_metadata?.image as string | string[] | undefined) || asset.metadata?.logo
17+
);
18+
const name = (asset.onchain_metadata?.name as string | undefined) || asset.metadata?.name;
19+
if (!image || !name) return null;
20+
try {
21+
return {
22+
description: this.metadatumToString(
23+
(asset.onchain_metadata?.description as string | string[] | undefined) || asset.metadata?.description
24+
),
25+
files: Array.isArray(asset.onchain_metadata?.files)
26+
? asset
27+
.onchain_metadata!.files.map((file): Asset.NftMetadataFile | null => {
28+
const mediaType = file.mediaType as string | undefined;
29+
const fileName = file.name as string | undefined;
30+
const src = file.src as string | undefined;
31+
if (!src || !mediaType) return null;
32+
try {
33+
return {
34+
mediaType: Asset.MediaType(mediaType),
35+
name: fileName,
36+
otherProperties: this.mapNftMetadataOtherProperties(file),
37+
src: Asset.Uri(src)
38+
};
39+
} catch {
40+
return null;
41+
}
42+
})
43+
.filter(isNotNil)
44+
: undefined,
45+
image: Asset.Uri(image),
46+
mediaType: asset.onchain_metadata?.mediaType
47+
? Asset.ImageMediaType(asset.onchain_metadata.mediaType as string)
48+
: undefined,
49+
name,
50+
otherProperties: this.mapNftMetadataOtherProperties(asset.onchain_metadata),
51+
version: '1.0'
52+
};
53+
} catch {
54+
return null;
55+
}
56+
}
57+
58+
private asString = (metadatum: unknown) => (typeof metadatum === 'string' ? metadatum : undefined);
59+
60+
private metadatumToString(metadatum: Cardano.Metadatum | undefined | null): string | undefined {
61+
let stringMetadatum: string | undefined;
62+
if (Array.isArray(metadatum)) {
63+
const result = metadatum.map((metadata) => this.asString(metadata)).filter(isNotNil);
64+
stringMetadatum = result.join('');
65+
} else {
66+
stringMetadatum = this.asString(metadatum);
67+
}
68+
69+
return stringMetadatum;
70+
}
71+
72+
private objToMetadatum(obj: unknown): Cardano.Metadatum {
73+
if (typeof obj === 'string') return obj;
74+
if (typeof obj === 'number') return BigInt(obj);
75+
if (typeof obj === 'object') {
76+
if (obj === null) return '';
77+
if (Array.isArray(obj)) {
78+
return obj.map((item) => this.objToMetadatum(item));
79+
}
80+
return new Map(Object.entries(obj).map(([key, value]) => [key, this.objToMetadatum(value)]));
81+
}
82+
return '';
83+
}
84+
85+
private mapNftMetadataOtherProperties(
86+
metadata: Responses['asset']['onchain_metadata']
87+
): Map<string, Cardano.Metadatum> | undefined {
88+
if (!metadata) {
89+
return;
90+
}
91+
const otherProperties = Object.entries(
92+
omit(metadata, ['name', 'image', 'description', 'mediaType', 'files', 'version'])
93+
);
94+
if (otherProperties.length === 0) return;
95+
// eslint-disable-next-line consistent-return
96+
return new Map(otherProperties.map(([key, value]) => [key, this.objToMetadatum(value)]));
97+
}
98+
99+
private mapTokenMetadata(assetId: Cardano.AssetId, asset: Responses['asset']): Asset.TokenMetadata {
100+
return {
101+
assetId,
102+
decimals: asset.metadata?.decimals || undefined,
103+
desc: this.metadatumToString(
104+
asset.metadata?.description || (asset.onchain_metadata?.description as string | string[] | undefined)
105+
),
106+
icon: this.metadatumToString(
107+
asset.metadata?.logo || (asset.onchain_metadata?.image as string | string[] | undefined)
108+
),
109+
name: asset.metadata?.name || (asset.onchain_metadata?.name as string | undefined),
110+
ticker: asset.metadata?.ticker || undefined,
111+
url: asset.metadata?.url || undefined,
112+
version: '1.0'
113+
};
114+
}
115+
116+
async getAsset({ assetId, extraData }: GetAssetArgs): Promise<Asset.AssetInfo> {
117+
try {
118+
const response = await this.request<Responses['asset']>(`assets/${assetId.toString()}`);
119+
const name = Cardano.AssetId.getAssetName(assetId);
120+
const policyId = Cardano.PolicyId(response.policy_id);
121+
const quantity = BigInt(response.quantity);
122+
return {
123+
assetId,
124+
fingerprint: Cardano.AssetFingerprint(response.fingerprint),
125+
name,
126+
nftMetadata: extraData?.nftMetadata ? this.mapNftMetadata(response) : null,
127+
policyId,
128+
quantity,
129+
supply: quantity,
130+
tokenMetadata: extraData?.tokenMetadata ? this.mapTokenMetadata(assetId, response) : null
131+
};
132+
} catch (error) {
133+
throw this.toProviderError(error);
134+
}
135+
}
136+
137+
getAssets({ assetIds, extraData }: GetAssetsArgs): Promise<Asset.AssetInfo[]> {
138+
return Promise.all(assetIds.map((assetId) => this.getAsset({ assetId, extraData })));
139+
}
140+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './assetInfoHttpProvider';
2+
export * from './BlockfrostAssetProvider';
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { CustomError } from 'ts-custom-error';
2+
import { catchError, firstValueFrom, switchMap, throwError } from 'rxjs';
3+
import { fromFetch } from 'rxjs/fetch';
4+
5+
export type BlockfrostClientConfig = {
6+
projectId?: string;
7+
baseUrl: string;
8+
apiVersion?: string;
9+
};
10+
11+
export type RateLimiter = {
12+
schedule: <T>(task: () => Promise<T>) => Promise<T>;
13+
};
14+
15+
export type BlockfrostClientDependencies = {
16+
/**
17+
* Rate limiter from npm: https://www.npmjs.com/package/bottleneck
18+
*
19+
* new Bottleneck({
20+
* reservoir: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.size,
21+
* reservoirIncreaseAmount: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.increaseAmount,
22+
* reservoirIncreaseInterval: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.increaseInterval,
23+
* reservoirIncreaseMaximum: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.size
24+
* })
25+
*/
26+
rateLimiter: RateLimiter;
27+
};
28+
29+
export class BlockfrostError extends CustomError {
30+
constructor(public status?: number, public body?: string, public innerError?: unknown) {
31+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
32+
const message: string | null = body || (innerError as any)?.message;
33+
super(`Blockfrost error with status '${status}': ${message}`);
34+
}
35+
}
36+
37+
export class BlockfrostClient {
38+
private rateLimiter: RateLimiter;
39+
private baseUrl: string;
40+
private requestInit: RequestInit;
41+
42+
constructor(
43+
{ apiVersion, projectId, baseUrl }: BlockfrostClientConfig,
44+
{ rateLimiter }: BlockfrostClientDependencies
45+
) {
46+
this.rateLimiter = rateLimiter;
47+
this.requestInit = projectId ? { headers: { project_id: projectId } } : {};
48+
this.baseUrl = apiVersion ? `${baseUrl}/api/${apiVersion}` : `${baseUrl}`;
49+
}
50+
51+
/**
52+
* @param endpoint e.g. 'blocks/latest'
53+
* @throws {BlockfrostError}
54+
*/
55+
public request<T>(endpoint: string): Promise<T> {
56+
return this.rateLimiter.schedule(() =>
57+
firstValueFrom(
58+
fromFetch(`${this.baseUrl}/${endpoint}`, this.requestInit).pipe(
59+
switchMap(async (response): Promise<T> => {
60+
if (response.ok) {
61+
try {
62+
return await response.json();
63+
} catch {
64+
throw new BlockfrostError(response.status, 'Failed to parse json');
65+
}
66+
}
67+
try {
68+
const body = await response.text();
69+
throw new BlockfrostError(response.status, body);
70+
} catch {
71+
throw new BlockfrostError(response.status);
72+
}
73+
}),
74+
catchError((err) => {
75+
if (err instanceof BlockfrostError) {
76+
return throwError(() => err);
77+
}
78+
return throwError(() => new BlockfrostError(undefined, undefined, err));
79+
})
80+
)
81+
)
82+
);
83+
}
84+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { BlockfrostClient, BlockfrostError } from './BlockfrostClient';
2+
import { HealthCheckResponse, Provider, ProviderError, ProviderFailure } from '@cardano-sdk/core';
3+
import { Logger } from 'ts-log';
4+
import { contextLogger } from '@cardano-sdk/util';
5+
import type { AsyncReturnType } from 'type-fest';
6+
import type { BlockFrostAPI } from '@blockfrost/blockfrost-js';
7+
8+
const toProviderFailure = (status: number | undefined): ProviderFailure => {
9+
switch (status) {
10+
case 400:
11+
return ProviderFailure.BadRequest;
12+
case 403:
13+
return ProviderFailure.Forbidden;
14+
case 404:
15+
return ProviderFailure.NotFound;
16+
case 402:
17+
case 418:
18+
case 425:
19+
case 429:
20+
return ProviderFailure.ServerUnavailable;
21+
case 500:
22+
return ProviderFailure.Unhealthy;
23+
default:
24+
return ProviderFailure.Unknown;
25+
}
26+
};
27+
28+
export abstract class BlockfrostProvider implements Provider {
29+
#logger: Logger;
30+
#client: BlockfrostClient;
31+
32+
constructor(client: BlockfrostClient, logger: Logger) {
33+
this.#client = client;
34+
this.#logger = contextLogger(logger, this.constructor.name);
35+
}
36+
37+
/**
38+
* @param endpoint e.g. 'blocks/latest'
39+
* @throws {ProviderError}
40+
*/
41+
protected async request<T>(endpoint: string): Promise<T> {
42+
try {
43+
this.#logger.debug('request', endpoint);
44+
const response = await this.#client.request<T>(endpoint);
45+
this.#logger.debug('response', response);
46+
return response;
47+
} catch (error) {
48+
this.#logger.error('error', error);
49+
throw this.toProviderError(error);
50+
}
51+
}
52+
53+
async healthCheck(): Promise<HealthCheckResponse> {
54+
try {
55+
const result = await this.#client.request<AsyncReturnType<BlockFrostAPI['health']>>('health');
56+
return { ok: result.is_healthy };
57+
} catch (error) {
58+
return { ok: false, reason: this.toProviderError(error).message };
59+
}
60+
}
61+
62+
protected toProviderError(error: unknown): ProviderError {
63+
if (error instanceof BlockfrostError) {
64+
return new ProviderError(toProviderFailure(error.status), error);
65+
}
66+
return new ProviderError(ProviderFailure.Unknown, error);
67+
}
68+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Milliseconds } from '@cardano-sdk/core';
2+
3+
export const DEFAULT_BLOCKFROST_API_VERSION = 'v0';
4+
5+
export const DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG = {
6+
increaseAmount: 10,
7+
increaseInterval: Milliseconds(1000),
8+
size: 500
9+
};
10+
11+
export const DEFAULT_BLOCKFROST_URLS = {
12+
mainnet: 'https://cardano-mainnet.blockfrost.io',
13+
preprod: 'https://cardano-preprod.blockfrost.io',
14+
preview: 'https://cardano-preview.blockfrost.io',
15+
sanchonet: 'https://cardano-sanchonet.blockfrost.io'
16+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './BlockfrostProvider';
2+
export * from './BlockfrostClient';
3+
export * from './const';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ProviderError, ProviderFailure } from '@cardano-sdk/core';
2+
import type { PaginationOptions } from '@blockfrost/blockfrost-js/lib/types';
3+
4+
const isNotFoundError = (error: unknown) => error instanceof ProviderError && error.reason === ProviderFailure.NotFound;
5+
6+
// copied from @cardano-sdk/cardano-services and updated to use custom blockfrost client instead of blockfrost-js
7+
export const fetchSequentially = async <Item, Arg, Response>(
8+
props: {
9+
request: (queryString: string) => Promise<Response[]>;
10+
responseTranslator?: (response: Response[]) => Item[];
11+
/**
12+
* @returns true to indicatate that current result set should be returned
13+
*/
14+
haveEnoughItems?: (items: Item[]) => boolean;
15+
paginationOptions?: PaginationOptions;
16+
},
17+
page = 1,
18+
accumulated: Item[] = []
19+
): Promise<Item[]> => {
20+
const count = props.paginationOptions?.count || 100;
21+
const order = props.paginationOptions?.order || 'asc';
22+
try {
23+
const response = await props.request(`count=${count}&page=${page}&order=${order}`);
24+
const maybeTranslatedResponse = props.responseTranslator ? props.responseTranslator(response) : response;
25+
const newAccumulatedItems = [...accumulated, ...maybeTranslatedResponse] as Item[];
26+
const haveEnoughItems = props.haveEnoughItems?.(newAccumulatedItems);
27+
if (response.length === count && !haveEnoughItems) {
28+
return fetchSequentially<Item, Arg, Response>(props, page + 1, newAccumulatedItems);
29+
}
30+
return newAccumulatedItems;
31+
} catch (error) {
32+
if (isNotFoundError(error)) {
33+
return [];
34+
}
35+
throw error;
36+
}
37+
};

packages/cardano-services-client/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,11 @@ export * from './RewardsProvider';
99
export * from './HandleProvider';
1010
export * from './version';
1111
export * from './WebSocket';
12+
export {
13+
BlockfrostClient,
14+
BlockfrostError,
15+
DEFAULT_BLOCKFROST_API_VERSION,
16+
DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG,
17+
DEFAULT_BLOCKFROST_URLS
18+
} from './blockfrost';
19+
export type { BlockfrostClientConfig, BlockfrostClientDependencies, RateLimiter } from './blockfrost';

0 commit comments

Comments
 (0)