From de0858651194f28b00b697e47f24e6a51c7b9339 Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Tue, 9 Apr 2019 18:58:41 -0700 Subject: [PATCH 1/2] feat(fetch): Making fetch happen - Adds `fromFetch` implementation that uses native `fetch` - Adds tests and basic documentation - DOES NOT polyfill fetch --- .../transforms/angular-api-package/index.js | 1 + .../systemjs/systemjs-compatibility-spec.js | 2 + spec/observables/dom/fetch-spec.ts | 195 ++++++++++++++++++ spec/support/default.opts | 2 +- src/fetch/index.ts | 1 + src/fetch/package.json | 8 + src/internal/observable/dom/fetch.ts | 61 ++++++ tools/make-umd-compat-bundle.js | 1 + tsconfig/tsconfig.base.json | 1 + tsconfig/tsconfig.legacy-reexport.json | 1 + 10 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 spec/observables/dom/fetch-spec.ts create mode 100644 src/fetch/index.ts create mode 100644 src/fetch/package.json create mode 100644 src/internal/observable/dom/fetch.ts diff --git a/docs_app/tools/transforms/angular-api-package/index.js b/docs_app/tools/transforms/angular-api-package/index.js index fafc7aed4d..75a7762657 100644 --- a/docs_app/tools/transforms/angular-api-package/index.js +++ b/docs_app/tools/transforms/angular-api-package/index.js @@ -70,6 +70,7 @@ module.exports = new Package('angular-api', [basePackage, typeScriptPackage]) 'index.ts', 'operators/index.ts', 'ajax/index.ts', + 'fetch/index.ts', 'webSocket/index.ts', 'testing/index.ts' ]; diff --git a/integration/systemjs/systemjs-compatibility-spec.js b/integration/systemjs/systemjs-compatibility-spec.js index 4c672575a1..cb1713c2c5 100644 --- a/integration/systemjs/systemjs-compatibility-spec.js +++ b/integration/systemjs/systemjs-compatibility-spec.js @@ -6,6 +6,7 @@ System.config({ packages: { 'rxjs': {main: 'index.js', defaultExtension: 'js' }, 'rxjs/ajax': {main: 'index.js', defaultExtension: 'js' }, + 'rxjs/fetch': {main: 'index.js', defaultExtension: 'js' }, 'rxjs/operators': {main: 'index.js', defaultExtension: 'js' }, 'rxjs/testing': {main: 'index.js', defaultExtension: 'js' }, 'rxjs/webSocket': {main: 'index.js', defaultExtension: 'js' } @@ -15,6 +16,7 @@ System.config({ Promise.all([ System.import('rxjs'), System.import('rxjs/ajax'), + System.import('rxjs/fetch'), System.import('rxjs/operators'), System.import('rxjs/testing'), System.import('rxjs/webSocket'), diff --git a/spec/observables/dom/fetch-spec.ts b/spec/observables/dom/fetch-spec.ts new file mode 100644 index 0000000000..4d7e7606c1 --- /dev/null +++ b/spec/observables/dom/fetch-spec.ts @@ -0,0 +1,195 @@ +import { fromFetch } from 'rxjs/fetch'; +import { expect } from 'chai'; +import { root } from '../../../src/internal/util/root'; + +const OK_RESPONSE = { + ok: true, +} as Response; + +function mockFetchImpl(input: string | Request, init?: RequestInit): Promise { + (mockFetchImpl as MockFetch).calls.push({ input, init }); + return new Promise((resolve, reject) => { + if (init.signal) { + init.signal.addEventListener('abort', () => { + console.log('triggered'); + reject(new MockDOMException()); + }); + } + return Promise.resolve(null).then(() => { + console.log('resolved'); + resolve((mockFetchImpl as any).respondWith); + }); + }); +} +(mockFetchImpl as MockFetch).reset = function (this: any) { + this.calls = [] as any[]; + this.respondWith = OK_RESPONSE; +}; +(mockFetchImpl as MockFetch).reset(); + +const mockFetch: MockFetch = mockFetchImpl as MockFetch; + +class MockDOMException {} + +class MockAbortController { + readonly signal = new MockAbortSignal(); + + abort() { + this.signal._signal(); + } + + constructor() { + MockAbortController.created++; + } + + static created = 0; + + static reset() { + MockAbortController.created = 0; + } +} + +class MockAbortSignal { + private _listeners: Function[] = []; + + aborted = false; + + addEventListener(name: 'abort', handler: Function) { + this._listeners.push(handler); + } + + removeEventListener(name: 'abort', handler: Function) { + const index = this._listeners.indexOf(handler); + if (index >= 0) { + this._listeners.splice(index, 1); + } + } + + _signal() { + this.aborted = true; + while (this._listeners.length > 0) { + this._listeners.shift()(); + } + } +} + +interface MockFetch { + (input: string | Request, init?: RequestInit): Promise; + calls: { input: string | Request, init: RequestInit | undefined }[]; + reset(): void; + respondWith: Response; +} + +describe('fromFetch', () => { + let _fetch: typeof fetch; + let _AbortController: AbortController; + + beforeEach(() => { + mockFetch.reset(); + if (root.fetch) { + _fetch = root.fetch; + } + root.fetch = mockFetch; + + MockAbortController.reset(); + if (root.AbortController) { + _AbortController = root.AbortController; + } + root.AbortController = MockAbortController; + }); + + afterEach(() => { + root.fetch = _fetch; + root.AbortController = _AbortController; + }); + + it('should exist', () => { + expect(fromFetch).to.be.a('function'); + }); + + it('should fetch', done => { + const fetch$ = fromFetch('/foo'); + expect(mockFetch.calls.length).to.equal(0); + expect(MockAbortController.created).to.equal(0); + + fetch$.subscribe({ + next: response => { + expect(response).to.equal(OK_RESPONSE); + }, + error: done, + complete: done, + }); + + expect(MockAbortController.created).to.equal(1); + expect(mockFetch.calls.length).to.equal(1); + expect(mockFetch.calls[0].input).to.equal('/foo'); + expect(mockFetch.calls[0].init.signal).not.to.be.undefined; + expect(mockFetch.calls[0].init.signal.aborted).to.be.false; + }); + + it('should handle Response that is not `ok`', done => { + mockFetch.respondWith = { + ok: false, + status: 400, + body: 'Bad stuff here' + } as any as Response; + + const fetch$ = fromFetch('/foo'); + expect(mockFetch.calls.length).to.equal(0); + expect(MockAbortController.created).to.equal(0); + + fetch$.subscribe({ + next: () => done(new Error('should not be called')), + error: err => { + expect(err.message).to.equal('RxJS Fetch HTTP Error\n\nStatus:\n400\n\nBody:\nBad stuff here\n'); + done(); + }, + }); + + expect(MockAbortController.created).to.equal(1); + expect(mockFetch.calls.length).to.equal(1); + expect(mockFetch.calls[0].input).to.equal('/foo'); + expect(mockFetch.calls[0].init.signal).not.to.be.undefined; + expect(mockFetch.calls[0].init.signal.aborted).to.be.false; + }); + + it('should abort when unsubscribed', () => { + const fetch$ = fromFetch('/foo'); + expect(mockFetch.calls.length).to.equal(0); + expect(MockAbortController.created).to.equal(0); + const subscription = fetch$.subscribe(); + + expect(MockAbortController.created).to.equal(1); + expect(mockFetch.calls.length).to.equal(1); + expect(mockFetch.calls[0].input).to.equal('/foo'); + expect(mockFetch.calls[0].init.signal).not.to.be.undefined; + expect(mockFetch.calls[0].init.signal.aborted).to.be.false; + + subscription.unsubscribe(); + expect(mockFetch.calls[0].init.signal.aborted).to.be.true; + }); + + it('should allow passing of init object', () => { + const myInit = {}; + const fetch$ = fromFetch('/foo', myInit); + fetch$.subscribe(); + expect(mockFetch.calls[0].init).to.equal(myInit); + expect(mockFetch.calls[0].init.signal).not.to.be.undefined; + }); + + it('should treat passed signals as a cancellation token which triggers an error', done => { + const controller = new MockAbortController(); + const signal = controller.signal as any; + const fetch$ = fromFetch('/foo', { signal }); + const subscription = fetch$.subscribe({ + error: err => { + expect(err).to.be.instanceof(MockDOMException); + done(); + } + }); + controller.abort(); + expect(mockFetch.calls[0].init.signal.aborted).to.be.true; + // The subscription will not be closed until the error fires when the promise resolves. + expect(subscription.closed).to.be.false; + }); +}); diff --git a/spec/support/default.opts b/spec/support/default.opts index ead4feb92d..32264362e9 100644 --- a/spec/support/default.opts +++ b/spec/support/default.opts @@ -7,7 +7,7 @@ --reporter dot --check-leaks ---globals WebSocket,FormData,XDomainRequest,ActiveXObject +--globals WebSocket,FormData,XDomainRequest,ActiveXObject,fetch,AbortController --recursive --timeout 5000 diff --git a/src/fetch/index.ts b/src/fetch/index.ts new file mode 100644 index 0000000000..e6ff01da27 --- /dev/null +++ b/src/fetch/index.ts @@ -0,0 +1 @@ +export { fromFetch } from '../internal/observable/dom/fetch'; diff --git a/src/fetch/package.json b/src/fetch/package.json new file mode 100644 index 0000000000..dff5519633 --- /dev/null +++ b/src/fetch/package.json @@ -0,0 +1,8 @@ +{ + "name": "rxjs/fetch", + "typings": "./index.d.ts", + "main": "./index.js", + "module": "../_esm5/fetch/index.js", + "es2015": "../_esm2015/fetch/index.js", + "sideEffects": false +} diff --git a/src/internal/observable/dom/fetch.ts b/src/internal/observable/dom/fetch.ts new file mode 100644 index 0000000000..852a562fae --- /dev/null +++ b/src/internal/observable/dom/fetch.ts @@ -0,0 +1,61 @@ +import { Observable } from '../../Observable'; + +/** + * Uses [the Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to + * make an HTTP request. + * + * Will automatically set up an internal [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) + * in order to teardown the internal `fetch` when the subscription tears down. + * + * If a `signal` is provided via the `init` argument, it will behave like it usually does with + * `fetch`. If the provided `signal` aborts, the error that `fetch` normally rejects with + * in that scenario will be emitted as an error from the observable. + * + * @param input The resource you would like to fetch. Can be a url or a request object. + * @param init A configuration object for the fetch. + * [See MDN for more details](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) + * @returns An Observable, that when subscribed to performs an HTTP request using the native `fetch` + * function. The {@link Subscription} is tied to an `AbortController` for the the fetch. + */ +export function fromFetch(input: string | Request, init?: RequestInit): Observable { + return new Observable(subscriber => { + const controller = new AbortController(); + const signal = controller.signal; + let outerSignalHandler: () => void; + let unsubscribed = false; + + if (init) { + // If we a signal is provided, just have it teardown. It's a cancellation token, basically. + if (init.signal) { + outerSignalHandler = () => { + if (!signal.aborted) { + controller.abort(); + } + }; + init.signal.addEventListener('abort', outerSignalHandler); + } + init.signal = signal; + } else { + init = { signal }; + } + + fetch(input, init).then(response => { + if (response.ok) { + subscriber.next(response); + subscriber.complete(); + } else { + subscriber.error(new Error(`RxJS Fetch HTTP Error\n\nStatus:\n${response.status}\n\nBody:\n${response.body}\n`)); + } + }).catch(err => { + if (!unsubscribed) { + // Only forward the error if it wasn't an abort. + subscriber.error(err); + } + }); + + return () => { + unsubscribed = true; + controller.abort(); + }; + }); +} diff --git a/tools/make-umd-compat-bundle.js b/tools/make-umd-compat-bundle.js index 7ba4826303..83e2eb4f3d 100644 --- a/tools/make-umd-compat-bundle.js +++ b/tools/make-umd-compat-bundle.js @@ -8,6 +8,7 @@ rollupBundle({ 'rxjs/operators': 'dist-compat/esm5_for_rollup/src/operators/index.js', 'rxjs/webSocket': 'dist-compat/esm5_for_rollup/src/webSocket/index.js', 'rxjs/ajax': 'dist-compat/esm5_for_rollup/src/ajax/index.js', + 'rxjs/fetch': 'dist-compat/esm5_for_rollup/src/fetch/index.js', 'rxjs/internal-compatibility': 'dist-compat/esm5_for_rollup/src/internal-compatibility/index.js', 'rxjs': 'dist-compat/esm5_for_rollup/src/index.js', }, diff --git a/tsconfig/tsconfig.base.json b/tsconfig/tsconfig.base.json index 7bf2b8ea11..9f53b1292d 100644 --- a/tsconfig/tsconfig.base.json +++ b/tsconfig/tsconfig.base.json @@ -13,6 +13,7 @@ // entry-points "../src/index.ts", "../src/ajax/index.ts", + "../src/fetch/index.ts", "../src/operators/index.ts", "../src/testing/index.ts", "../src/webSocket/index.ts", diff --git a/tsconfig/tsconfig.legacy-reexport.json b/tsconfig/tsconfig.legacy-reexport.json index 3a134b2576..0768fa43de 100644 --- a/tsconfig/tsconfig.legacy-reexport.json +++ b/tsconfig/tsconfig.legacy-reexport.json @@ -16,6 +16,7 @@ "rxjs/internal-compatibility": ["../dist/typings/internal-compatibility/index"], "rxjs/testing": ["../dist/typings/testing/index"], "rxjs/ajax": ["../dist/typings/ajax/index"], + "rxjs/fetch": ["../dist/typings/fetch/index"], "rxjs/operators": ["../dist/typings/operators/index"], "rxjs/webSocket": ["../dist/typings/webSocket/index"], "rxjs-compat": ["../dist-compat/typings/compat/Rx"], From 71ecb714327b4e97a3af282293638517ae9b967d Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Tue, 16 Apr 2019 15:26:21 -0700 Subject: [PATCH 2/2] fixup! feat(fetch): Making fetch happen --- spec/observables/dom/fetch-spec.ts | 17 ++++++------ src/internal/observable/dom/fetch.ts | 41 ++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/spec/observables/dom/fetch-spec.ts b/spec/observables/dom/fetch-spec.ts index 4d7e7606c1..93923c7914 100644 --- a/spec/observables/dom/fetch-spec.ts +++ b/spec/observables/dom/fetch-spec.ts @@ -11,12 +11,10 @@ function mockFetchImpl(input: string | Request, init?: RequestInit): Promise((resolve, reject) => { if (init.signal) { init.signal.addEventListener('abort', () => { - console.log('triggered'); reject(new MockDOMException()); }); } return Promise.resolve(null).then(() => { - console.log('resolved'); resolve((mockFetchImpl as any).respondWith); }); }); @@ -139,11 +137,11 @@ describe('fromFetch', () => { expect(MockAbortController.created).to.equal(0); fetch$.subscribe({ - next: () => done(new Error('should not be called')), - error: err => { - expect(err.message).to.equal('RxJS Fetch HTTP Error\n\nStatus:\n400\n\nBody:\nBad stuff here\n'); - done(); + next: response => { + expect(response).to.equal(mockFetch.respondWith); }, + complete: done, + error: done }); expect(MockAbortController.created).to.equal(1); @@ -169,10 +167,13 @@ describe('fromFetch', () => { expect(mockFetch.calls[0].init.signal.aborted).to.be.true; }); - it('should allow passing of init object', () => { + it('should allow passing of init object', done => { const myInit = {}; const fetch$ = fromFetch('/foo', myInit); - fetch$.subscribe(); + fetch$.subscribe({ + error: done, + complete: done, + }); expect(mockFetch.calls[0].init).to.equal(myInit); expect(mockFetch.calls[0].init.signal).not.to.be.undefined; }); diff --git a/src/internal/observable/dom/fetch.ts b/src/internal/observable/dom/fetch.ts index 852a562fae..05551c2d21 100644 --- a/src/internal/observable/dom/fetch.ts +++ b/src/internal/observable/dom/fetch.ts @@ -4,6 +4,9 @@ import { Observable } from '../../Observable'; * Uses [the Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to * make an HTTP request. * + * **WARNING** Parts of the fetch API are still experimental. `AbortController` is + * required for this implementation to work and use cancellation appropriately. + * * Will automatically set up an internal [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) * in order to teardown the internal `fetch` when the subscription tears down. * @@ -11,6 +14,36 @@ import { Observable } from '../../Observable'; * `fetch`. If the provided `signal` aborts, the error that `fetch` normally rejects with * in that scenario will be emitted as an error from the observable. * + * ### Basic Use + * + * ```ts + * import { of } from 'rxjs'; + * import { fetch } from 'rxjs/fetch'; + * import { switchMap, catchError } from 'rxjs/operators'; + * + * const data$ = fetch('https://api.github.com/users?per_page=5').pipe( + * switchMap(response => { + * if(responose.ok) { + * // OK return data + * return response.json(); + * } else { + * // Server is returning a status requiring the client to try something else. + * return of({ error: true, message: `Error ${response.status}` }); + * } + * }), + * catchError(err => { + * // Network or other error, handle appropriately + * console.error(err); + * return of({ error: true, message: error.message }) + * }) + * ); + * + * data$.subscribe({ + * next: result => console.log(result), + * complete: () => console.log('done') + * }) + * ``` + * * @param input The resource you would like to fetch. Can be a url or a request object. * @param init A configuration object for the fetch. * [See MDN for more details](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) @@ -40,12 +73,8 @@ export function fromFetch(input: string | Request, init?: RequestInit): Observab } fetch(input, init).then(response => { - if (response.ok) { - subscriber.next(response); - subscriber.complete(); - } else { - subscriber.error(new Error(`RxJS Fetch HTTP Error\n\nStatus:\n${response.status}\n\nBody:\n${response.body}\n`)); - } + subscriber.next(response); + subscriber.complete(); }).catch(err => { if (!unsubscribed) { // Only forward the error if it wasn't an abort.