-
Notifications
You must be signed in to change notification settings - Fork 58
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1528 from input-output-hk/feat/lw-11834-client-si…
…de-compatible-blockfrost-provider feat: browser compatible BlockfrostAssetProvider
- Loading branch information
Showing
23 changed files
with
721 additions
and
355 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
147 changes: 147 additions & 0 deletions
147
packages/cardano-services-client/src/AssetInfoProvider/BlockfrostAssetProvider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }))); | ||
} | ||
} |
1 change: 1 addition & 0 deletions
1
packages/cardano-services-client/src/AssetInfoProvider/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,2 @@ | ||
export * from './assetInfoHttpProvider'; | ||
export * from './BlockfrostAssetProvider'; |
84 changes: 84 additions & 0 deletions
84
packages/cardano-services-client/src/blockfrost/BlockfrostClient.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}) | ||
) | ||
) | ||
); | ||
} | ||
} |
68 changes: 68 additions & 0 deletions
68
packages/cardano-services-client/src/blockfrost/BlockfrostProvider.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export * from './BlockfrostProvider'; | ||
export * from './BlockfrostClient'; | ||
export * from './const'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
}; |
Oops, something went wrong.