github.com/kaleido-io/firefly@v0.0.0-20210622132723-8b4b6aacb971/kat/src/lib/event-stream.ts (about) 1 // Copyright © 2021 Kaleido, Inc. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import * as QueryString from 'querystring'; 16 import { URL } from 'url'; 17 import axios, { AxiosInstance } from 'axios'; 18 import { promisify } from 'util'; 19 import * as timers from 'timers'; 20 import * as database from '../clients/database'; 21 const sleep = promisify(timers.setTimeout); 22 23 import * as utils from './utils'; 24 import { config } from './config' 25 import { IConfig, IEventStream, IEventStreamSubscription } from './interfaces'; 26 const logger = utils.getLogger('lib/event-stream.ts'); 27 28 /* istanbul ignore next */ 29 const requestLogger = (config: any) => { 30 const qs = config.params ? `?${QueryString.stringify(config.params)}` : ''; 31 logger.info(`--> ${config.method} ${config.baseURL}${config.url}${qs}`); 32 logger.debug(config.data); 33 return config; 34 }; 35 36 /* istanbul ignore next */ 37 const responseLogger = (response: any) => { 38 const { config, status, data } = response; 39 logger.info(`<-- ${config.method} ${config.url} [${status}]`); 40 logger.debug(data); 41 return response; 42 }; 43 44 /* istanbul ignore next */ 45 const errorLogger = (err: any) => { 46 const { config = {}, response = {} } = err; 47 const { status, data } = response; 48 logger.info(`<-- ${config.method} ${config.url} [${status || err}]: ${JSON.stringify(data)}`); 49 throw err; 50 }; 51 52 const subscriptionsInfoEthereum = [ 53 ['Asset instance created', 'AssetInstanceCreated'], 54 ['Asset instance batch created', 'AssetInstanceBatchCreated'], 55 ['Payment instance created', 'PaymentInstanceCreated'], 56 ['Payment definition created', 'PaymentDefinitionCreated'], 57 ['Asset definition created', 'AssetDefinitionCreated'], 58 ['Asset instance property set', 'AssetInstancePropertySet'], 59 ['Described payment instance created', 'DescribedPaymentInstanceCreated'], 60 ['Described asset instance created', 'DescribedAssetInstanceCreated'], 61 ['Described payment definition created', 'DescribedPaymentDefinitionCreated'], 62 ['Member registered', 'MemberRegistered'] 63 ]; 64 65 const subscriptionInfoCorda = [ 66 ['Asset instance created', 'io.kaleido.kat.states.AssetInstanceCreated'], 67 ['Described asset instance created', 'io.kaleido.kat.states.DescribedAssetInstanceCreated'], 68 ['Asset instance batch created', 'io.kaleido.kat.states.AssetInstanceBatchCreated'], 69 ['Asset instance property set', 'io.kaleido.kat.states.AssetInstancePropertySet'] 70 ] 71 72 export const ensureEventStreamAndSubscriptions = async () => { 73 if(!config.eventStreams.skipSetup) { 74 const esMgr = new EventStreamManager(config); 75 await esMgr.ensureEventStreamsWithRetry(); 76 } 77 }; 78 79 class EventStreamManager { 80 private gatewayPath: string; 81 private api: AxiosInstance; 82 private streamDetails: any; 83 private retryCount: number; 84 private retryDelay: number; 85 private protocol: string; 86 87 constructor(config: IConfig) { 88 const apiURL = new URL(config.apiGateway.apiEndpoint); 89 this.gatewayPath = apiURL.pathname.replace(/^\//, ''); 90 apiURL.pathname = ''; 91 const creds = `${config.apiGateway.auth?.user??config.appCredentials.user}:${config.apiGateway.auth?.password??config.appCredentials.password}`; 92 this.api = axios.create({ 93 baseURL: apiURL.href, 94 headers: { 95 Authorization: `Basic ${Buffer.from(creds).toString('base64')}` 96 } 97 }); 98 this.api.interceptors.request.use(requestLogger); 99 this.api.interceptors.response.use(responseLogger, errorLogger); 100 this.retryCount = 20; 101 this.retryDelay = 5000; 102 this.protocol = config.protocol; 103 this.streamDetails = { 104 name: config.eventStreams.topic, 105 errorHandling: config.eventStreams.config?.errorHandling??'block', 106 batchSize: config.eventStreams.config?.batchSize??50, 107 batchTimeoutMS: config.eventStreams.config?.batchTimeoutMS??500, 108 type: "websocket", 109 websocket: { 110 topic: config.eventStreams.topic 111 } 112 } 113 if(this.protocol === 'ethereum') { 114 // Due to spell error in ethconnect, we do this for now until ethconnect is updated 115 this.streamDetails.blockedReryDelaySec = config.eventStreams.config?.blockedRetryDelaySec??30; 116 } else { 117 this.streamDetails.blockedRetryDelaySec = config.eventStreams.config?.blockedRetryDelaySec??30; 118 } 119 } 120 121 async ensureEventStreamsWithRetry() { 122 for (let i = 1; i <= this.retryCount; i++) { 123 try { 124 if (i > 1) await sleep(this.retryDelay); 125 const stream: IEventStream = await this.ensureEventStream(); 126 await this.ensureSubscriptions(stream); 127 return; 128 } catch (err) { 129 logger.error(`Attempt ${i} to initialize event streams failed`, err); 130 } 131 } 132 throw new Error("Failed to initialize event streams after retries"); 133 } 134 135 async ensureEventStream(): Promise<IEventStream> { 136 const { data: existingStreams } = await this.api.get('eventstreams'); 137 let stream = existingStreams.find((s: any) => s.name === this.streamDetails.name); 138 if (stream) { 139 const { data: patchedStream } = await this.api.patch(`eventstreams/${stream.id}`, this.streamDetails); 140 return patchedStream; 141 } 142 const { data: newStream } = await this.api.post('eventstreams', this.streamDetails); 143 return newStream; 144 } 145 146 subscriptionInfo() { 147 switch(this.protocol) { 148 case 'ethereum': 149 return subscriptionsInfoEthereum; 150 case 'corda': 151 return subscriptionInfoCorda; 152 default: 153 throw new Error("Unsupported protocol."); 154 } 155 } 156 157 async createSubscription(eventType: string, streamId: string, description: string): Promise<{id: string, name: string}> { 158 switch(this.protocol) { 159 case 'ethereum': 160 return this.api.post(`${this.gatewayPath}/${eventType}/Subscribe`, { 161 description, 162 name: eventType, 163 stream: streamId, 164 fromBlock: "0", // Subscribe from the start of the chain 165 }).then(r => { logger.info(`Created subscription ${eventType}: ${r.data.id}`); return r.data }); 166 case 'corda': 167 return this.api.post('subscriptions', { 168 name: eventType, 169 stream: streamId, 170 fromTime: null, // BEGINNING is specified as `null` in Corda event streams 171 filter: { 172 stateType: eventType, 173 stateStatus: "unconsumed", 174 relevancyStatus: "all" 175 } 176 }).then(r => { logger.info(`Created subscription ${eventType}: ${r.data.id}`); return r.data }); 177 default: 178 throw new Error("Unsupported protocol."); 179 } 180 } 181 182 183 184 async ensureSubscriptions(stream: IEventStream) { 185 const dbSubscriptions = (await database.retrieveSubscriptions()) || {}; 186 const { data: existing } = await this.api.get('subscriptions'); 187 for (const [description, eventName] of this.subscriptionInfo()) { 188 const dbEventName = eventName.replace(/\./g, '_'); 189 let sub = existing.find((s: IEventStreamSubscription) => s.name === eventName && s.stream === stream.id); 190 let storedSubId = dbSubscriptions[dbEventName]; 191 if (!sub || sub.id !== storedSubId) { 192 if (sub) { 193 logger.info(`Deleting stale subscription that does not match persisted id ${storedSubId}`, sub); 194 await this.api.delete(`subscriptions/${sub.id}`); 195 } 196 const newSub = await this.createSubscription(eventName, stream.id, description); 197 dbSubscriptions[dbEventName] = newSub.id; 198 } else { 199 logger.info(`Subscription ${eventName}: ${sub.id}`); 200 } 201 } 202 await database.upsertSubscriptions(dbSubscriptions); 203 } 204 205 }