Skip to content

Commit

Permalink
Merge pull request #1528 from input-output-hk/feat/lw-11834-client-si…
Browse files Browse the repository at this point in the history
…de-compatible-blockfrost-provider

feat: browser compatible BlockfrostAssetProvider
  • Loading branch information
mkazlauskas authored Nov 13, 2024
2 parents 3921646 + b5c9f26 commit 66412ed
Show file tree
Hide file tree
Showing 23 changed files with 721 additions and 355 deletions.
6 changes: 6 additions & 0 deletions packages/cardano-services-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@
},
"devDependencies": {
"@cardano-sdk/util-dev": "workspace:~",
"@types/lodash": "^4.14.182",
"@types/node-fetch": "^2.6.12",
"@types/validator": "^13.7.1",
"axios-mock-adapter": "^2.0.0",
"eslint": "^7.32.0",
"express": "^4.17.3",
"get-port-please": "^2.5.0",
"jest": "^28.1.3",
"madge": "^5.0.1",
"node-fetch": "2",
"npm-run-all": "^4.1.5",
"ts-jest": "^28.0.7",
"tsc-alias": "^1.8.10",
Expand All @@ -59,6 +62,9 @@
"class-validator": "^0.14.0",
"isomorphic-ws": "^5.0.0",
"json-bigint": "~1.0.0",
"lodash": "^4.17.21",
"rxjs": "^7.4.0",
"ts-custom-error": "^3.2.0",
"ts-log": "^2.2.4",
"ws": "^8.17.1"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Asset, AssetProvider, Cardano, GetAssetArgs, GetAssetsArgs } from '@cardano-sdk/core';
import { BlockfrostClient } from '../blockfrost/BlockfrostClient';
import { BlockfrostProvider } from '../blockfrost/BlockfrostProvider';
import { Logger } from 'ts-log';
import { isNotNil } from '@cardano-sdk/util';
import omit from 'lodash/omit.js';
import type { Responses } from '@blockfrost/blockfrost-js';

