diff --git a/Feeds Daemon.postman_collection.json b/Feeds Daemon.postman_collection.json index 981d694..adb435f 100644 --- a/Feeds Daemon.postman_collection.json +++ b/Feeds Daemon.postman_collection.json @@ -100,7 +100,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\n \"method\":\"updateFeed\",\n \"params\": {\n \"feed_id\":\"satoshi\",\n \"fields\": [\n {\n \"name\": \"Bitcoin\",\n \"value\": 1.243\n }\n ]\n }\n}", + "raw": "{\n \"method\":\"updateFeed\",\n \"params\": {\n \"feed_id\":\"satoshi\",\n \"fields\": [\n {\n \"name\": \"total balance\",\n \"value\": 11\n },\n {\n \"name\": \"total open pnl\",\n \"value\": 12\n },\n {\n \"name\": \"total open pnl and total balance\",\n \"value\": { \"balance\": 10, \"absolute_pnl\": 1, \"relative_pnl\": 10 }\n }\n ]\n }\n}", "options": { "raw": { "language": "json" diff --git a/README.md b/README.md index b34b28b..8419d53 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ node start.js ## Test & Development There are tests for all methods located in `./test` dir. You can run them by `npm run test` -There examples in `./examples` folder. They require running daemon. Run `npm run start` to start daemon and in separate terminal run `npm run example:account` to start generating account feed. See [examples readme](./example/README.md) for more details +There examples in `./examples` folder. They require running daemon. Run `npm run start` to start daemon and in separate terminal run `npm run example:exchange:account` to start generating account feed. See [examples readme](./example/README.md) for more details ## RPC API @@ -78,14 +78,13 @@ curl --location --request POST 'http://localhost:8787/v0.1/rpc' \ Response ``` json { - "jsonrpc": "2.0", - "id": "925b10c27f4ad350ab3ef7e027605fd83388c999a890cfdd8e6061656b5a5513", - "result": { - "slashdrive": { - "key": "", - "encryption_key": "" - } - } + "jsonrpc": "2.0", + "id": "", + "result": { + "url": ":", + "feed_key": "", + "encrypt_key": "" + } } ``` @@ -95,14 +94,22 @@ Update feed request curl --location --request POST 'http://localhost:8787/v0.1/rpc' \ --header 'Content-Type: application/json' \ --data-raw '{ - "method":"updateFeedBalance", + "method":"updateFeed", "params": { "feed_id":"satoshi123", "fields": [ - { - "name": "Bitcoin", - "value": 1.442 - } + { + "name": "total balance", + "value": 11 + }, + { + "name": "total open pnl", + "value": 12 + }, + { + "name": "total open pnl and total balance", + "value": { "balance": 10, "absolute_pnl": 1, "relative_pnl": 10 } + } ] } }' @@ -111,8 +118,10 @@ Response ``` json { "jsonrpc": "2.0", - "id": "4fefad839fa440cc2a85d8178d1d895fa1044460080b8fe1a26b4942aa86c07f", - "result": true + "id": "", + "result": { + "updated": true + } } ``` @@ -129,10 +138,11 @@ Response ``` json { "jsonrpc": "2.0", - "id": "c6ccd88f842330ab60153b5fb512101d2ab76824189eee5690f9070ebe18cb87", + "id": "call id", "result": { - "feed_key": "", - "encrypt_key": "" + "url": ":", + "feed_key": "", + "encrypt_key": "" } } ``` diff --git a/example/README.md b/example/README.md index cc0b44c..3350772 100644 --- a/example/README.md +++ b/example/README.md @@ -1,4 +1,4 @@ -

Account feeds example

+

Exchange account feeds example

## Start account feeds daemon @@ -45,7 +45,7 @@ $ npm run start ## Feed generation -Open a separate terminal window and execute `npm run example:account` from the root directory. This will create three hyperdrives. Each hyperdrive feeds the data for a particular customer account. The drive contents are accessible by knowing the discovery key and the encryption key. This information can be shared by [slashfeed URL](https://github.com/synonymdev/slashtags/tree/master/packages/url), which has the following format: `slashfeed:#encryptionKey=`. +Open a separate terminal window and execute `npm run example:exchange:account` from the root directory. This will create three hyperdrives. Each hyperdrive feeds the data for a particular customer account. The drive contents are accessible by knowing the discovery key and the encryption key. This information can be shared by [slashfeed URL](https://github.com/synonymdev/slashtags/tree/master/packages/url), which has the following format: `slashfeed:#encryptionKey=`. ```sh example Starting account feed +0ms @@ -64,9 +64,21 @@ The data will update periodically. ```sh example Updating account feeds +5s -example:abcde123 Updated feed: [ 'Bitcoin: "7.40"', 'Bitcoin P/L: "-99.27"' ] +0ms -example:satoshi9191 Updated feed: [ 'Bitcoin: "9.73"', 'Bitcoin P/L: "-51.02"' ] +0ms -example:synonymxyz Updated feed: [ 'Bitcoin: "8.52"', 'Bitcoin P/L: "54.86"' ] +0ms +example:abcde123 Updated feed: [ + 'total balance: "-5443472.07061947"', + 'total open pnl: {"absolute":"-9039929.42534387","relative":"11.30"}', + 'total open pnl and total balance: {"value":"8112571.67253644","absolute_pnl":"2471980.91819883","relative_pnl":"-26.32"}' +] +0ms +example:satoshi9191 Updated feed: [ + 'total balance: "-719298.06843400"', + 'total open pnl: {"absolute":"-331530.44059873","relative":"-61.52"}', + 'total open pnl and total balance: {"value":"-7741750.18537790","absolute_pnl":"-9805918.76897961","relative_pnl":"-17.91"}' +] +0ms +example:synonymxyz Updated feed: [ + 'total balance: "9146804.61864919"', + 'total open pnl: {"absolute":"1281327.62853056","relative":"-70.31"}', + 'total open pnl and total balance: {"value":"-797822.55459577","absolute_pnl":"6331277.87150443","relative_pnl":"10.34"}' +] +0ms ... ``` diff --git a/example/accountFeed.js b/example/exchangeAccountFeed.js similarity index 79% rename from example/accountFeed.js rename to example/exchangeAccountFeed.js index 594fa9a..dd7a382 100644 --- a/example/accountFeed.js +++ b/example/exchangeAccountFeed.js @@ -42,13 +42,26 @@ async function updateAccounts (accountIds) { for (const accountId of accountIds) { const update = [ { - name: 'Bitcoin', - value: faker.finance.amount(5, 10, 2) + name: 'total balance', + value: faker.finance.amount(-10000000, 10000000, 8), }, { - name: 'Bitcoin P/L', - value: faker.finance.amount(-100, 100, 2) - } + name: "total open pnl", + value: { + absolute: faker.finance.amount(-10000000, 10000000, 8), + relative: faker.finance.amount(-100, 100, 2) + }, + }, + { + name: "total open pnl and total balance", + value: { + value: faker.finance.amount(-10000000, 10000000, 8), + absolute_pnl: faker.finance.amount(-10000000, 10000000, 8), + relative_pnl: faker.finance.amount(-100, 100, 2) + }, + }, + + ] await updateFeed(accountId, update) accountLogger(accountId)('Updated feed:', update.map(u => `${u.name}: ${JSON.stringify(u.value)}`)) diff --git a/package.json b/package.json index d1c1807..dd9fe30 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "scripts": { "test": "mocha ./test/", "start": "DEBUG=stfeed* node start.js", - "example:account": "DEBUG=example* node example/accountFeed.js" + "example:exchange:account": "DEBUG=example* node example/exchangeAccountFeed.js" }, "repository": { "type": "git", diff --git a/schemas/slashfeed.json b/schemas/slashfeed.json index 3bb3dd4..90f21e6 100644 --- a/schemas/slashfeed.json +++ b/schemas/slashfeed.json @@ -1,25 +1,32 @@ { "name": "Bitfinex", "description": "Bitfinex account feed", - "type": "account_feed", + "type": "exchange_account_feed", "version": "0.0.1", "icons": { "48": "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAyNHB4IiBoZWlnaHQ9IjEwMjRweCIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICAgPGNpcmNsZSBjeD0iNTEyIiBjeT0iNTEyIiByPSI1MTIiIHN0eWxlPSJmaWxsOiMwM2NhOWIiLz4KICAgPHBhdGggZD0iTTczMi44MSAyNzVjLTEuNjctLjczLTE3My41MS0yNC42LTM0My4zOSA4Ni44OEMyODQgNDMxLjIyIDI3MCA1MzIuNjggMjc0LjgxIDYwMC4zNmMyNDcuMDgtMjcuODkgNDUyLjQxLTMxNy4yOSA0NTgtMzI1LjM2ek0yOTEuNTcgNjc1LjUzYzIxLjI0IDM4LjE0IDE4MS43NyAxMjQuNiAzMjMuODcgNS4zNVM3NDUuNjIgMzQ0LjY2IDczMi44MSAyNzVjLTQuNDcgMTAuMTEtMTU5LjYyIDM1Ni44OS00NDEuMjQgNDAwLjUxIiBzdHlsZT0iZmlsbDojZmZmIi8+Cjwvc3ZnPgo=" }, "fields": [ { - "name": "Bitcoin", - "description": "Bitcoin balance", - "main": "/feed/bitcoin/", - "type": "currency", - "units": "btc" + "type": "balance", + "name": "total balance", + "description": "Total balance", + "main": "/feed/total-balance/", + "units": "sats" }, { - "name": "Bitcoin P/L", - "description": "Bitcoin relative balance change", - "main": "/feed/bitcoin-p-l/", - "type": "delta", - "units": "%" + "type": "pnl", + "name": "total open pnl", + "description": "Total open Profit and Loss", + "main": "/feed/total-open-pnl/", + "units": "sats" + }, + { + "type": "pnl_and_balance", + "name": "total open pnl and total balance", + "description": "Bitcoin open Profit and Loss and Total balance", + "main": "/feed/total-open-pnl-and-total-balance/", + "units": "sats" } ] -} \ No newline at end of file +} diff --git a/src/BaseUtil.js b/src/BaseUtil.js index 6ff9d2c..f31d263 100644 --- a/src/BaseUtil.js +++ b/src/BaseUtil.js @@ -11,6 +11,15 @@ module.exports = (name, fileName) => { errName: `${name}_ERROR`, fileName }), - log: Log(name) + log: Log(name), + getFileName: (fieldName) => { + const regex = /[^a-z0-9]+/gi + const trailing = /-+$/ + + return `/${fieldName.toLowerCase().trim().replace(regex, '-').replace(trailing, '')}/` + }, + snakeToCamel: (str) => { + return str.toLowerCase().replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '').replace('_', '')) + } } } diff --git a/src/Feeds.js b/src/Feeds.js index e48fca1..2943735 100644 --- a/src/Feeds.js +++ b/src/Feeds.js @@ -1,11 +1,11 @@ -const fs = require('fs') const b4a = require('b4a') const z32 = require('z32') -const path = require('path') const Feeds = require('@synonymdev/feeds') const FeedDb = require('./FeedDb.js') +const SlashtagsSchema = require('./SlashtagsSchema.js') +const { snakeToCamel, getFileName } = require('./util.js') const Log = require('./Log.js') const customErr = require('./CustomError.js') @@ -17,13 +17,8 @@ const _err = { dbFailedStart: 'FAILED_TO_START_DB', feedIdMissing: 'FEED_ID_NOT_PASSED', failedCreateDrive: 'FAILED_TO_CREATE_FEED_FEED', - failedCreateDriveArgs: 'FAILED_TO_CREATE_FEED_INVALID_RESPONSE', - failedBalanceCheck: 'FAILED_BALANCE_CHECK', badConfig: 'BAD_CONSTRUCTOR_CONFIG', badSchemaSetup: 'FEED_SCHEMA_FAILED', - badFeedDataType: 'BAD_FEED_DATA_TYPE', - invalidSchema: 'INVALID_FEED_SCHEMA', - badUpdateParam: 'BAD_UPDATE_PARAM', updateFeedFailed: 'FAILED_TO_UPDATE_FEED', idNoFeed: 'FEED_ID_HAS_NO_FEED', failedDeleteFeed: 'FAILED_FEED_DELETE', @@ -35,111 +30,17 @@ const _err = { processAlreadyRunning: 'PROCESS_ALREADY_RUNNING', feedNotFound: 'FEED_FEED_NOT_FOUND', - missingFeedName: 'MISSING_FEED_NAME', - missingFeedDescription: 'MISSING_FEED_DESCRIPTION', - missingFeedIcons: 'MISSING_FEED_ICONS', - missingFeedFields: 'MISSING_FEED_FIELDS', - invalidFeedIcon: 'INVALID_FEED_ICON', - missingFields: 'MISSING_FIELDS', invalidFeedFields: 'INVALID_FEED_FIELDS', missingFieldName: 'MISSING_FIELD_NAME', - missingFieldDescription: 'MISSING_FIELD_DESCRIPTION', - missingFieldUnits: 'MISSING_FIELD_UNITS', - badFieldType: 'UNSUPPORTED_FIELD_TYPE', missingFieldValue: 'MISSING_FIELD_VALUE', unknownField: 'UKNOWN_FIELD', - invalidFieldValue: 'INVALID_FIELD_VALUE' } module.exports = class SlashtagsFeeds { static err = _err static Error = Err - static DEFAULT_SCHEMA_PATH = './schemas/slashfeed.json' - static DEFAULT_TYPES = [ - 'number', - 'utf-8' - ] - - static MEASURED_TYPES = [ - 'currency', - 'delta' - ] - - static VALID_TYPES = [ - ...this.DEFAULT_TYPES, - ...this.MEASURED_TYPES - ] - - static validateSchemaConfig (schemaConfig) { - if (!schemaConfig.name) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFeedName) - if (!schemaConfig.description) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFeedDescription) - if (!schemaConfig.icons) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFeedIcons) - if (!schemaConfig.fields) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFeedFields) - if (!Array.isArray(schemaConfig.fields)) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.invalidFeedFields) - - const imageRX = /^data:image\/((svg\+xml)|(png));base64,.+$/ - for (const size in schemaConfig.icons) { - const icon = schemaConfig.icons[size] - - if (typeof icon !== 'string') throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.invalidFeedIcon) - if (!imageRX.test(icon)) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.invalidFeedIcon) - } - - schemaConfig.fields.forEach((field) => { - if (!field.name) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFieldName) - if (!field.description) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFieldDescription) - if (field.type && (field.type !== '') && !SlashtagsFeeds.VALID_TYPES.includes(field.type)) { - throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.badFieldType) - } - - if (this.MEASURED_TYPES.includes(field.type)) { - if (!field.units) throw new SlashtagsFeeds.Error(SlashtagsFeeds.err.missingFieldUnits) - } - }) - } - - static generateSchema (config) { - const { schemaConfig } = config - SlashtagsFeeds.validateSchemaConfig(schemaConfig) - - const schema = { - name: schemaConfig.name, - description: schemaConfig.description, - type: 'account_feed', - version: '0.0.1', - icons: {} - } - - for (const size in schemaConfig.icons) { - schema.icons[size] = schemaConfig.icons[size] - } - - schema.fields = schemaConfig.fields.map((field) => { - return { - name: field.name, - description: field.description, - main: path.join(Feeds.FEED_PREFIX, SlashtagsFeeds.getFileName(field)), - type: field.type || 'utf-8', - units: field.units - } - }) - - return schema - } - - static persistSchema (schema) { - fs.writeFileSync(this.DEFAULT_SCHEMA_PATH, Buffer.from(JSON.stringify(schema, undefined, 2)), 'utf-8') - } - - static getFileName (field) { - const regex = /[^a-z0-9]+/gi - const trailing = /-+$/ - - return `/${field.name.toLowerCase().trim().replace(regex, '-').replace(trailing, '')}/` - } - /** * @param {String} config.db.name Database name * @param {String} config.db.path Database path location @@ -153,12 +54,12 @@ module.exports = class SlashtagsFeeds { let feedSchema if (config.schemaConfig) { - feedSchema = SlashtagsFeeds.generateSchema(config) - SlashtagsFeeds.persistSchema(feedSchema) + feedSchema = SlashtagsSchema.generateSchema(config.schemaConfig) + SlashtagsSchema.persistSchema(feedSchema) } else if (config.feed_schema) { feedSchema = config.feed_schema } - SlashtagsFeeds.validateSchemaConfig(feedSchema) + SlashtagsSchema.validateSchemaConfig(feedSchema) this.config = config this.db = new FeedDb(config.db) @@ -192,7 +93,7 @@ module.exports = class SlashtagsFeeds { * @param {Object} updates[].wallet_name feed id to update * @param {Object} updates[].amount amount */ - async updateFeedBalance (update) { + async updateFeed (update) { if (!this.ready) throw new Err(_err.notReady) this.validateUpdate(update) @@ -203,7 +104,7 @@ module.exports = class SlashtagsFeeds { try { // NOTE: consider storing balance on db as well for (const field of update.fields) { - await this._slashfeeds.update(update.feed_id, SlashtagsFeeds.getFileName(field), field.value) + await this._slashfeeds.update(update.feed_id, getFileName(field.name), field.value) } return { updated: true } } catch (err) { @@ -257,8 +158,8 @@ module.exports = class SlashtagsFeeds { } return { // XXX should it be hex or base32 - key: feed.key.toString('hex'), - encryption_key: feed.encryptionKey.toString('hex') + feed_key: feed.key.toString('hex'), + encrypt_key: feed.encryptionKey.toString('hex') } } @@ -307,17 +208,15 @@ module.exports = class SlashtagsFeeds { * @param {String} feedId */ async _initFeed (args) { - return Promise.all( - this.feed_schema.fields.map( - async (field) => { - await this._slashfeeds.update( - args.feed_id, - SlashtagsFeeds.getFileName(field), - args.init_data || null - ) - } - ) - ) + for (let field in this.feed_schema.fields) { + for (let fieldName in this.feed_schema.fields[field]) { + await this._slashfeeds.update( + args.feed_id, + getFileName(fieldName), + args.init_data || null + ) + } + } } async createFeed (args) { @@ -365,8 +264,8 @@ module.exports = class SlashtagsFeeds { try { await this.db.insert({ feed_id: args.feed_id, - feed_key: feed.key, - encrypt_key: feed.encryption_key, + feed_key: feed.feed_key, + encrypt_key: feed.encrypt_key, meta: {} }) } catch (err) { @@ -378,16 +277,17 @@ module.exports = class SlashtagsFeeds { const { format } = await import('@synonymdev/slashtags-url') const url = format( - b4a.from(feed.key, 'hex'), + b4a.from(feed.feed_key, 'hex'), { protocol: 'slashfeed:', - fragment: { encryptionKey: z32.encode(b4a.from(feed.encryption_key, 'hex')) } + fragment: { encryptionKey: z32.encode(b4a.from(feed.encrypt_key, 'hex')) } } ) return { url, - slashdrive: feed + feed_key: feed.feed_key, + encrypt_key: feed.encrypt_key } } @@ -422,16 +322,18 @@ module.exports = class SlashtagsFeeds { if (!Array.isArray(update.fields)) throw new Err(_err.invalidFeedFields) if (update.fields.length === 0) throw new Err(_err.invalidFeedFields) - for (const field of update.fields) { - this.validateFieldUpdate(field) + const { validateFieldsValues } = require( + `${__dirname}/schemaTypes/${snakeToCamel(this._slashfeeds.type || 'exchange_account_feed')}.js` + ) + + for (let field of update.fields) { + if (!field.name) throw new Err(_err.missingFieldName) } - } - validateFieldUpdate (field) { - if (!field.name) throw new Err(_err.missingFieldName) - if (!field.value) throw new Err(_err.missingFieldValue) + for (let field of update.fields) { + if (!field.value) throw new Err(_err.missingFieldValue) + } - const schemaField = this.feed_schema.fields.find((sF) => sF.name === field.name) - if (!schemaField) throw new Err(_err.unknownField) + validateFieldsValues(update.fields, this.feed_schema.fields) } } diff --git a/src/RPC.js b/src/RPC.js index 470a3d4..74d04a6 100644 --- a/src/RPC.js +++ b/src/RPC.js @@ -91,7 +91,7 @@ module.exports = function (config) { { name: 'updateFeed', description: 'Update feed feed', - svc: 'feeds.updateFeedBalance' + svc: 'feeds.updateFeed' }, { name: 'getFeed', diff --git a/src/SlashtagsSchema.js b/src/SlashtagsSchema.js new file mode 100644 index 0000000..f9f96df --- /dev/null +++ b/src/SlashtagsSchema.js @@ -0,0 +1,78 @@ +const fs = require('fs') + +const customErr = require('./CustomError.js') +const { snakeToCamel } = require('./util.js') +const Err = customErr({ errName: 'Slashtags', fileName: __filename }) + +const _err = { + missingFeedName: 'MISSING_FEED_NAME', + missingFeedDescription: 'MISSING_FEED_DESCRIPTION', + missingFeedIcons: 'MISSING_FEED_ICONS', + missingFeedFields: 'MISSING_FEED_FIELDS', + invalidFeedIcon: 'INVALID_FEED_ICON', + + invalidFeedFields: 'INVALID_FEED_FIELDS', + missingFieldName: 'MISSING_FIELD_NAME', + missingFieldDescription: 'MISSING_FIELD_DESCRIPTION', + missingFieldUnits: 'MISSING_FIELD_UNITS', + badFieldType: 'UNSUPPORTED_FIELD_TYPE', + + invalidField: 'INVALID_FIELD', + invalidFieldValue: 'INVALID_FIELD_VALUE' +} + +module.exports = class SlashtagsSchema { + static err = _err + static Error = Err + + static DEFAULT_SCHEMA_PATH = './schemas/slashfeed.json' + + static validateSchemaConfig (schemaConfig) { + if (!schemaConfig.name) throw new SlashtagsSchema.Error(SlashtagsSchema.err.missingFeedName) + if (!schemaConfig.description) throw new SlashtagsSchema.Error(SlashtagsSchema.err.missingFeedDescription) + if (!schemaConfig.icons) throw new SlashtagsSchema.Error(SlashtagsSchema.err.missingFeedIcons) + if (!schemaConfig.fields) throw new SlashtagsSchema.Error(SlashtagsSchema.err.missingFeedFields) + + const imageRX = /^data:image\/((svg\+xml)|(png));base64,.+$/ + for (const size in schemaConfig.icons) { + const icon = schemaConfig.icons[size] + + if (typeof icon !== 'string') throw new SlashtagsSchema.Error(SlashtagsSchema.err.invalidFeedIcon) + if (!imageRX.test(icon)) throw new SlashtagsSchema.Error(SlashtagsSchema.err.invalidFeedIcon) + } + + const { validateSchemaFields, validateSchemaValues } = require( + `${__dirname}/schemaTypes/${snakeToCamel(schemaConfig.type || 'exchange_account_feed')}.js` + ) + validateSchemaFields(schemaConfig.fields, SlashtagsSchema.err.invalidField) + validateSchemaValues(schemaConfig.fields, SlashtagsSchema.err.invalidFieldValue) + } + + static generateSchema (schemaConfig) { + SlashtagsSchema.validateSchemaConfig(schemaConfig) + + const { generateSchemaFields } = require( + `${__dirname}/schemaTypes/${snakeToCamel(schemaConfig.type || 'exchange_account_feed')}.js` + ) + + const schema = { + name: schemaConfig.name, + description: schemaConfig.description, + type: schemaConfig.type || 'exchange_account_feed', + version: schemaConfig.version || '0.0.1', + icons: {} + } + + for (const size in schemaConfig.icons) { + schema.icons[size] = schemaConfig.icons[size] + } + + schema.fields = generateSchemaFields(schemaConfig.fields) + + return schema + } + + static persistSchema (schema) { + fs.writeFileSync(this.DEFAULT_SCHEMA_PATH, Buffer.from(JSON.stringify(schema, undefined, 2)), 'utf-8') + } +} diff --git a/src/schemaTypes/ExchangeAccountFeed.js b/src/schemaTypes/ExchangeAccountFeed.js new file mode 100644 index 0000000..310ae1a --- /dev/null +++ b/src/schemaTypes/ExchangeAccountFeed.js @@ -0,0 +1,88 @@ +const Feeds = require('@synonymdev/feeds') +const path = require('path') +const { getFileName } = require('../util.js') + +module.exports = class ExchangeAccountFeed { + static SUPPORTED_TYPES = [ + 'balance', + 'pnl', + 'pnl_and_balance' + ] + + static REQUIRED_PROPS_FOR_TYPE = { + balance: [ + 'description', + 'units', + ], + + pnl: [ + 'description', + 'units', + ], + + pnl_and_balance:[ + 'description', + 'units', + ] + } + + static generateSchemaFields(schemaFields) { + return schemaFields.map((field) => { + return { + ...field, + main: path.join(Feeds.FEED_PREFIX, getFileName(field.name)), + } + }) + } + + static validateSchemaFields(fields, err) { + fields.forEach((field) => { + if (!ExchangeAccountFeed.SUPPORTED_TYPES.includes(field.type)) throw err || new Error(`Wrong type ${field.type}`) + + ExchangeAccountFeed.REQUIRED_PROPS_FOR_TYPE[field.type].forEach((prop) => { + if(!field[prop]) throw err || new Error(`${field.type} for ${field.name} is missing ${prop}`) + }) + }) + } + + static validateSchemaValues(fields, err) { + return + } + + static validateFieldsValues(updates, fields) { + for (let balanceField in fields.balance) { + updates.forEach((field) => { + if (field.name === balanceField) ExchangeAccountFeed.validateBalanceValue(field.value) + }) + } + + for (let pnlField in fields.pnl) { + updates.forEach((field) => { + if (field.name === pnlField) ExchangeAccountFeed.validatePNLValue(field.value) + }) + } + + for (let pnlAndBalanceField in fields.pnl_and_balance) { + updates.forEach((field) => { + if (field.name === pnlAndBalanceField) ExchangeAccountFeed.validatePNLandBalanceValue(field.value) + }) + } + } + + static validateBalanceValue(value) { + if (isNaN(parseFloat(value))) throw new Error('invalid balance') + } + + static validatePNLValue(value) { + const { absolute, relative } = value + if (isNaN(parseFloat(absolute))) throw new Error('invalid absolute') + if (isNaN(parseFloat(relative))) throw new Error('invalid relative') + } + + static validatePNLandBalanceValue(value) { + const { absolute_pnl, relative_pnl, balance } = value + if (isNaN(parseFloat(balance))) throw new Error('invalid balance') + if (isNaN(parseFloat(absolute_pnl))) throw new Error('invalid absolute') + if (isNaN(parseFloat(relative_pnl))) throw new Error('invalid relative') + } +} diff --git a/src/util.js b/src/util.js index 4b8c4fc..e222c45 100644 --- a/src/util.js +++ b/src/util.js @@ -19,5 +19,16 @@ module.exports = { rnd: function () { return randomBytes(32).toString('hex') + }, + + getFileName: function (fieldName) { + const regex = /[^a-z0-9]+/gi + const trailing = /-+$/ + + return `/${fieldName.toLowerCase().trim().replace(regex, '-').replace(trailing, '')}/` + }, + + snakeToCamel: function (str) { + return str.toLowerCase().replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '').replace('_', '')) } } diff --git a/test/Feeds.test.js b/test/Feeds.test.js index c0eab10..174fe10 100644 --- a/test/Feeds.test.js +++ b/test/Feeds.test.js @@ -1,5 +1,7 @@ const { strict: assert } = require('node:assert') const SlashtagsFeeds = require('../src/Feeds.js') +const { getFileName } = require('../src/util.js') +const SlashtagsSchema = require('../src/SlashtagsSchema.js') const FeedDb = require('../src/FeedDb.js') const path = require('path') const fs = require('fs') @@ -20,7 +22,16 @@ describe('SlashtagsFeeds', () => { describe('Constructor', () => { describe('Valid config', () => { let feed - before(() => feed = new SlashtagsFeeds(validConfig)) + let conf + before(() => { + feed = new SlashtagsFeeds(validConfig) + conf = { + name: Schema.name, + description: Schema.description, + icons: JSON.parse(JSON.stringify(Schema.icons)), + fields: JSON.parse(JSON.stringify(Schema.fields)) + } + }) it('has config', () => assert.deepStrictEqual(feed.config, validConfig)) it('has db', () => assert.deepStrictEqual(feed.db, new FeedDb(validConfig.db))) @@ -28,120 +39,14 @@ describe('SlashtagsFeeds', () => { it('has lock', () => assert.deepStrictEqual(feed.lock, new Map())) it('has ready flag', () => assert.equal(feed.ready, false)) it('has slashtags property', () => assert.equal(feed._slashfeeds, null)) - - describe('it generates and overwrites slashfeed based on config', () => { - let conf - beforeEach(() => { - conf = { - ...JSON.parse(JSON.stringify(validConfig)), - schemaConfig: { - name: Schema.name, - description: Schema.description, - icons: JSON.parse(JSON.stringify(Schema.icons)), - fields: Schema.fields.map(f => JSON.parse(JSON.stringify(f))) - } - } - }) - - describe('invalid schamaConfig', () => { - describe('missing name', () => { - beforeEach(() => { - delete conf.schemaConfig.name - error.message = SlashtagsFeeds.err.missingFeedName - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('missing description', () => { - beforeEach(() => { - delete conf.schemaConfig.description - error.message = SlashtagsFeeds.err.missingFeedDescription - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('missing icons', () => { - beforeEach(() => { - delete conf.schemaConfig.icons - error.message = SlashtagsFeeds.err.missingFeedIcons - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('missing fields', () => { - beforeEach(() => { - delete conf.schemaConfig.fields - error.message = SlashtagsFeeds.err.missingFeedFields - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('fields are not array', () => { - beforeEach(() => { - conf.schemaConfig.fields = 'fields' - error.message = SlashtagsFeeds.err.invalidFeedFields - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('invalid icon', () => { - beforeEach(() => { - conf.schemaConfig.icons['48'] = 'not an image' - error.message = SlashtagsFeeds.err.invalidFeedIcon - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('invalid field', () => { - describe('missing name', () => { - beforeEach(() => { - delete conf.schemaConfig.fields[0].name - error.message = SlashtagsFeeds.err.missingFieldName - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('missing description', () => { - beforeEach(() => { - delete conf.schemaConfig.fields[1].description - error.message = SlashtagsFeeds.err.missingFieldDescription - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - - describe('invalid type', () => { - beforeEach(() => { - conf.schemaConfig.fields[1].type = 'unsupported type' - error.message = SlashtagsFeeds.err.badFieldType - }) - - it('throws an error', () => assert.throws(() => new SlashtagsFeeds(conf), error)) - }) - }) - }) - - describe('valid config', () => { - let instance - beforeEach(() => instance = new SlashtagsFeeds(conf)) - - it('uses new schema', () => assert.deepStrictEqual( - instance.feed_schema, - SlashtagsFeeds.generateSchema(conf) - )) - it('persists generated schema', () => assert.deepStrictEqual( - instance.feed_schema, - JSON.parse(fs.readFileSync(SlashtagsFeeds.DEFAULT_SCHEMA_PATH).toString('utf8')) - )) - }) - }) + it('uses new schema', () => assert.deepStrictEqual( + feed.feed_schema, + SlashtagsSchema.generateSchema(conf) + )) + it('persists generated schema', () => assert.deepStrictEqual( + feed.feed_schema, + JSON.parse(fs.readFileSync(SlashtagsSchema.DEFAULT_SCHEMA_PATH).toString('utf8')) + )) }) describe('Invalid config', () => { @@ -154,23 +59,6 @@ describe('SlashtagsFeeds', () => { slashtags: path.resolve('./test-data/storage'), } - // TODO: -// describe('Invalid feed schema', () => { -// before(() => error.message = SlashtagsFeeds.err.invalidSchema) -// -// const keys = [ 'image', 'name', 'feed_type', 'version' ] -// keys.forEach((k) => { -// let tmp -// beforeEach(() => { -// tmp = invalidConfig.feed_schema[k] -// invalidConfig.feed_schema[k] = null -// }) -// afterEach(() => invalidConfig.feed_schema[k] = tmp) -// -// it(`fails without ${k}`, () => assert.throws(() => new SlashtagsFeeds(invalidConfig), error)) -// }) -// }) - describe('Missing slashtags', () => { before(() => error.message = SlashtagsFeeds.err.badConfig) @@ -328,7 +216,6 @@ describe('SlashtagsFeeds', () => { after(() => feed.db.insert = insertFeed) it('fails with no feed error', async function () { - this.timeout(5000) await assert.rejects(async () => feed.createFeed(input), error) }) }) @@ -346,12 +233,9 @@ describe('SlashtagsFeeds', () => { await feed.deleteFeed(input) }) - it('has slashdrive property', () => assert(res.slashdrive)) - describe('slashdrive property', () => { - it('has key', () => assert(res.slashdrive.key)) - it('has encryption_key', () => assert(res.slashdrive.encryption_key)) - it('has url', () => assert(res.url)) - }) + it('has feed_key', () => assert(res.feed_key)) + it('has encrypt', () => assert(res.encrypt_key)) + it('has url', () => assert(res.url)) }) }) }) @@ -546,7 +430,7 @@ describe('SlashtagsFeeds', () => { let readResult let createResult before(async function () { - this.timeout(5000) + this.timeout(10000) createResult = await feed.createFeed(input) readResult = await feed.getFeed(input) @@ -555,28 +439,44 @@ describe('SlashtagsFeeds', () => { describe('feed_key', () => { it('has `feed_key`', () => assert(readResult.feed_key)) - it('is correct', () => assert.strictEqual(createResult.slashdrive.key, readResult.feed_key)) + it('is correct', () => assert.strictEqual(createResult.feed_key, readResult.feed_key)) }) describe('encrypt_key', () => { it('has `encrypt_key`', () => assert(readResult.encrypt_key)) - it('is correct', () => assert.strictEqual(createResult.slashdrive.encryption_key, readResult.encrypt_key)) + it('is correct', () => assert.strictEqual(createResult.encrypt_key, readResult.encrypt_key)) }) }) }) - describe('updateFeedBalance', () => { + describe('updateFeed', () => { const update = { feed_id: 'testUpdateFeed', fields: [ { - name: 'Bitcoin', + name: 'bitcoin futures balance', + value: 11, + }, + { + name: 'bitcoin options balance', value: 12, }, { - name: 'Bitcoin P/L', - value: 1, - } + name: 'bitcoin futures pnl', + value: { absolute: 1, relative: 10 }, + }, + { + name: 'bitcoin options pnl', + value: { absolute: 2, relative: 20 }, + }, + { + name: 'bitcoin futures pnl and balance', + value: { balance: 10, absolute_pnl: 1, relative_pnl: 10 }, + }, + { + name: 'bitcoin options pnl and balance', + value: { balance: 10, absolute_pnl: 1, relative_pnl: 10 }, + }, ] } @@ -597,7 +497,7 @@ describe('SlashtagsFeeds', () => { }) after(() => feed.ready = true) - it('fails if slahstags is not ready', async () => assert.rejects(async () => feed.updateFeedBalance(update), error)) + it('fails if slahstags is not ready', async () => assert.rejects(async () => feed.updateFeed(update), error)) }) describe('Input handling', () => { @@ -608,7 +508,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, feed_id: undefined } error.message = SlashtagsFeeds.err.feedIdMissing }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) describe('fields is missing', () => { @@ -616,7 +516,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, fields: undefined } error.message = SlashtagsFeeds.err.missingFields }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) describe('fields is not an array', () => { @@ -624,7 +524,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, fields: 'fields' } error.message = SlashtagsFeeds.err.invalidFeedFields }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) describe('fields is empty array', () => { @@ -632,7 +532,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, fields: [] } error.message = SlashtagsFeeds.err.invalidFeedFields }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) describe('field is missing name', () => { @@ -640,7 +540,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, fields: [{ value: 1 }]} error.message = SlashtagsFeeds.err.missingFieldName }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) describe('field is missing value', () => { @@ -648,7 +548,7 @@ describe('SlashtagsFeeds', () => { input = { ...update, fields: [{ name: 1 }]} error.message = SlashtagsFeeds.err.missingFieldValue }) - it('throws an error', async () => assert.rejects(async () => feed.updateFeedBalance(input), error)) + it('throws an error', async () => assert.rejects(async () => feed.updateFeed(input), error)) }) }) @@ -659,7 +559,7 @@ describe('SlashtagsFeeds', () => { }) it('throws an error', async () => assert.rejects( - async () => feed.updateFeedBalance({...update, feed_id: 'do_not_exist' }), + async () => feed.updateFeed({...update, feed_id: 'do_not_exist' }), error )) }) @@ -680,7 +580,7 @@ describe('SlashtagsFeeds', () => { }) it('throws an error', async () => assert.rejects( - async () => feed.updateFeedBalance({...update, feed_id: 'exist' }), + async () => feed.updateFeed({...update, feed_id: 'exist' }), error )) }) @@ -693,7 +593,7 @@ describe('SlashtagsFeeds', () => { await feed.deleteFeed({ feed_id: update.feed_id }) await feed.createFeed({ feed_id: update.feed_id }) - res = await feed.updateFeedBalance(update) + res = await feed.updateFeed(update) }) it('returns true', () => assert.deepStrictEqual(res, { updated: true })) @@ -704,8 +604,8 @@ describe('SlashtagsFeeds', () => { before(async () => { await feed.stop() feedReader = new Feeds(validConfig.slashtags, validConfig.feed_schema) - balance = await feedReader.get(update.feed_id, SlashtagsFeeds.getFileName(update.fields[0])) - balanceChange = await feedReader.get(update.feed_id, SlashtagsFeeds.getFileName(update.fields[1])) + balance = await feedReader.get(update.feed_id, getFileName(update.fields[0].name)) + balanceChange = await feedReader.get(update.feed_id, getFileName(update.fields[1].name)) }) after(async () => { diff --git a/test/SlashtagsSchema.test.js b/test/SlashtagsSchema.test.js new file mode 100644 index 0000000..c4d7deb --- /dev/null +++ b/test/SlashtagsSchema.test.js @@ -0,0 +1,66 @@ +const { strict: assert } = require('node:assert') +const SlashtagsSchema = require('../src/SlashtagsSchema.js') +const Schema = require('../schemas/slashfeed.json') + +describe('SlashtagsSchema', () => { + const error = { name: 'Slashtags' } + + describe('generateSchema', () => { + describe('schemaConfig validation', () => { + let conf + beforeEach(() => { + conf = { + name: Schema.name, + description: Schema.description, + icons: JSON.parse(JSON.stringify(Schema.icons)), + fields: JSON.parse(JSON.stringify(Schema.fields)) + } + }) + + describe('missing name', () => { + beforeEach(() => { + delete conf.name + error.message = SlashtagsSchema.err.missingFeedName + }) + + it('throws an error', () => assert.throws(() => SlashtagsSchema.generateSchema(conf), error)) + }) + + describe('missing description', () => { + beforeEach(() => { + delete conf.description + error.message = SlashtagsSchema.err.missingFeedDescription + }) + + it('throws an error', () => assert.throws(() => SlashtagsSchema.generateSchema(conf), error)) + }) + + describe('missing icons', () => { + beforeEach(() => { + delete conf.icons + error.message = SlashtagsSchema.err.missingFeedIcons + }) + + it('throws an error', () => assert.throws(() => SlashtagsSchema.generateSchema(conf), error)) + }) + + describe('missing fields', () => { + beforeEach(() => { + delete conf.fields + error.message = SlashtagsSchema.err.missingFeedFields + }) + + it('throws an error', () => assert.throws(() => SlashtagsSchema.generateSchema(conf), error)) + }) + + describe('invalid icon', () => { + beforeEach(() => { + conf.icons['48'] = 'not an image' + error.message = SlashtagsSchema.err.invalidFeedIcon + }) + + it('throws an error', () => assert.throws(() => SlashtagsSchema.generateSchema(conf), error)) + }) + }) + }) +}) diff --git a/test/schemaTypes/ExchangeAccountFeed.test.js b/test/schemaTypes/ExchangeAccountFeed.test.js new file mode 100644 index 0000000..6c9faf9 --- /dev/null +++ b/test/schemaTypes/ExchangeAccountFeed.test.js @@ -0,0 +1,99 @@ +const { strict: assert } = require('node:assert') +const ExchangeAccountFeed = require('../../src/schemaTypes/ExchangeAccountFeed.js') + +describe('ExchangeAccountFeed', () => { + const validExchangeAccountSchemaFields = [ + { + "type": "balance", + "name": "btc balance", + "description": "description", + "main": "path to value in slashdrive", + "units": "sign to be shown next to value", + }, + { + "type": "pnl", + "name": "spot pnl", + "main": "path to value on slashdrive, the value example is { absolute: 75, relative: 12 }", + "description": "description", + "units": "sign to be shown next to absolute value, relative value always shown with % sign", + }, + { + "type": "pnl_and_balance", + "name": "spot pnl and balance", + "description": "description", + "main": "path to value on slashdrive, the value example is { balance: 100, absolute_pnl: 75, relative_pnl: 300 }", + "units": "sign to be shown next to absolute value, relative value always shown with % sign", + } + ] + +// describe('Invalid fields', () => { +// let invalidFields +// beforeEach(() => invalidFields = JSON.parse(JSON.stringify(validExchangeAccountSchemaFields))) +// +// for (let field of validExchangeAccountSchemaFields) { +// for (let prop of ExchangeAccountFeed.REQUIRED_PROPS_FOR_TYPE[field.type]) { +// const message = `${field.type} for ${field.name} is missing ${prop}` +// describe(message, () => { +// beforeEach(() => delete field[prop]) +// it('fails', () => assert.throws(() => ExchangeAccountFeed.validateSchemaFields(invalidFields), { message })) +// }) +// } +// } +// }) + + describe('Generated scheam', () => { + let res + before(() => res = ExchangeAccountFeed.generateSchemaFields(validExchangeAccountSchemaFields)) + + describe('balance', () => { + it('contains description', () => assert.equal( + res[0].description, + validExchangeAccountSchemaFields[0].description + )) + + it('contains main', () => assert.equal( + res[0].main, + '/feed/btc-balance/' + )) + + it('contains units', () => assert.equal( + res[0].units, + validExchangeAccountSchemaFields[0].units + )) + }) + + describe('pnl', () => { + it('contains description', () => assert.equal( + res[1].description, + validExchangeAccountSchemaFields[1].description + )) + + it('contains main', () => assert.equal( + res[1].main, + '/feed/spot-pnl/' + )) + + it('contains units', () => assert.equal( + res[1].units, + validExchangeAccountSchemaFields[1].units + )) + }) + + describe('pnl_and_balance', () => { + it('contains description', () => assert.equal( + res[2].description, + validExchangeAccountSchemaFields[2].description + )) + + it('contains main', () => assert.equal( + res[2].main, + '/feed/spot-pnl-and-balance/' + )) + + it('contains units', () => assert.equal( + res[2].units, + validExchangeAccountSchemaFields[2].units + )) + }) + }) +})