diff --git a/README.md b/README.md index 4f4c499..3069b90 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ npm install gw2api-client ``` -This module can be used for Node.js as well as browsers using [Browserify](https://github.com/substack/browserify-handbook#how-node_modules-works). +This module can be used for Node.js as well as browsers using [Browserify](https://github.com/substack/browserify-handbook#how-node_modules-works). ## Usage @@ -98,6 +98,42 @@ The cache uses expiration times and not the build number, because the content of setInterval(() => api.flushCacheIfGameUpdated(), 10 * 60 * 1000) ``` +### Auto-Batching + +By default, all API requests are executed individually against the live API (or the cache). You can queue/batch requests to bulk endpoints with the `.autoBatch(batchDelay)` method. When enabled, the library will pool ids from get/many for the specified `batchDelay` in ms(default 50 ms), then make API requests with all pending IDs. + +```js +api.items().autoBatch().get(100) +api.items().autoBatch().get(100) +api.items().autoBatch().get(101) +api.items().autoBatch().many([100, 101, 102, 103]) +api.items().autoBatch().many([100, 103, 104, 105]) +// when called in immediate succession here, the only API call will be: +// https://api.guildwars2.com/v2/items?ids=100,101,102,103,104,105 +``` + +Autobatching can be enabled for all endpoints by calling `client.autoBatch()`: + +```js +api.autoBatch() // autobatching turned on for all endpoints stemmed from this client object +api.items().get(100) +api.items().get(100) +api.items().get(101) +api.items().many([100, 101, 102, 103]) +api.items().many([100, 103, 104, 105]) +``` + +Or enable only for a single Endpoint by saving a reference to an endpoint after calling autobatch: + +```js +const itemsApi = api.items().autoBatch() // autobatching turned on for all calls from this endpoint +itemsApi.get(100) +itemsApi.get(100) +itemsApi.get(101) +itemsApi.many([100, 101, 102, 103]) +itemsApi.many([100, 103, 104, 105]) +``` + ### Error handling You can use the Promise `catch` to handle all possible errors. diff --git a/src/client.js b/src/client.js index 2ef42d6..6353c51 100644 --- a/src/client.js +++ b/src/client.js @@ -12,6 +12,8 @@ module.exports = class Client { this.caches = [nullCache()] this.debug = false this.client = this + this.autoBatching = false + this.autoBatchDelay = 50 } // Set the schema version @@ -79,6 +81,15 @@ module.exports = class Client { }) } + // Turn on autobatching for all endpoints + autoBatch (autoBatchDelay) { + if (autoBatchDelay) { + this.autoBatchDelay = autoBatchDelay + } + this.autoBatching = true + return this + } + // All the different API endpoints account () { return new endpoints.AccountEndpoint(this) diff --git a/src/endpoint.js b/src/endpoint.js index 7d30672..8bf0438 100644 --- a/src/endpoint.js +++ b/src/endpoint.js @@ -5,6 +5,8 @@ const hashString = require('./hash') const clone = (x) => JSON.parse(JSON.stringify(x)) +const autoBatchSharedData = {} + module.exports = class AbstractEndpoint { constructor (parent) { this.client = parent.client @@ -24,6 +26,10 @@ module.exports = class AbstractEndpoint { this.isAuthenticated = false this.isOptionallyAuthenticated = false this.credentials = false + + this.autoBatching = parent.autoBatching + this.autoBatchDelay = parent.autoBatchDelay + this.setupAutoBatchSharedData() this._skipCache = false } @@ -31,6 +37,7 @@ module.exports = class AbstractEndpoint { // Set the schema version schema (schema) { this.schemaVersion = schema + this.setupAutoBatchSharedData() this.debugMessage(`set the schema to ${schema}`) return this } @@ -43,6 +50,7 @@ module.exports = class AbstractEndpoint { // Set the language for locale-aware endpoints language (lang) { this.lang = lang + this.setupAutoBatchSharedData() this.debugMessage(`set the language to ${lang}`) return this } @@ -50,6 +58,7 @@ module.exports = class AbstractEndpoint { // Set the api key for authenticated endpoints authenticate (apiKey) { this.apiKey = apiKey + this.setupAutoBatchSharedData() this.debugMessage(`set the api key to ${apiKey}`) return this } @@ -74,6 +83,29 @@ module.exports = class AbstractEndpoint { return this } + // Turn on auto-batching for this endpoint + autoBatch (autoBatchDelay) { + if (autoBatchDelay) { + this.autoBatchDelay = autoBatchDelay + this.setupAutoBatchSharedData() + } + this.autoBatching = true + return this + } + + // Sets _autoBatch to shared batching object based on _cacheHash + setupAutoBatchSharedData() { + const autoBatchId = this._cacheHash(this.constructor.name + this.autoBatchDelay) + if (!autoBatchSharedData[autoBatchId]) { + autoBatchSharedData[autoBatchId] = { + idsForNextBatch: new Set(), + nextBatchPromise: null, + } + } + + this._autoBatch = autoBatchSharedData[autoBatchId] + } + // Get all ids ids () { this.debugMessage(`ids(${this.url}) called`) @@ -156,7 +188,14 @@ module.exports = class AbstractEndpoint { // Request the single id if the endpoint a bulk endpoint if (this.isBulk && !url) { - return this._request(`${this.url}?id=${id}`) + if (this.autoBatching) { + return this._autoBatchMany([id]).then((items) => { + return items[0]?items[0]:null + }) + } + else { + return this._request(`${this.url}?id=${id}`) + } } // We are dealing with a custom url instead @@ -168,8 +207,34 @@ module.exports = class AbstractEndpoint { return this._request(this.url) } + _autoBatchMany (ids) { + if (this._autoBatch.idsForNextBatch.size === 0) { + this._autoBatch.nextBatchPromise = new Promise((resolve, reject) => { + setTimeout(() => { + const batchedIds = Array.from(this._autoBatch.idsForNextBatch) + this.debugMessage(`autoBatchMany called (${batchedIds.length} ids)`) + this._autoBatch.idsForNextBatch.clear() + return resolve(this.many(batchedIds, true)) + }, this.autoBatchDelay) + }).then(items => { + const indexedItems = {} + items.forEach(item => { + indexedItems[item.id] = item + }) + return indexedItems + }) + } + + // Add the requested ids to the pending ids + ids.forEach(id => this._autoBatch.idsForNextBatch.add(id)) + // Return the results based on the requested ids + return this._autoBatch.nextBatchPromise.then(indexedItems => { + return ids.map(id => indexedItems[id]).filter(x => x) + }) + } + // Get multiple entries by ids - many (ids) { + many (ids, skipAutoBatch) { this.debugMessage(`many(${this.url}) called (${ids.length} ids)`) if (!this.isBulk) { @@ -186,7 +251,7 @@ module.exports = class AbstractEndpoint { // There is no cache time set, so always use the live data if (!this.cacheTime) { - return this._many(ids) + return this._many(ids, undefined, skipAutoBatch) } // Get as much as possible out of the cache @@ -201,7 +266,7 @@ module.exports = class AbstractEndpoint { this.debugMessage(`many(${this.url}) resolving partially from cache (${cached.length} ids)`) const missingIds = getMissingIds(ids, cached) - return this._many(missingIds, cached.length > 0).then(content => { + return this._many(missingIds, cached.length > 0, skipAutoBatch).then(content => { const cacheContent = content.map(value => [this._cacheHash(value.id), value]) this._cacheSetMany(cacheContent) @@ -230,9 +295,15 @@ module.exports = class AbstractEndpoint { } // Get multiple entries by ids from the live API - _many (ids, partialRequest = false) { + _many (ids, partialRequest = false, skipAutoBatch) { this.debugMessage(`many(${this.url}) requesting from api (${ids.length} ids)`) + if (this.autoBatching && !skipAutoBatch) { + return this._autoBatchMany(ids) + } + + + // Chunk the requests to the max page size const pages = chunk(ids, this.maxPageSize) const requests = pages.map(page => `${this.url}?ids=${page.join(',')}`) diff --git a/tests/client.spec.js b/tests/client.spec.js index 9046a0d..44b269f 100644 --- a/tests/client.spec.js +++ b/tests/client.spec.js @@ -123,6 +123,26 @@ describe('client', () => { client.build = tmp }) + describe('autobatch', () => { + const batchDelay = 10 + it('can turn on autobatching with specified delay', () => { + let api = client.autoBatch(100) + expect(client.autoBatchDelay).toEqual(100) + expect(api).toBeInstanceOf(Module) + + let endpoint = client.account().autoBatch(200) + expect(endpoint.autoBatchDelay).toEqual(200) + expect(client.autoBatch(300).autoBatchDelay).toEqual(300) + expect(endpoint.autoBatchDelay).toEqual(200) + }) + + it ('has default bach delay of 50', () => { + let endpoint = client.autoBatch().items() + expect(client.autoBatchDelay).toEqual(50) + expect(endpoint.autoBatchDelay).toEqual(50) + }) + }) + it('can get the account endpoint', () => { let endpoint = client.account() expect(endpoint.url).toEqual('/v2/account') diff --git a/tests/endpoint.spec.js b/tests/endpoint.spec.js index cbe3761..7a714d9 100644 --- a/tests/endpoint.spec.js +++ b/tests/endpoint.spec.js @@ -108,6 +108,139 @@ describe('abstract endpoint', () => { }) }) + describe('auto batching', () => { + const batchDelay = 10 + + beforeEach(() => { + mockClient.autoBatching = true + }) + + afterEach(() => { + mockClient.autoBatching = false + }) + + it('sets up _autoBatch variable', () => { + let x = endpoint.autoBatch(batchDelay) + expect(x).toBeInstanceOf(Module) + expect(x.autoBatchDelay).toEqual(batchDelay) + expect(x._autoBatch.idsForNextBatch).toBeDefined() + expect(x._autoBatch.nextBatchPromise).toBeNull() + }) + + it('has default batchDelay of 50', () => { + let x = endpoint.autoBatch() + expect(x.autoBatchDelay).toEqual(50) + }) + + it('autoBatch can change the batchDelay', () => { + endpoint.autoBatch() + endpoint.autoBatch(batchDelay) + expect(endpoint.autoBatchDelay).toEqual(batchDelay) + }) + + it('supports batching from get', async () => { + let content = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }] + endpoint.isBulk = true + endpoint.url = '/v2/test' + endpoint.autoBatch(batchDelay) + fetchMock.addResponse(content) + + let [entry1, entry2, entry3] = await Promise.all([endpoint.get(1), endpoint.get(2), endpoint.get(1)]) + expect(fetchMock.lastUrl()).toEqual('https://api.guildwars2.com/v2/test?v=schema&ids=1,2') + expect(entry1).toEqual(content[0]) + expect(entry2).toEqual(content[1]) + expect(entry3).toEqual(content[0]) + }) + + it('returns null from get with no response', async () => { + let content = [] + endpoint.isBulk = true + endpoint.url = '/v2/test' + endpoint.autoBatch(batchDelay) + fetchMock.addResponse(content) + + let [entry1, entry2] = await Promise.all([endpoint.get(1), endpoint.get(2)]) + expect(fetchMock.lastUrl()).toEqual('https://api.guildwars2.com/v2/test?v=schema&ids=1,2') + expect(entry1).toEqual(null) + expect(entry2).toEqual(null) + }) + + it('supports batching from many', async () => { + let content = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }, { id: 3, name: 'bar' }] + endpoint.isBulk = true + endpoint.url = '/v2/test' + endpoint.autoBatch(batchDelay) + fetchMock.addResponse(content) + + let [entry1, entry2] = await Promise.all([endpoint.many([1,2]), endpoint.many([2,3])]) + expect(fetchMock.lastUrl()).toEqual('https://api.guildwars2.com/v2/test?v=schema&ids=1,2,3') + expect(entry1).toEqual([content[0],content[1]]) + expect(entry2).toEqual([content[1],content[2]]) + }) + + it('only batches requests during the batchDelay', async () => { + let content1 = [{ id: 1, name: 'foo' }] + let content2 = [{ id: 2, name: 'bar' }] + endpoint.isBulk = true + endpoint.url = '/v2/test' + endpoint.autoBatch(batchDelay) + fetchMock.addResponse(content1) + fetchMock.addResponse(content2) + + let [entry1, entry2] = await Promise.all([ + endpoint.get(1), + new Promise((resolve) => {setTimeout(() => {resolve(endpoint.get(2))}, batchDelay+1)}) + ]) + expect(fetchMock.urls()).toEqual([ + 'https://api.guildwars2.com/v2/test?v=schema&ids=1', + 'https://api.guildwars2.com/v2/test?v=schema&ids=2' + ]) + expect(entry1).toEqual(content1[0]) + expect(entry2).toEqual(content2[0]) + + }) + + it('can batch requests from different endpoints in parallel', async () => { + let content1 = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }] + let content2 = [{ id: 1, name: 'bar' }] + + endpoint = new Module(mockClient) + endpoint.caches.map(cache => cache.flush()) + endpoint.isBulk = true + endpoint.url = '/v2/test' + endpoint.schemaVersion = 'schema' + + class differentModule extends Module { + constructor (client) { + super(client) + this.url = '/v2/differentTest' + this.schemaVersion = 'schema' + this.isBulk = true + } + } + + differentEndpoint = new differentModule(mockClient) + differentEndpoint.caches.map(cache => cache.flush()) + + fetchMock.addResponse(content1) + fetchMock.addResponse(content2) + + let [entry1, entry2, entry3] = await Promise.all([ + endpoint.get(1), + differentEndpoint.get(1), + endpoint.get(2), + ]) + expect(fetchMock.urls()).toEqual([ + 'https://api.guildwars2.com/v2/test?v=schema&ids=1,2', + 'https://api.guildwars2.com/v2/differentTest?v=schema&ids=1' + ]) + expect(entry1).toEqual(content1[0]) + expect(entry2).toEqual(content2[0]) + expect(entry3).toEqual(content1[1]) + + }) + }) + describe('get', () => { it('support for bulk expanding', async () => { let content = { id: 1, name: 'foo' } diff --git a/tests/mocks/client.mock.js b/tests/mocks/client.mock.js index 810b1a2..a027ce5 100644 --- a/tests/mocks/client.mock.js +++ b/tests/mocks/client.mock.js @@ -7,6 +7,8 @@ const mockClient = { apiKey: false, fetch: fetch, caches: [nullCache(), memoryCache(), memoryCache()], + autoBatching: false, + autoBatchDelay: 50, language: function (lang) { this.lang = lang },