export class BlockfrostAssetProvider extends BlockfrostProvider implements AssetProvider {
constructor(client: BlockfrostClient, logger: Logger) {
super(client, logger);
}

private mapNftMetadata(asset: Responses['asset']): Asset.NftMetadata | null {
const image = this.metadatumToString(
(asset.onchain_metadata?.image as string | string[] | undefined) || asset.metadata?.logo
);
const name = (asset.onchain_metadata?.name as string | undefined) || asset.metadata?.name;
if (!image || !name) return null;
try {
return {
description: this.metadatumToString(
(asset.onchain_metadata?.description as string | string[] | undefined) || asset.metadata?.description
),
files: Array.isArray(asset.onchain_metadata?.files)
? asset
.onchain_metadata!.files.map((file): Asset.NftMetadataFile | null => {
const mediaType = file.mediaType as string | undefined;
const fileName = file.name as string | undefined;
const src = this.metadatumToString(file.src as string | string[] | undefined);
if (!src || !mediaType) return null;
try {
return {
mediaType: Asset.MediaType(mediaType),
name: fileName,
otherProperties: this.mapNftMetadataOtherProperties(file),
src: Asset.Uri(src)
};
} catch (error) {
this.logger.warn('Failed to parse onchain_metadata file', file, error);
return null;
}
})
.filter(isNotNil)
: undefined,
image: Asset.Uri(image),
mediaType: asset.onchain_metadata?.mediaType
? Asset.ImageMediaType(asset.onchain_metadata.mediaType as string)
: undefined,
name,
otherProperties: this.mapNftMetadataOtherProperties(asset.onchain_metadata),
version: this.mapNftMetadataVersion(asset.onchain_metadata)
};
} catch (error) {
this.logger.warn('Failed to parse nft metadata', asset, error);
return null;
}
}

private asString = (metadatum: unknown) => (typeof metadatum === 'string' ? metadatum : undefined);

private metadatumToString(metadatum: Cardano.Metadatum | undefined | null): string | undefined {
let stringMetadatum: string | undefined;
if (Array.isArray(metadatum)) {
const result = metadatum.map((metadata) => this.asString(metadata)).filter(isNotNil);
stringMetadatum = result.join('');
} else {
stringMetadatum = this.asString(metadatum);
}

return stringMetadatum;
}

private objToMetadatum(obj: unknown): Cardano.Metadatum {
if (typeof obj === 'string') return obj;
if (typeof obj === 'number') return BigInt(obj);
if (typeof obj === 'object') {
if (obj === null) return '';
if (Array.isArray(obj)) {
return obj.map((item) => this.objToMetadatum(item));
}
return new Map(Object.entries(obj).map(([key, value]) => [key, this.objToMetadatum(value)]));
}
return '';
}

private mapNftMetadataVersion(metadata: Responses['asset']['onchain_metadata']) {
return typeof metadata?.version === 'string' ? metadata.version : '1.0';
}

private mapNftMetadataOtherProperties(
metadata: Responses['asset']['onchain_metadata']
): Map<string, Cardano.Metadatum> | undefined {
if (!metadata) {
return;
}
const otherProperties = Object.entries(
omit(metadata, ['name', 'image', 'description', 'mediaType', 'files', 'version'])
);
if (otherProperties.length === 0) return;
// eslint-disable-next-line consistent-return
return new Map(otherProperties.map(([key, value]) => [key, this.objToMetadatum(value)]));
}

private mapTokenMetadata(assetId: Cardano.AssetId, asset: Responses['asset']): Asset.TokenMetadata {
return {
assetId,
decimals: asset.metadata?.decimals || undefined,
desc: this.metadatumToString(
asset.metadata?.description || (asset.onchain_metadata?.description as string | string[] | undefined)
),
icon: this.metadatumToString(
asset.metadata?.logo || (asset.onchain_metadata?.image as string | string[] | undefined)
),
name: asset.metadata?.name || (asset.onchain_metadata?.name as string | undefined),
ticker: asset.metadata?.ticker || undefined,
url: asset.metadata?.url || undefined,
version: '1.0'
};
}

async getAsset({ assetId, extraData }: GetAssetArgs): Promise<Asset.AssetInfo> {
try {
const response = await this.request<Responses['asset']>(`assets/${assetId.toString()}`);
const name = Cardano.AssetId.getAssetName(assetId);
const policyId = Cardano.PolicyId(response.policy_id);
const quantity = BigInt(response.quantity);
return {
assetId,
fingerprint: Cardano.AssetFingerprint(response.fingerprint),
name,
nftMetadata: extraData?.nftMetadata ? this.mapNftMetadata(response) : null,
policyId,
quantity,
supply: quantity,
tokenMetadata: extraData?.tokenMetadata ? this.mapTokenMetadata(assetId, response) : null
};
} catch (error) {
this.logger.error('getAsset failed', assetId, extraData);
throw this.toProviderError(error);
}
}

getAssets({ assetIds, extraData }: GetAssetsArgs): Promise<Asset.AssetInfo[]> {
return Promise.all(assetIds.map((assetId) => this.getAsset({ assetId, extraData })));
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './assetInfoHttpProvider';
export * from './BlockfrostAssetProvider';
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { CustomError } from 'ts-custom-error';
import { catchError, firstValueFrom, switchMap, throwError } from 'rxjs';
import { fromFetch } from 'rxjs/fetch';

export type BlockfrostClientConfig = {
projectId?: string;
baseUrl: string;
apiVersion?: string;
};

export type RateLimiter = {
schedule: <T>(task: () => Promise<T>) => Promise<T>;
};

export type BlockfrostClientDependencies = {
/**
* Rate limiter from npm: https://www.npmjs.com/package/bottleneck
*
* new Bottleneck({
* reservoir: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.size,
* reservoirIncreaseAmount: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.increaseAmount,
* reservoirIncreaseInterval: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.increaseInterval,
* reservoirIncreaseMaximum: DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG.size
* })
*/
rateLimiter: RateLimiter;
};

export class BlockfrostError extends CustomError {
constructor(public status?: number, public body?: string, public innerError?: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message: string | null = body || (innerError as any)?.message;
super(`Blockfrost error with status '${status}': ${message}`);
}
}

export class BlockfrostClient {
private rateLimiter: RateLimiter;
private baseUrl: string;
private requestInit: RequestInit;

constructor(
{ apiVersion, projectId, baseUrl }: BlockfrostClientConfig,
{ rateLimiter }: BlockfrostClientDependencies
) {
this.rateLimiter = rateLimiter;
this.requestInit = projectId ? { headers: { project_id: projectId } } : {};
this.baseUrl = apiVersion ? `${baseUrl}/api/${apiVersion}` : `${baseUrl}`;
}

/**
* @param endpoint e.g. 'blocks/latest'
* @throws {BlockfrostError}
*/
public request<T>(endpoint: string): Promise<T> {
return this.rateLimiter.schedule(() =>
firstValueFrom(
fromFetch(`${this.baseUrl}/${endpoint}`, this.requestInit).pipe(
switchMap(async (response): Promise<T> => {
if (response.ok) {
try {
return await response.json();
} catch {
throw new BlockfrostError(response.status, 'Failed to parse json');
}
}
try {
const body = await response.text();
throw new BlockfrostError(response.status, body);
} catch {
throw new BlockfrostError(response.status);
}
}),
catchError((err) => {
if (err instanceof BlockfrostError) {
return throwError(() => err);
}
return throwError(() => new BlockfrostError(undefined, undefined, err));
})
)
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { BlockfrostClient, BlockfrostError } from './BlockfrostClient';
import { HealthCheckResponse, Provider, ProviderError, ProviderFailure } from '@cardano-sdk/core';
import { Logger } from 'ts-log';
import { contextLogger } from '@cardano-sdk/util';
import type { AsyncReturnType } from 'type-fest';
import type { BlockFrostAPI } from '@blockfrost/blockfrost-js';

const toProviderFailure = (status: number | undefined): ProviderFailure => {
switch (status) {
case 400:
return ProviderFailure.BadRequest;
case 403:
return ProviderFailure.Forbidden;
case 404:
return ProviderFailure.NotFound;
case 402:
case 418:
case 425:
case 429:
return ProviderFailure.ServerUnavailable;
case 500:
return ProviderFailure.Unhealthy;
default:
return ProviderFailure.Unknown;
}
};

export abstract class BlockfrostProvider implements Provider {
protected logger: Logger;
#client: BlockfrostClient;

constructor(client: BlockfrostClient, logger: Logger) {
this.#client = client;
this.logger = contextLogger(logger, this.constructor.name);
}

/**
* @param endpoint e.g. 'blocks/latest'
* @throws {ProviderError}
*/
protected async request<T>(endpoint: string): Promise<T> {
try {
this.logger.debug('request', endpoint);
const response = await this.#client.request<T>(endpoint);
this.logger.debug('response', response);
return response;
} catch (error) {
this.logger.error('error', error);
throw this.toProviderError(error);
}
}

async healthCheck(): Promise<HealthCheckResponse> {
try {
const result = await this.#client.request<AsyncReturnType<BlockFrostAPI['health']>>('health');
return { ok: result.is_healthy };
} catch (error) {
return { ok: false, reason: this.toProviderError(error).message };
}
}

protected toProviderError(error: unknown): ProviderError {
if (error instanceof BlockfrostError) {
return new ProviderError(toProviderFailure(error.status), error);
}
return new ProviderError(ProviderFailure.Unknown, error);
}
}
16 changes: 16 additions & 0 deletions packages/cardano-services-client/src/blockfrost/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Milliseconds } from '@cardano-sdk/core';

export const DEFAULT_BLOCKFROST_API_VERSION = 'v0';

export const DEFAULT_BLOCKFROST_RATE_LIMIT_CONFIG = {
increaseAmount: 10,
increaseInterval: Milliseconds(1000),
size: 500
};

export const DEFAULT_BLOCKFROST_URLS = {
mainnet: 'https://cardano-mainnet.blockfrost.io',
preprod: 'https://cardano-preprod.blockfrost.io',
preview: 'https://cardano-preview.blockfrost.io',
sanchonet: 'https://cardano-sanchonet.blockfrost.io'
};
3 changes: 3 additions & 0 deletions packages/cardano-services-client/src/blockfrost/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './BlockfrostProvider';
export * from './BlockfrostClient';
export * from './const';
37 changes: 37 additions & 0 deletions packages/cardano-services-client/src/blockfrost/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ProviderError, ProviderFailure } from '@cardano-sdk/core';
import type { PaginationOptions } from '@blockfrost/blockfrost-js/lib/types';

const isNotFoundError = (error: unknown) => error instanceof ProviderError && error.reason === ProviderFailure.NotFound;

// copied from @cardano-sdk/cardano-services and updated to use custom blockfrost client instead of blockfrost-js
export const fetchSequentially = async <Item, Arg, Response>(
props: {
request: (queryString: string) => Promise<Response[]>;
responseTranslator?: (response: Response[]) => Item[];
/**
* @returns true to indicatate that current result set should be returned
*/
haveEnoughItems?: (items: Item[]) => boolean;
paginationOptions?: PaginationOptions;
},
page = 1,
accumulated: Item[] = []
): Promise<Item[]> => {
const count = props.paginationOptions?.count || 100;
const order = props.paginationOptions?.order || 'asc';
try {
const response = await props.request(`count=${count}&page=${page}&order=${order}`);
const maybeTranslatedResponse = props.responseTranslator ? props.responseTranslator(response) : response;
const newAccumulatedItems = [...accumulated, ...maybeTranslatedResponse] as Item[];
const haveEnoughItems = props.haveEnoughItems?.(newAccumulatedItems);
if (response.length === count && !haveEnoughItems) {
return fetchSequentially<Item, Arg, Response>(props, page + 1, newAccumulatedItems);
}
return newAccumulatedItems;
} catch (error) {
if (isNotFoundError(error)) {
return [];
}
throw error;
}
};
Loading

0 comments on commit 66412ed

Please sign in to comment.