diff --git a/libs/fetcher.client.js b/libs/fetcher.client.js index f803d09..c936d8d 100644 --- a/libs/fetcher.client.js +++ b/libs/fetcher.client.js @@ -38,6 +38,7 @@ function Request(operation, resource, options) { this._body = null; this._clientConfig = {}; this._startTime = 0; + this._request = null; } /** @@ -104,22 +105,39 @@ Request.prototype._captureMetaAndStats = function (err, result) { } }; +Request.prototype._send = function () { + if (this._request) { + return this._request; + } + + this._startTime = Date.now(); + this._request = httpRequest(normalizeOptions(this)); + var captureMetaAndStats = this._captureMetaAndStats.bind(this); + + this._request.then( + function (result) { + captureMetaAndStats(null, result); + return result; + }, + function (err) { + captureMetaAndStats(err); + throw err; + }, + ); + + return this._request; +}; + Request.prototype.then = function (resolve, reject) { - return this.end(function (err, data, meta) { - if (err) { - reject(err); - } else { - resolve({ data, meta }); - } - }); + return this._send().then(resolve, reject); }; Request.prototype.catch = function (reject) { - return this.end(function (err) { - if (err) { - reject(err); - } - }); + return this._send().catch(reject); +}; + +Request.prototype.abort = function () { + return this._request.abort(); }; /** @@ -130,36 +148,26 @@ Request.prototype.catch = function (reject) { * @async */ Request.prototype.end = function (callback) { - if (!callback) { + if (!arguments.length) { console.warn( 'You called .end() without a callback. This will become an error in the future. Use .then() instead.', ); } - var self = this; - self._startTime = Date.now(); + this._send(); - var onResponse = function (err, result) { - self._captureMetaAndStats(err, result); - if (callback) { - setTimeout(function () { - callback(err, result && result.data, result && result.meta); - }); - } else if (err) { - throw err; - } else { - return result; - } - }; + if (callback) { + this._request.then( + function (result) { + callback(null, result && result.data, result && result.meta); + }, + function (err) { + callback(err); + }, + ); + } - return httpRequest(normalizeOptions(self)).then( - function (result) { - return onResponse(null, result); - }, - function (err) { - return onResponse(err); - }, - ); + return this._request; }; /** diff --git a/libs/fetcher.js b/libs/fetcher.js index 3fb2c27..6ba0728 100644 --- a/libs/fetcher.js +++ b/libs/fetcher.js @@ -226,7 +226,7 @@ class Request { * is complete. */ end(callback) { - if (!callback) { + if (!arguments.length) { console.warn( 'You called .end() without a callback. This will become an error in the future. Use .then() instead.', ); diff --git a/libs/util/httpRequest.js b/libs/util/httpRequest.js index 58d36fd..ab68ca7 100644 --- a/libs/util/httpRequest.js +++ b/libs/util/httpRequest.js @@ -136,14 +136,14 @@ function _fetch() { } function _send() { - this._promise = _fetch.call(this); + this._request = _fetch.call(this); } function FetchrHttpRequest(options) { this._controller = new AbortController(); this._currentAttempt = 0; this._options = options; - this._promise = null; + this._request = null; } FetchrHttpRequest.prototype.abort = function () { @@ -151,13 +151,11 @@ FetchrHttpRequest.prototype.abort = function () { }; FetchrHttpRequest.prototype.then = function (resolve, reject) { - this._promise = this._promise.then(resolve, reject); - return this; + return this._request.then(resolve, reject); }; FetchrHttpRequest.prototype.catch = function (reject) { - this._promise = this._promise.catch(reject); - return this; + return this._request.catch(reject); }; function httpRequest(options) { diff --git a/tests/functional/fetchr.test.js b/tests/functional/fetchr.test.js index 6052f5f..8e99625 100644 --- a/tests/functional/fetchr.test.js +++ b/tests/functional/fetchr.test.js @@ -41,7 +41,7 @@ describe('client/server integration', () => { describe('CRUD', () => { it('can create item', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher.create( 'item', { id: '42' }, @@ -64,7 +64,7 @@ describe('client/server integration', () => { it('can read one item', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher.read('item', { id: '42' }); }); @@ -79,8 +79,8 @@ describe('client/server integration', () => { it('can read many items', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); - return fetcher.read('item', null); + const fetcher = new Fetchr(); + return fetcher.read('item'); }); expect(response.data).to.deep.equal([ @@ -96,7 +96,7 @@ describe('client/server integration', () => { it('can update item', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher.update( 'item', { id: '42' }, @@ -119,7 +119,7 @@ describe('client/server integration', () => { it('can delete item', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher.delete('item', { id: '42' }); }); @@ -129,6 +129,56 @@ describe('client/server integration', () => { }); }); + describe('Promise support', () => { + it('Always return the same value if resolved multiple times', async () => { + const [id, value] = await page.evaluate(async () => { + const fetcher = new Fetchr(); + + await fetcher.create( + 'item', + { id: '42' }, + { value: 'this is an item' }, + ); + + const request = fetcher.read('item', { id: '42' }); + + return Promise.all([ + request.then(({ data }) => data.id), + request.then(({ data }) => data.value), + ]); + }); + + expect(id).to.equal('42'); + expect(value).to.equal('this is an item'); + }); + + it('Works with Promise.all', async () => { + const response = await page.evaluate(async () => { + const fetcher = new Fetchr(); + + await fetcher.create( + 'item', + { id: '42' }, + { value: 'this is an item' }, + ); + + const promise = fetcher.read('item', { id: '42' }); + + return Promise.all([promise]) + .then(([result]) => result) + .catch((err) => err); + }); + + expect(response.data).to.deep.equal({ + id: '42', + value: 'this is an item', + }); + expect(response.meta).to.deep.equal({ + statusCode: 200, + }); + }); + }); + describe('Error handling', () => { describe('GET', () => { it('can handle unconfigured server', async () => { @@ -136,7 +186,7 @@ describe('client/server integration', () => { const fetcher = new Fetchr({ xhrPath: 'http://localhost:3001', }); - return fetcher.read('error', null).catch((err) => err); + return fetcher.read('error').catch((err) => err); }); expect(response).to.deep.equal({ @@ -159,8 +209,8 @@ describe('client/server integration', () => { it('can handle service expected errors', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); - return fetcher.read('error', null).catch((err) => err); + const fetcher = new Fetchr(); + return fetcher.read('error').catch((err) => err); }); expect(response).to.deep.equal({ @@ -187,7 +237,7 @@ describe('client/server integration', () => { it('can handle service unexpected errors', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .read('error', { error: 'unexpected' }) .catch((err) => err); @@ -217,7 +267,7 @@ describe('client/server integration', () => { it('can handle incorrect api path', async () => { const response = await page.evaluate(() => { const fetcher = new Fetchr({ xhrPath: '/non-existent' }); - return fetcher.read('item', null).catch((err) => err); + return fetcher.read('item').catch((err) => err); }); expect(response).to.deep.equal({ @@ -240,12 +290,26 @@ describe('client/server integration', () => { it('can handle aborts', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); - const request = fetcher.read('slow', null); - - request.abort(); - - return request.catch((err) => err); + const fetcher = new Fetchr(); + const request = fetcher.read('slow'); + + // We need to abort after 300ms since the current + // implementation does not trigger a request when + // calling read with only one argument. We could + // call .end() or .then() without any callback, + // but doing it with a setTimeout is more + // realistic since it's very likely that users + // would have abort inside an event handler in a + // different stack. + const abort = () => + new Promise((resolve) => { + setTimeout(() => { + request.abort(); + resolve(); + }, 300); + }); + + return Promise.all([request, abort()]).catch((err) => err); }); expect(response).to.deep.equal({ @@ -279,7 +343,7 @@ describe('client/server integration', () => { // abort mechanism work. const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); const promise = fetcher.read('slow', null, { retry: { maxRetries: 5, interval: 0 }, timeout: 200, @@ -298,7 +362,7 @@ describe('client/server integration', () => { it('can handle timeouts', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .read('error', { error: 'timeout' }, { timeout: 20 }) .catch((err) => err); @@ -324,7 +388,7 @@ describe('client/server integration', () => { it('can retry failed requests', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .read( 'error', @@ -348,7 +412,7 @@ describe('client/server integration', () => { // instantly. const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .read('slow-then-fast', { reset: true }) .then(() => @@ -369,7 +433,7 @@ describe('client/server integration', () => { const fetcher = new Fetchr({ xhrPath: 'http://localhost:3001', }); - return fetcher.create('error', null).catch((err) => err); + return fetcher.create('error').catch((err) => err); }); expect(response).to.deep.equal({ @@ -395,8 +459,8 @@ describe('client/server integration', () => { it('can handle service expected errors', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); - return fetcher.create('error', null).catch((err) => err); + const fetcher = new Fetchr(); + return fetcher.create('error').catch((err) => err); }); expect(response).to.deep.equal({ @@ -426,7 +490,7 @@ describe('client/server integration', () => { it('can handle service unexpected errors', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .create('error', { error: 'unexpected' }) .catch((err) => err); @@ -459,7 +523,7 @@ describe('client/server integration', () => { it('can handle incorrect api path', async () => { const response = await page.evaluate(() => { const fetcher = new Fetchr({ xhrPath: '/non-existent' }); - return fetcher.create('item', null).catch((err) => err); + return fetcher.create('item').catch((err) => err); }); expect(response).to.deep.equal({ @@ -485,7 +549,7 @@ describe('client/server integration', () => { it('can handle timeouts', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .create('error', { error: 'timeout' }, null, { timeout: 20, @@ -516,7 +580,7 @@ describe('client/server integration', () => { it('can retry failed requests', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .create('error', { error: 'retry' }, null, { retry: { maxRetries: 2 }, @@ -536,7 +600,7 @@ describe('client/server integration', () => { describe('headers', () => { it('can handle request and response headers', async () => { const response = await page.evaluate(() => { - const fetcher = new Fetchr({}); + const fetcher = new Fetchr(); return fetcher .read('header', null, { headers: { 'x-fetchr-request': '42' }, diff --git a/tests/unit/libs/util/httpRequest.js b/tests/unit/libs/util/httpRequest.js index 345815c..39f8278 100644 --- a/tests/unit/libs/util/httpRequest.js +++ b/tests/unit/libs/util/httpRequest.js @@ -443,4 +443,29 @@ describe('Client HTTP', function () { }); }); }); + + describe('Promise Support', () => { + it('always returns the response if resolved multiple times', async function () { + const body = { data: 'BODY' }; + + fetchMock.get('/url', { body, status: responseStatus }); + + const request = httpRequest(GETConfig); + + expect(await request).to.deep.equal(body); + expect(await request).to.deep.equal(body); + }); + + it('works with Promise.all', function () { + const body = { data: 'BODY' }; + + fetchMock.get('/url', { body, status: responseStatus }); + + const request = httpRequest(GETConfig); + + return Promise.all([request]).then(([result]) => { + expect(result).to.deep.equal(body); + }); + }); + }); });