From c23a3c045dd24c0e761af5c8992bd317a9f09956 Mon Sep 17 00:00:00 2001 From: Andrew Richardson Date: Thu, 12 Oct 2023 15:56:00 -0400 Subject: [PATCH 1/3] Backport fixes from 1.2 Add all additional methods/fixes that are also applicable to FireFly 1.0. Signed-off-by: Andrew Richardson --- lib/firefly.ts | 269 +++++++++++++++++++++++++++++++++++++--------- lib/http.ts | 70 +++++++++--- lib/interfaces.ts | 112 ++++++++++++++++--- lib/logger.ts | 50 +++++++-- lib/schema.ts | 84 ++++++++++++--- lib/websocket.ts | 28 ++++- 6 files changed, 506 insertions(+), 107 deletions(-) diff --git a/lib/firefly.ts b/lib/firefly.ts index cd7b330..b9bf46a 100644 --- a/lib/firefly.ts +++ b/lib/firefly.ts @@ -1,5 +1,7 @@ -import { Stream, Readable } from 'stream'; +import { Readable } from 'stream'; +import * as http from 'http'; import * as FormData from 'form-data'; +import * as WebSocket from 'ws'; import { FireFlyStatusResponse, FireFlyPrivateSendOptions, @@ -49,6 +51,29 @@ import { FireFlyTransactionFilter, FireFlyOperationFilter, FireFlyOperationResponse, + FireFlyBlockchainEventFilter, + FireFlyBlockchainEventResponse, + FireFlyContractAPIInvokeRequest, + FireFlyContractAPIQueryRequest, + FireFlyContractInvokeRequest, + FireFlyContractInvokeResponse, + FireFlyContractQueryRequest, + FireFlyContractQueryResponse, + FireFlyDataBlobRequest, + FireFlyDataBlobRequestDefaults, + FireFlyDataFilter, + FireFlyDataRequest, + FireFlyDeleteOptions, + FireFlyReplaceOptions, + FireFlyTokenApprovalFilter, + FireFlyTokenApprovalRequest, + FireFlyTokenApprovalResponse, + FireFlyTokenTransferFilter, + FireFlyWebSocketConnectCallback, + FireFlyNamespaceResponse, + FireFlyIdentityFilter, + FireFlyIdentitiesResponse, + FireFlyIdentityResponse, } from './interfaces'; import { FireFlyWebSocket, FireFlyWebSocketCallback } from './websocket'; import HttpBase, { mapConfig } from './http'; @@ -61,7 +86,21 @@ export default class FireFly extends HttpBase { return response.data; } - async getOrganizations( + getIdentities( + filter?: FireFlyIdentityFilter, + options?: FireFlyGetOptions, + ): Promise { + return this.getMany('/identities', filter, options); + } + + getIdentity( + id: string, + options?: FireFlyGetOptions, + ): Promise { + return this.getOne(`/identities/${id}`, options); + } + + getOrganizations( filter?: FireFlyOrganizationFilter, options?: FireFlyGetOptions, ): Promise { @@ -73,14 +112,14 @@ export default class FireFly extends HttpBase { ); } - async getNodes( + getNodes( filter?: FireFlyNodeFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/network/nodes', filter, options, true); } - async getVerifiers( + getVerifiers( namespace?: string, filter?: FireFlyVerifierFilter, options?: FireFlyGetOptions, @@ -94,14 +133,18 @@ export default class FireFly extends HttpBase { ); } - async getDatatypes( + getNamespaces(options?: FireFlyGetOptions): Promise { + return this.getMany('/namespaces', undefined, options, true); + } + + getDatatypes( filter?: FireFlyDatatypeFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/datatypes', filter, options); } - async getDatatype( + getDatatype( name: string, version: string, options?: FireFlyGetOptions, @@ -109,35 +152,45 @@ export default class FireFly extends HttpBase { return this.getOne(`/datatypes/${name}/${version}`, options); } - async createDatatype( + createDatatype( req: FireFlyDatatypeRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/datatypes', req, options); } - async getSubscriptions( + getSubscriptions( filter?: FireFlySubscriptionFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/subscriptions', filter, options); } - async replaceSubscription(sub: FireFlySubscriptionRequest): Promise { - return this.replaceOne('/subscriptions', sub); + replaceSubscription( + sub: FireFlySubscriptionRequest, + options?: FireFlyReplaceOptions, + ): Promise { + return this.replaceOne('/subscriptions', sub, options); } - async deleteSubscription(subId: string) { - await this.deleteOne(`/subscriptions/${subId}`); + async deleteSubscription(subId: string, options?: FireFlyDeleteOptions) { + await this.deleteOne(`/subscriptions/${subId}`, options); } - async getData(id: string, options?: FireFlyGetOptions): Promise { + getData(id: string, options?: FireFlyGetOptions): Promise { return this.getOne(`/data/${id}`, options); } - async getDataBlob(id: string, options?: FireFlyGetOptions): Promise { + findData( + filter?: FireFlyDataFilter, + options?: FireFlyGetOptions, + ): Promise { + return this.getMany(`/data`, filter, options); + } + + async getDataBlob(id: string, options?: FireFlyGetOptions): Promise { const response = await this.wrapError( - this.http.get(`/data/${id}/blob`, { + this.http.get(`/data/${id}/blob`, { ...mapConfig(options), responseType: 'stream', }), @@ -145,16 +198,38 @@ export default class FireFly extends HttpBase { return response.data; } + uploadData( + data: FireFlyDataRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne('/data', data, options); + } + + publishData(id: string, options?: FireFlyCreateOptions): Promise { + return this.createOne(`/data/${id}/value/publish`, {}, options); + } + async uploadDataBlob( blob: string | Buffer | Readable, - filename: string, + blobOptions?: FormData.AppendOptions, + dataOptions?: FireFlyDataBlobRequest, + options?: FireFlyCreateOptions, ): Promise { + dataOptions = { ...FireFlyDataBlobRequestDefaults, ...dataOptions }; const formData = new FormData(); - formData.append('autometa', 'true'); - formData.append('file', blob, { filename }); + for (const key in dataOptions) { + const val = dataOptions[key as keyof FireFlyDataBlobRequest]; + if (val !== undefined) { + formData.append(key, val); + } + } + formData.append('file', blob, blobOptions); + const requestOptions = mapConfig(options); const response = await this.wrapError( this.http.post('/data', formData, { + ...requestOptions, headers: { + ...requestOptions.headers, ...formData.getHeaders(), 'Content-Length': formData.getLengthSync(), }, @@ -163,35 +238,40 @@ export default class FireFly extends HttpBase { return response.data; } - async getBatches( + publishDataBlob(id: string, options?: FireFlyCreateOptions): Promise { + return this.createOne(`/data/${id}/blob/publish`, {}, options); + } + + async deleteData(id: string, options?: FireFlyDeleteOptions) { + await this.deleteOne(`/data/${id}`, options); + } + + getBatches( filter?: FireFlyBatchFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany(`/batches`, filter, options); } - async getMessages( + getMessages( filter?: FireFlyMessageFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany(`/messages`, filter, options); } - async getMessage( - id: string, - options?: FireFlyGetOptions, - ): Promise { + getMessage(id: string, options?: FireFlyGetOptions): Promise { return this.getOne(`/messages/${id}`, options); } - async sendBroadcast( + sendBroadcast( message: FireFlyBroadcastMessageRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/messages/broadcast', message, options); } - async sendPrivateMessage( + sendPrivateMessage( message: FireFlyPrivateMessageRequest, options?: FireFlyPrivateSendOptions, ): Promise { @@ -199,60 +279,81 @@ export default class FireFly extends HttpBase { return this.createOne(url, message, options); } - async createTokenPool( + createTokenPool( pool: FireFlyTokenPoolRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/tokens/pools', pool, options); } - async getTokenPools( + getTokenPools( filter?: FireFlyTokenPoolFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany(`/tokens/pools`, filter, options); } - async getTokenPool( + getTokenPool( nameOrId: string, options?: FireFlyGetOptions, ): Promise { return this.getOne(`/tokens/pools/${nameOrId}`, options); } - async mintTokens(transfer: FireFlyTokenMintRequest, options?: FireFlyCreateOptions) { + mintTokens(transfer: FireFlyTokenMintRequest, options?: FireFlyCreateOptions) { return this.createOne('/tokens/mint', transfer, options); } - async transferTokens( + transferTokens( transfer: FireFlyTokenTransferRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/tokens/transfers', transfer, options); } - async burnTokens( + approveTokens( + approval: FireFlyTokenApprovalRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne('/tokens/approvals', approval, options); + } + + getTokenApprovals( + filter?: FireFlyTokenApprovalFilter, + options?: FireFlyGetOptions, + ): Promise { + return this.getMany(`/tokens/approvals`, filter, options); + } + + burnTokens( transfer: FireFlyTokenBurnRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/tokens/burn', transfer, options); } - async getTokenTransfer( + getTokenTransfers( + filter?: FireFlyTokenTransferFilter, + options?: FireFlyGetOptions, + ): Promise { + return this.getMany(`/tokens/transfers`, filter, options); + } + + getTokenTransfer( id: string, options?: FireFlyGetOptions, ): Promise { return this.getOne(`/tokens/transfers/${id}`, options); } - async getTokenBalances( + getTokenBalances( filter?: FireFlyTokenBalanceFilter, options?: FireFlyGetOptions, - ): Promise { - return this.getMany('/tokens/balances', filter, options); + ): Promise { + return this.getMany('/tokens/balances', filter, options); } - async generateContractInterface( + generateContractInterface( request: FireFlyContractGenerateRequest, ): Promise { return this.createOne( @@ -261,14 +362,14 @@ export default class FireFly extends HttpBase { ); } - async createContractInterface( + createContractInterface( ffi: FireFlyContractInterfaceRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/contracts/interfaces', ffi, options); } - async getContractInterfaces( + getContractInterfaces( filter?: FireFlyContractInterfaceFilter, options?: FireFlyGetOptions, ): Promise { @@ -279,7 +380,7 @@ export default class FireFly extends HttpBase { ); } - async getContractInterface( + getContractInterface( id: string, fetchchildren?: boolean, options?: FireFlyGetOptions, @@ -289,28 +390,68 @@ export default class FireFly extends HttpBase { }); } - async createContractAPI( + createContractAPI( api: FireFlyContractAPIRequest, options?: FireFlyCreateOptions, ): Promise { return this.createOne('/apis', api, options); } - async getContractAPIs( + getContractAPIs( filter?: FireFlyContractAPIFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/apis', filter, options); } - async getContractAPI( + getContractAPI( name: string, options?: FireFlyGetOptions, ): Promise { return this.getOne(`/apis/${name}`, options); } - async createContractListener( + invokeContract( + request: FireFlyContractInvokeRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne('/contracts/invoke', request, options); + } + + queryContract( + request: FireFlyContractQueryRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne('/contracts/query', request, options); + } + + invokeContractAPI( + apiName: string, + methodPath: string, + request: FireFlyContractAPIInvokeRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne( + `/apis/${apiName}/invoke/${methodPath}`, + request, + options, + ); + } + + queryContractAPI( + apiName: string, + methodPath: string, + request: FireFlyContractAPIQueryRequest, + options?: FireFlyCreateOptions, + ): Promise { + return this.createOne( + `/apis/${apiName}/query/${methodPath}`, + request, + options, + ); + } + + createContractListener( listener: FireFlyContractListenerRequest, options?: FireFlyCreateOptions, ): Promise { @@ -321,14 +462,18 @@ export default class FireFly extends HttpBase { ); } - async getContractListeners( + getContractListeners( filter?: FireFlyContractListenerFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/contracts/listeners', filter, options); } - async getContractAPIListeners( + async deleteContractListener(id: string, options?: FireFlyDeleteOptions) { + await this.deleteOne(`/contracts/listeners/${id}`, options); + } + + getContractAPIListeners( apiName: string, eventPath: string, options?: FireFlyGetOptions, @@ -340,7 +485,7 @@ export default class FireFly extends HttpBase { ); } - async createContractAPIListener( + createContractAPIListener( apiName: string, eventPath: string, listener: FireFlyContractListenerRequest, @@ -353,37 +498,57 @@ export default class FireFly extends HttpBase { ); } - async getOperations( + getOperations( filter?: FireFlyOperationFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/operations', filter, options); } - async getOperation( + getOperation( id: string, options?: FireFlyGetOptions, ): Promise { return this.getOne(`/operations/${id}`, options); } - async getTransactions( + retryOperation(id: string, options?: FireFlyCreateOptions): Promise { + return this.createOne(`/operations/${id}/retry`, {}, options); + } + + getTransactions( filter?: FireFlyTransactionFilter, options?: FireFlyGetOptions, ): Promise { return this.getMany('/transactions', filter, options); } - async getTransaction( + getTransaction( id: string, options?: FireFlyGetOptions, ): Promise { return this.getOne(`/transactions/${id}`, options); } + getBlockchainEvents( + filter?: FireFlyBlockchainEventFilter, + options?: FireFlyGetOptions, + ): Promise { + return this.getMany('/blockchainevents', filter, options); + } + + getBlockchainEvent( + id: string, + options?: FireFlyGetOptions, + ): Promise { + return this.getOne(`/blockchainevents/${id}`, options); + } + listen( subscriptions: string | string[] | FireFlySubscriptionBase, callback: FireFlyWebSocketCallback, + socketOptions?: WebSocket.ClientOptions | http.ClientRequestArgs, + afterConnect?: FireFlyWebSocketConnectCallback, ): FireFlyWebSocket { const options: FireFlyWebSocketOptions = { host: this.options.websocket.host, @@ -394,6 +559,8 @@ export default class FireFly extends HttpBase { autoack: false, reconnectDelay: this.options.websocket.reconnectDelay, heartbeatInterval: this.options.websocket.heartbeatInterval, + socketOptions: socketOptions, + afterConnect: afterConnect, }; const handler: FireFlyWebSocketCallback = (socket, event) => { diff --git a/lib/http.ts b/lib/http.ts index 4e93f25..ff0c5b7 100644 --- a/lib/http.ts +++ b/lib/http.ts @@ -5,6 +5,10 @@ import { FireFlyCreateOptions, FireFlyGetOptions, FireFlyError, + FireFlyReplaceOptions, + FireFlyUpdateOptions, + FireFlyDeleteOptions, + FireFlyIdempotencyError, } from './interfaces'; function isSuccess(status: number) { @@ -12,16 +16,34 @@ function isSuccess(status: number) { } export function mapConfig( - options: FireFlyGetOptions | FireFlyCreateOptions | undefined, + options: + | FireFlyGetOptions + | FireFlyUpdateOptions + | FireFlyReplaceOptions + | FireFlyCreateOptions + | FireFlyDeleteOptions + | undefined, params?: any, ): AxiosRequestConfig { - return { + const config: AxiosRequestConfig = { ...options?.requestConfig, - params: { - ...params, - confirm: options?.confirm, - }, + params, }; + if (options !== undefined) { + if ('confirm' in options) { + config.params = { + ...config.params, + confirm: options.confirm, + }; + } + if ('publish' in options) { + config.params = { + ...config.params, + publish: options.publish, + }; + } + } + return config; } export default class HttpBase { @@ -35,17 +57,28 @@ export default class HttpBase { this.options = this.setDefaults(options); this.rootHttp = axios.create({ ...options.requestConfig, - baseURL: `${options.host}/api/v1`, + baseURL: this.options.baseURL, }); this.http = axios.create({ ...options.requestConfig, - baseURL: `${options.host}/api/v1/namespaces/${this.options.namespace}`, + baseURL: this.options.namespaceBaseURL, }); } private setDefaults(options: FireFlyOptionsInput): FireFlyOptions { + const baseURLSet = (options.baseURL ?? '') !== '' && (options.namespaceBaseURL ?? '' !== ''); + if (!baseURLSet && (options.host ?? '') === '') { + throw new Error('Invalid options. Option host, or baseURL and namespaceBaseURL must be set.'); + } + if ((options.host ?? '') === '' && (options.websocket?.host ?? '') === '') { + throw new Error('Invalid options. Option host, or websocket.host must be set.'); + } return { ...options, + baseURL: baseURLSet ? options.baseURL : `${options.host}/api/v1`, + namespaceBaseURL: baseURLSet + ? options.namespaceBaseURL + : `${options.host}/api/v1/namespaces/${options.namespace}`, namespace: options.namespace ?? 'default', websocket: { ...options.websocket, @@ -59,8 +92,12 @@ export default class HttpBase { protected async wrapError(response: Promise>) { return response.catch((err) => { if (axios.isAxiosError(err)) { - const errorMessage = err.response?.data?.error; - const ffError = new FireFlyError(errorMessage ?? err.message); + const errorMessage = err.response?.data?.error ?? err.message; + const errorClass = + errorMessage?.includes('FF10430') || errorMessage?.includes('FF10431') + ? FireFlyIdempotencyError + : FireFlyError; + const ffError = new errorClass(errorMessage, err, err.request.path); if (this.errorHandler !== undefined) { this.errorHandler(ffError); } @@ -92,13 +129,18 @@ export default class HttpBase { return response.data; } - protected async replaceOne(url: string, data: any) { - const response = await this.wrapError(this.http.put(url, data)); + protected async updateOne(url: string, data: any, options?: FireFlyUpdateOptions) { + const response = await this.wrapError(this.http.patch(url, data, mapConfig(options))); + return response.data; + } + + protected async replaceOne(url: string, data: any, options?: FireFlyReplaceOptions) { + const response = await this.wrapError(this.http.put(url, data, mapConfig(options))); return response.data; } - protected async deleteOne(url: string) { - await this.wrapError(this.http.delete(url)); + protected async deleteOne(url: string, options?: FireFlyDeleteOptions) { + await this.wrapError(this.http.delete(url, mapConfig(options))); } onError(handler: (err: FireFlyError) => void) { diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 17051a8..c91bd41 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -1,4 +1,6 @@ +import * as http from 'http'; import { AxiosRequestConfig } from 'axios'; +import * as WebSocket from 'ws'; import { operations } from './schema'; /** @@ -13,16 +15,26 @@ import { operations } from './schema'; // General -export class FireFlyError extends Error {} +export class FireFlyError extends Error { + constructor(message?: string, public originalError?: Error, public path?: string) { + super(message); + } +} + +export class FireFlyIdempotencyError extends FireFlyError {} -export interface FireFlyGetOptions { - confirm: undefined; +interface FireFlyBaseHttpOptions { requestConfig?: AxiosRequestConfig; } -export interface FireFlyCreateOptions { +export interface FireFlyGetOptions extends FireFlyBaseHttpOptions {} +export interface FireFlyUpdateOptions extends FireFlyBaseHttpOptions {} +export interface FireFlyReplaceOptions extends FireFlyBaseHttpOptions {} +export interface FireFlyDeleteOptions extends FireFlyBaseHttpOptions {} + +export interface FireFlyCreateOptions extends FireFlyBaseHttpOptions { confirm?: boolean; - requestConfig?: AxiosRequestConfig; + publish?: boolean; } export interface FireFlyOptionsInput { @@ -30,6 +42,8 @@ export interface FireFlyOptionsInput { namespace?: string; username?: string; password?: string; + baseURL?: string; + namespaceBaseURL?: string; websocket?: { host?: string; reconnectDelay?: number; @@ -47,6 +61,14 @@ export interface FireFlyOptions extends FireFlyOptionsInput { }; } +export interface FireFlyWebSocketSender { + send: (json: JSON) => void; +} + +export interface FireFlyWebSocketConnectCallback { + (sender: FireFlyWebSocketSender): void | Promise; +} + export interface FireFlyWebSocketOptions { host: string; namespace: string; @@ -57,14 +79,31 @@ export interface FireFlyWebSocketOptions { autoack: boolean; reconnectDelay: number; heartbeatInterval: number; + socketOptions?: WebSocket.ClientOptions | http.ClientRequestArgs; + afterConnect?: FireFlyWebSocketConnectCallback; } +// Namespace +export type FireFlyNamespaceResponse = Required< + operations['getNamespace']['responses']['200']['content']['application/json'] +>; + // Network +export type FireFlyIdentityFilter = operations['getIdentities']['parameters']['query']; export type FireFlyOrganizationFilter = operations['getNetworkOrgs']['parameters']['query']; export type FireFlyNodeFilter = operations['getNetworkNodes']['parameters']['query']; export type FireFlyVerifierFilter = operations['getVerifiers']['parameters']['query']; +export type FireFlyIdentityRequest = + operations['postNewIdentity']['requestBody']['content']['application/json']; + +export type FireFlyIdentityResponse = Required< + operations['getIdentityByID']['responses']['200']['content']['application/json'] +>; +export type FireFlyIdentitiesResponse = Required< + operations['getIdentities']['responses']['200']['content']['application/json'] +>; export type FireFlyOrganizationResponse = Required< operations['getNetworkOrg']['responses']['200']['content']['application/json'] >; @@ -108,17 +147,17 @@ export interface FireFlyEphemeralSubscription extends FireFlySubscriptionBase { } export interface FireFlyEnrichedEvent extends FireFlyEventResponse { - blockchainEvent?: unknown; - contractAPI?: unknown; - contractInterface?: unknown; + blockchainEvent?: FireFlyBlockchainEventResponse; + contractAPI?: FireFlyContractAPIResponse; + contractInterface?: FireFlyContractInterfaceResponse; datatype?: FireFlyDatatypeResponse; - identity?: unknown; + identity?: FireFlyIdentityResponse; message?: FireFlyMessageResponse; - namespaceDetails?: unknown; - tokenApproval?: unknown; + tokenApproval?: FireFlyTokenApprovalResponse; tokenPool?: FireFlyTokenPoolResponse; tokenTransfer?: FireFlyTokenTransferResponse; transaction?: FireFlyTransactionResponse; + operation?: FireFlyOperationResponse; } export interface FireFlyEventDelivery extends FireFlyEnrichedEvent { @@ -145,11 +184,17 @@ export type FireFlyDataFilter = operations['getData']['parameters']['query']; export type FireFlyDataRequest = operations['postData']['requestBody']['content']['application/json']; +export type FireFlyDataBlobRequest = + operations['postData']['requestBody']['content']['multipart/form-data']; export type FireFlyDataResponse = Required< operations['getDataByID']['responses']['200']['content']['application/json'] >; +export const FireFlyDataBlobRequestDefaults: FireFlyDataBlobRequest = { + autometa: 'true', +}; + // Messages export type FireFlyMessageFilter = operations['getMsgs']['parameters']['query']; @@ -166,6 +211,9 @@ export type FireFlyMessageResponse = Required< export type FireFlyBatchResponse = Required< operations['getBatchByID']['responses']['200']['content']['application/json'] >; +export type FireFlyGroupResponse = Required< + operations['getGroupByHash']['responses']['200']['content']['application/json'] +>; export interface FireFlyPrivateSendOptions extends FireFlyCreateOptions { requestReply?: boolean; @@ -184,6 +232,8 @@ export type FireFlyTokenPoolResponse = Required< // Token Transfers +export type FireFlyTokenTransferFilter = operations['getTokenTransfers']['parameters']['query']; + export type FireFlyTokenMintRequest = operations['postTokenMint']['requestBody']['content']['application/json']; export type FireFlyTokenBurnRequest = @@ -199,9 +249,23 @@ export type FireFlyTokenTransferResponse = Required< export type FireFlyTokenBalanceFilter = operations['getTokenBalances']['parameters']['query']; -export type FireFlyTokenBalanceResponse = Required< +type BalancesList = Required< operations['getTokenBalances']['responses']['200']['content']['application/json'] >; +const balances: BalancesList = []; +export type FireFlyTokenBalanceResponse = typeof balances[0]; + +// Token Approvals + +export type FireFlyTokenApprovalFilter = operations['getTokenApprovals']['parameters']['query']; + +export type FireFlyTokenApprovalRequest = + operations['postTokenApproval']['requestBody']['content']['application/json']; +type ApprovalsList = + operations['getTokenApprovals']['responses']['200']['content']['application/json']; + +const approvals: ApprovalsList = []; +export type FireFlyTokenApprovalResponse = typeof approvals[0]; // Operations + Transactions @@ -241,3 +305,27 @@ export type FireFlyContractAPIResponse = Required< export type FireFlyContractListenerResponse = Required< operations['getContractListenerByNameOrID']['responses']['200']['content']['application/json'] >; + +export type FireFlyContractInvokeRequest = + operations['postContractInvoke']['requestBody']['content']['application/json']; +export type FireFlyContractAPIInvokeRequest = + operations['postContractAPIInvoke']['requestBody']['content']['application/json']; +export type FireFlyContractInvokeResponse = Required< + operations['postContractInvoke']['responses']['202']['content']['application/json'] +>; + +export type FireFlyContractQueryRequest = + operations['postContractQuery']['requestBody']['content']['application/json']; +export type FireFlyContractAPIQueryRequest = + operations['postContractAPIQuery']['requestBody']['content']['application/json']; +export type FireFlyContractQueryResponse = Required< + operations['postContractQuery']['responses']['200']['content']['application/json'] +>; + +// Blockchain Events + +export type FireFlyBlockchainEventFilter = operations['getBlockchainEvents']['parameters']['query']; + +export type FireFlyBlockchainEventResponse = Required< + operations['getBlockchainEventByID']['responses']['200']['content']['application/json'] +>; diff --git a/lib/logger.ts b/lib/logger.ts index 9c8e641..81c5f51 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,28 +1,56 @@ +enum logLevels { + LEVEL_NONE, + LEVEL_ERROR, + LEVEL_WARN, + LEVEL_LOG, + LEVEL_INFO, + LEVEL_DEBUG, + LEVEL_TRACE +} + export default class Logger { - constructor(private prefix: string) {} + + private logLevel = logLevels.LEVEL_LOG; + + constructor(private prefix: string) { + switch (process.env.FF_SDK_LOG_LEVEL) { + case 'NONE': this.logLevel = logLevels.LEVEL_NONE; break; + case 'ERROR': this.logLevel = logLevels.LEVEL_ERROR; break; + case 'WARN': this.logLevel = logLevels.LEVEL_WARN; break; + case 'LOG': this.logLevel = logLevels.LEVEL_LOG; break; + case 'INFO': this.logLevel = logLevels.LEVEL_INFO; break; + case 'DEBUG': this.logLevel = logLevels.LEVEL_DEBUG; break; + case 'TRACE': this.logLevel = logLevels.LEVEL_TRACE; break; + } + } private formatMessage(message: string) { const now = new Date().toISOString(); return `${now} [${this.prefix}] ${message}`; } - log(message?: any, ...optionalParams: any[]): void { - console.log(this.formatMessage(message), ...optionalParams); + error(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_ERROR && console.error(this.formatMessage(message), ...optionalParams); } - debug(message?: any, ...optionalParams: any[]): void { - console.debug(this.formatMessage(message), ...optionalParams); + warn(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_WARN && console.warn(this.formatMessage(message), ...optionalParams); } - trace(message?: any, ...optionalParams: any[]): void { - console.trace(this.formatMessage(message), ...optionalParams); + log(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_LOG && console.log(this.formatMessage(message), ...optionalParams); } - warn(message?: any, ...optionalParams: any[]): void { - console.warn(this.formatMessage(message), ...optionalParams); + info(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_INFO && console.info(this.formatMessage(message), ...optionalParams); } - error(message?: any, ...optionalParams: any[]): void { - console.error(this.formatMessage(message), ...optionalParams); + debug(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_DEBUG && console.debug(this.formatMessage(message), ...optionalParams); } + + trace(message?: any, ...optionalParams: any[]): void { + this.logLevel >= logLevels.LEVEL_TRACE && console.trace(this.formatMessage(message), ...optionalParams); + } + } diff --git a/lib/schema.ts b/lib/schema.ts index 078c05b..1d1debf 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -4878,8 +4878,32 @@ export interface operations { * @description The timestamp of when the message was confirmed/rejected */ confirmed?: string; - /** @description The list of data elements attached to the message */ + /** @description For input allows you to specify data in-line in the message, that will be turned into data attachments. For output when fetchdata is used on API calls, includes the in-line data payloads of all data attachments */ data?: { + /** @description An optional in-line hash reference to a previously uploaded binary data blob */ + blob?: { + /** + * Format: byte + * @description The hash of the binary blob data + */ + hash?: string; + /** @description The name field from the metadata attached to the blob, commonly used as a path/filename, and indexed for search */ + name?: string; + /** @description If this data has been published to shared storage, this field is the id of the data in the shared storage plugin (IPFS hash etc.) */ + public?: string; + /** + * Format: int64 + * @description The size of the binary data + */ + size?: number; + }; + /** @description The optional datatype to use for validation of the in-line data */ + datatype?: { + /** @description The name of the datatype */ + name?: string; + /** @description The version of the datatype. Semantic versioning is encouraged, such as v1.0.1 */ + version?: string; + }; /** * Format: byte * @description The hash of the referenced data @@ -4890,6 +4914,10 @@ export interface operations { * @description The UUID of the referenced data resource */ id?: string; + /** @description The data validator type to use for in-line data */ + validator?: string; + /** @description The in-line value for the data. Can be any JSON type - object, array, string, number or boolean */ + value?: any; }[]; /** @description Allows you to specify details of the private group of recipients in-line in the message. Alternative to using the header.group to specify the hash of a group that has been previously resolved */ group?: { @@ -5813,8 +5841,32 @@ export interface operations { * @description The timestamp of when the message was confirmed/rejected */ confirmed?: string; - /** @description The list of data elements attached to the message */ + /** @description For input allows you to specify data in-line in the message, that will be turned into data attachments. For output when fetchdata is used on API calls, includes the in-line data payloads of all data attachments */ data?: { + /** @description An optional in-line hash reference to a previously uploaded binary data blob */ + blob?: { + /** + * Format: byte + * @description The hash of the binary blob data + */ + hash?: string; + /** @description The name field from the metadata attached to the blob, commonly used as a path/filename, and indexed for search */ + name?: string; + /** @description If this data has been published to shared storage, this field is the id of the data in the shared storage plugin (IPFS hash etc.) */ + public?: string; + /** + * Format: int64 + * @description The size of the binary data + */ + size?: number; + }; + /** @description The optional datatype to use for validation of the in-line data */ + datatype?: { + /** @description The name of the datatype */ + name?: string; + /** @description The version of the datatype. Semantic versioning is encouraged, such as v1.0.1 */ + version?: string; + }; /** * Format: byte * @description The hash of the referenced data @@ -5825,6 +5877,10 @@ export interface operations { * @description The UUID of the referenced data resource */ id?: string; + /** @description The data validator type to use for in-line data */ + validator?: string; + /** @description The in-line value for the data. Can be any JSON type - object, array, string, number or boolean */ + value?: any; }[]; /** @description Allows you to specify details of the private group of recipients in-line in the message. Alternative to using the header.group to specify the hash of a group that has been previously resolved */ group?: { @@ -6999,8 +7055,6 @@ export interface operations { protocolId?: string; /** @description A string identifying the parties and entities in the scope of this approval, as provided by the token connector */ subject?: string; - /** @description The index of the token within the pool that this approval applies to */ - tokenIndex?: string; /** @description If submitted via FireFly, this will reference the UUID of the FireFly transaction (if the token connector in use supports attaching data) */ tx?: { /** @@ -7076,8 +7130,6 @@ export interface operations { protocolId?: string; /** @description A string identifying the parties and entities in the scope of this approval, as provided by the token connector */ subject?: string; - /** @description The index of the token within the pool that this approval applies to */ - tokenIndex?: string; /** @description If submitted via FireFly, this will reference the UUID of the FireFly transaction (if the token connector in use supports attaching data) */ tx?: { /** @@ -7133,8 +7185,6 @@ export interface operations { protocolId?: string; /** @description A string identifying the parties and entities in the scope of this approval, as provided by the token connector */ subject?: string; - /** @description The index of the token within the pool that this approval applies to */ - tokenIndex?: string; /** @description If submitted via FireFly, this will reference the UUID of the FireFly transaction (if the token connector in use supports attaching data) */ tx?: { /** @@ -7157,12 +7207,8 @@ export interface operations { approved?: boolean; /** @description Input only field, with token connector specific configuration of the approval. See your chosen token connector documentation for details */ config?: { [key: string]: any }; - /** @description Token connector specific information about the approval operation, such as whether it applied to a limited balance of a fungible token. See your chosen token connector documentation for details */ - info?: { [key: string]: any }; /** @description The blockchain signing key for the approval request. On input defaults to the first signing key of the organization that operates the node */ key?: string; - /** @description The namespace for the approval, which must match the namespace of the token pool */ - namespace?: string; /** @description The blockchain identity that is granted the approval */ operator?: string; /** @@ -7170,8 +7216,6 @@ export interface operations { * @description The UUID the token pool this approval applies to */ pool?: string; - /** @description The index of the token within the pool that this approval applies to */ - tokenIndex?: string; }; }; }; @@ -7415,6 +7459,8 @@ export interface operations { "application/json": { /** @description The amount for the transfer. For non-fungible tokens will always be 1. For fungible tokens, the number of decimals for the token pool should be considered when inputting the amount. For example, with 18 decimals a fractional balance of 10.234 will be specified as 10,234,000,000,000,000,000 */ amount?: string; + /** @description Input only field, with token connector specific configuration of the transfer. See your chosen token connector documentation for details */ + config?: { [key: string]: any }; /** @description The source account for the transfer. On input defaults to the value of 'key' */ from?: string; /** @description The blockchain signing key for the transfer. On input defaults to the first signing key of the organization that operates the node */ @@ -7502,6 +7548,8 @@ export interface operations { pool?: string; /** @description The index of the token within the pool that this transfer applies to */ tokenIndex?: string; + /** @description The URI of the token this transfer applies to */ + uri?: string; }; }; }; @@ -7695,6 +7743,8 @@ export interface operations { "application/json": { /** @description The amount for the transfer. For non-fungible tokens will always be 1. For fungible tokens, the number of decimals for the token pool should be considered when inputting the amount. For example, with 18 decimals a fractional balance of 10.234 will be specified as 10,234,000,000,000,000,000 */ amount?: string; + /** @description Input only field, with token connector specific configuration of the transfer. See your chosen token connector documentation for details */ + config?: { [key: string]: any }; /** @description The blockchain signing key for the transfer. On input defaults to the first signing key of the organization that operates the node */ key?: string; /** @description You can specify a message to correlate with the transfer, which can be of type broadcast or private. Your chosen token connector and on-chain smart contract must support on-chain/off-chain correlation by taking a `data` input on the transfer */ @@ -7782,6 +7832,8 @@ export interface operations { to?: string; /** @description The index of the token within the pool that this transfer applies to */ tokenIndex?: string; + /** @description The URI of the token this transfer applies to */ + uri?: string; }; }; }; @@ -8445,6 +8497,8 @@ export interface operations { "application/json": { /** @description The amount for the transfer. For non-fungible tokens will always be 1. For fungible tokens, the number of decimals for the token pool should be considered when inputting the amount. For example, with 18 decimals a fractional balance of 10.234 will be specified as 10,234,000,000,000,000,000 */ amount?: string; + /** @description Input only field, with token connector specific configuration of the transfer. See your chosen token connector documentation for details */ + config?: { [key: string]: any }; /** @description The source account for the transfer. On input defaults to the value of 'key' */ from?: string; /** @description The blockchain signing key for the transfer. On input defaults to the first signing key of the organization that operates the node */ @@ -8534,6 +8588,8 @@ export interface operations { to?: string; /** @description The index of the token within the pool that this transfer applies to */ tokenIndex?: string; + /** @description The URI of the token this transfer applies to */ + uri?: string; }; }; }; diff --git a/lib/websocket.ts b/lib/websocket.ts index bb1c3c3..59377a5 100644 --- a/lib/websocket.ts +++ b/lib/websocket.ts @@ -26,7 +26,7 @@ export class FireFlyWebSocket { private readonly logger = new Logger(FireFlyWebSocket.name); private socket?: WebSocket; - private closed = false; + private closed? = () => {}; private pingTimer?: NodeJS.Timeout; private disconnectTimer?: NodeJS.Timeout; private reconnectTimer?: NodeJS.Timeout; @@ -57,10 +57,11 @@ export class FireFlyWebSocket { ? `${this.options.username}:${this.options.password}` : undefined; const socket = (this.socket = new WebSocket(url, { + ...this.options.socketOptions, auth, handshakeTimeout: this.options.heartbeatInterval, })); - this.closed = false; + this.closed = undefined; socket .on('open', () => { @@ -82,6 +83,9 @@ export class FireFlyWebSocket { ); this.logger.log(`Started listening on subscription ${this.options.namespace}:${name}`); } + if (this.options?.afterConnect !== undefined) { + this.options.afterConnect(this); + } }) .on('error', (err) => { this.logger.error('Error', err.stack); @@ -89,6 +93,7 @@ export class FireFlyWebSocket { .on('close', () => { if (this.closed) { this.logger.log('Closed'); + this.closed(); // do this after all logging } else { this.disconnectDetected = true; this.reconnect('Closed by peer'); @@ -147,7 +152,17 @@ export class FireFlyWebSocket { if (!this.reconnectTimer) { this.close(); this.logger.error(`Websocket closed: ${msg}`); - this.reconnectTimer = setTimeout(() => this.connect(), this.options.reconnectDelay); + if (this.options.reconnectDelay === -1) { + // do not attempt to reconnect + } else { + this.reconnectTimer = setTimeout(() => this.connect(), this.options.reconnectDelay); + } + } + } + + send(json: JSON) { + if (this.socket !== undefined) { + this.socket.send(JSON.stringify(json)); } } @@ -163,8 +178,10 @@ export class FireFlyWebSocket { } } - close() { - this.closed = true; + async close(wait?: boolean): Promise { + const closedPromise = new Promise(resolve => { + this.closed = resolve; + }); this.clearPingTimers(); if (this.socket) { try { @@ -172,6 +189,7 @@ export class FireFlyWebSocket { } catch (e: any) { this.logger.warn(`Failed to clean up websocket: ${e.message}`); } + if (wait) await closedPromise; this.socket = undefined; } } From 16f7ee53a49d200ba77f27a311e1929b0ce4036a Mon Sep 17 00:00:00 2001 From: Andrew Richardson Date: Wed, 11 Oct 2023 21:49:59 -0400 Subject: [PATCH 2/3] Expose "protocol_error" event type Signed-off-by: Andrew Richardson --- lib/interfaces.ts | 3 ++- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/interfaces.ts b/lib/interfaces.ts index c91bd41..d9ebc36 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -160,7 +160,8 @@ export interface FireFlyEnrichedEvent extends FireFlyEventResponse { operation?: FireFlyOperationResponse; } -export interface FireFlyEventDelivery extends FireFlyEnrichedEvent { +export interface FireFlyEventDelivery extends Omit { + type: FireFlyEnrichedEvent['type'] | 'protocol_error'; subscription: { id: string; name: string; diff --git a/package-lock.json b/package-lock.json index dada2ca..011c6db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@hyperledger/firefly-sdk", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@hyperledger/firefly-sdk", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "dependencies": { "axios": "^0.26.1", diff --git a/package.json b/package.json index 032c340..4a5ba9e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperledger/firefly-sdk", - "version": "1.0.0", + "version": "1.0.1", "description": "Client SDK for Hyperledger FireFly", "main": "dist/index.js", "types": "dist/index.d.ts", From 539d538eff97e8ae16710d64e6c68d52a431b4fa Mon Sep 17 00:00:00 2001 From: Andrew Richardson Date: Wed, 11 Oct 2023 23:10:00 -0400 Subject: [PATCH 3/3] Add "noack" option to completely disable managed ack on websockets Also move to passing an "options" object instead of continuing to add individual parameters to the listen() function. Signed-off-by: Andrew Richardson --- lib/firefly.ts | 24 ++++++++++++++++-------- lib/interfaces.ts | 3 ++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/firefly.ts b/lib/firefly.ts index b9bf46a..e2dcd1a 100644 --- a/lib/firefly.ts +++ b/lib/firefly.ts @@ -548,26 +548,34 @@ export default class FireFly extends HttpBase { subscriptions: string | string[] | FireFlySubscriptionBase, callback: FireFlyWebSocketCallback, socketOptions?: WebSocket.ClientOptions | http.ClientRequestArgs, - afterConnect?: FireFlyWebSocketConnectCallback, + fireflySocketOptions?: Partial | FireFlyWebSocketConnectCallback, ): FireFlyWebSocket { + if (typeof fireflySocketOptions === 'function') { + // Legacy compatibility (afterConnect callback passed as 4th arg) + fireflySocketOptions = { + afterConnect: fireflySocketOptions, + }; + } const options: FireFlyWebSocketOptions = { host: this.options.websocket.host, namespace: this.options.namespace, username: this.options.username, password: this.options.password, - subscriptions: [], - autoack: false, reconnectDelay: this.options.websocket.reconnectDelay, heartbeatInterval: this.options.websocket.heartbeatInterval, - socketOptions: socketOptions, - afterConnect: afterConnect, + autoack: false, + ...fireflySocketOptions, + socketOptions, + subscriptions: [], }; const handler: FireFlyWebSocketCallback = (socket, event) => { this.queue = this.queue.finally(() => callback(socket, event)); - this.queue.then(() => { - socket.ack(event); - }); + if (!options.noack) { + this.queue.then(() => { + socket.ack(event); + }); + } }; if (Array.isArray(subscriptions)) { diff --git a/lib/interfaces.ts b/lib/interfaces.ts index d9ebc36..9679672 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -76,7 +76,8 @@ export interface FireFlyWebSocketOptions { username?: string; password?: string; ephemeral?: FireFlyEphemeralSubscription; - autoack: boolean; + autoack?: boolean; + noack?: boolean; reconnectDelay: number; heartbeatInterval: number; socketOptions?: WebSocket.ClientOptions | http.ClientRequestArgs;