Skip to content

Commit 5a1ef86

Browse files
authored
feat(fetch): Making fetch happen (ReactiveX#4702)
* feat(fetch): Making fetch happen - Adds `fromFetch` implementation that uses native `fetch` - Adds tests and basic documentation - DOES NOT polyfill fetch * fixup! feat(fetch): Making fetch happen
1 parent a9c87f6 commit 5a1ef86

File tree

10 files changed

+302
-1
lines changed

10 files changed

+302
-1
lines changed

docs_app/tools/transforms/angular-api-package/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ module.exports = new Package('angular-api', [basePackage, typeScriptPackage])
7070
'index.ts',
7171
'operators/index.ts',
7272
'ajax/index.ts',
73+
'fetch/index.ts',
7374
'webSocket/index.ts',
7475
'testing/index.ts'
7576
];

integration/systemjs/systemjs-compatibility-spec.js

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ System.config({
66
packages: {
77
'rxjs': {main: 'index.js', defaultExtension: 'js' },
88
'rxjs/ajax': {main: 'index.js', defaultExtension: 'js' },
9+
'rxjs/fetch': {main: 'index.js', defaultExtension: 'js' },
910
'rxjs/operators': {main: 'index.js', defaultExtension: 'js' },
1011
'rxjs/testing': {main: 'index.js', defaultExtension: 'js' },
1112
'rxjs/webSocket': {main: 'index.js', defaultExtension: 'js' }
@@ -15,6 +16,7 @@ System.config({
1516
Promise.all([
1617
System.import('rxjs'),
1718
System.import('rxjs/ajax'),
19+
System.import('rxjs/fetch'),
1820
System.import('rxjs/operators'),
1921
System.import('rxjs/testing'),
2022
System.import('rxjs/webSocket'),

spec/observables/dom/fetch-spec.ts

+196
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { fromFetch } from 'rxjs/fetch';
2+
import { expect } from 'chai';
3+
import { root } from '../../../src/internal/util/root';
4+
5+
const OK_RESPONSE = {
6+
ok: true,
7+
} as Response;
8+
9+
function mockFetchImpl(input: string | Request, init?: RequestInit): Promise<Response> {
10+
(mockFetchImpl as MockFetch).calls.push({ input, init });
11+
return new Promise<any>((resolve, reject) => {
12+
if (init.signal) {
13+
init.signal.addEventListener('abort', () => {
14+
reject(new MockDOMException());
15+
});
16+
}
17+
return Promise.resolve(null).then(() => {
18+
resolve((mockFetchImpl as any).respondWith);
19+
});
20+
});
21+
}
22+
(mockFetchImpl as MockFetch).reset = function (this: any) {
23+
this.calls = [] as any[];
24+
this.respondWith = OK_RESPONSE;
25+
};
26+
(mockFetchImpl as MockFetch).reset();
27+
28+
const mockFetch: MockFetch = mockFetchImpl as MockFetch;
29+
30+
class MockDOMException {}
31+
32+
class MockAbortController {
33+
readonly signal = new MockAbortSignal();
34+
35+
abort() {
36+
this.signal._signal();
37+
}
38+
39+
constructor() {
40+
MockAbortController.created++;
41+
}
42+
43+
static created = 0;
44+
45+
static reset() {
46+
MockAbortController.created = 0;
47+
}
48+
}
49+
50+
class MockAbortSignal {
51+
private _listeners: Function[] = [];
52+
53+
aborted = false;
54+
55+
addEventListener(name: 'abort', handler: Function) {
56+
this._listeners.push(handler);
57+
}
58+
59+
removeEventListener(name: 'abort', handler: Function) {
60+
const index = this._listeners.indexOf(handler);
61+
if (index >= 0) {
62+
this._listeners.splice(index, 1);
63+
}
64+
}
65+
66+
_signal() {
67+
this.aborted = true;
68+
while (this._listeners.length > 0) {
69+
this._listeners.shift()();
70+
}
71+
}
72+
}
73+
74+
interface MockFetch {
75+
(input: string | Request, init?: RequestInit): Promise<Response>;
76+
calls: { input: string | Request, init: RequestInit | undefined }[];
77+
reset(): void;
78+
respondWith: Response;
79+
}
80+
81+
describe('fromFetch', () => {
82+
let _fetch: typeof fetch;
83+
let _AbortController: AbortController;
84+
85+
beforeEach(() => {
86+
mockFetch.reset();
87+
if (root.fetch) {
88+
_fetch = root.fetch;
89+
}
90+
root.fetch = mockFetch;
91+
92+
MockAbortController.reset();
93+
if (root.AbortController) {
94+
_AbortController = root.AbortController;
95+
}
96+
root.AbortController = MockAbortController;
97+
});
98+
99+
afterEach(() => {
100+
root.fetch = _fetch;
101+
root.AbortController = _AbortController;
102+
});
103+
104+
it('should exist', () => {
105+
expect(fromFetch).to.be.a('function');
106+
});
107+
108+
it('should fetch', done => {
109+
const fetch$ = fromFetch('/foo');
110+
expect(mockFetch.calls.length).to.equal(0);
111+
expect(MockAbortController.created).to.equal(0);
112+
113+
fetch$.subscribe({
114+
next: response => {
115+
expect(response).to.equal(OK_RESPONSE);
116+
},
117+
error: done,
118+
complete: done,
119+
});
120+
121+
expect(MockAbortController.created).to.equal(1);
122+
expect(mockFetch.calls.length).to.equal(1);
123+
expect(mockFetch.calls[0].input).to.equal('/foo');
124+
expect(mockFetch.calls[0].init.signal).not.to.be.undefined;
125+
expect(mockFetch.calls[0].init.signal.aborted).to.be.false;
126+
});
127+
128+
it('should handle Response that is not `ok`', done => {
129+
mockFetch.respondWith = {
130+
ok: false,
131+
status: 400,
132+
body: 'Bad stuff here'
133+
} as any as Response;
134+
135+
const fetch$ = fromFetch('/foo');
136+
expect(mockFetch.calls.length).to.equal(0);
137+
expect(MockAbortController.created).to.equal(0);
138+
139+
fetch$.subscribe({
140+
next: response => {
141+
expect(response).to.equal(mockFetch.respondWith);
142+
},
143+
complete: done,
144+
error: done
145+
});
146+
147+
expect(MockAbortController.created).to.equal(1);
148+
expect(mockFetch.calls.length).to.equal(1);
149+
expect(mockFetch.calls[0].input).to.equal('/foo');
150+
expect(mockFetch.calls[0].init.signal).not.to.be.undefined;
151+
expect(mockFetch.calls[0].init.signal.aborted).to.be.false;
152+
});
153+
154+
it('should abort when unsubscribed', () => {
155+
const fetch$ = fromFetch('/foo');
156+
expect(mockFetch.calls.length).to.equal(0);
157+
expect(MockAbortController.created).to.equal(0);
158+
const subscription = fetch$.subscribe();
159+
160+
expect(MockAbortController.created).to.equal(1);
161+
expect(mockFetch.calls.length).to.equal(1);
162+
expect(mockFetch.calls[0].input).to.equal('/foo');
163+
expect(mockFetch.calls[0].init.signal).not.to.be.undefined;
164+
expect(mockFetch.calls[0].init.signal.aborted).to.be.false;
165+
166+
subscription.unsubscribe();
167+
expect(mockFetch.calls[0].init.signal.aborted).to.be.true;
168+
});
169+
170+
it('should allow passing of init object', done => {
171+
const myInit = {};
172+
const fetch$ = fromFetch('/foo', myInit);
173+
fetch$.subscribe({
174+
error: done,
175+
complete: done,
176+
});
177+
expect(mockFetch.calls[0].init).to.equal(myInit);
178+
expect(mockFetch.calls[0].init.signal).not.to.be.undefined;
179+
});
180+
181+
it('should treat passed signals as a cancellation token which triggers an error', done => {
182+
const controller = new MockAbortController();
183+
const signal = controller.signal as any;
184+
const fetch$ = fromFetch('/foo', { signal });
185+
const subscription = fetch$.subscribe({
186+
error: err => {
187+
expect(err).to.be.instanceof(MockDOMException);
188+
done();
189+
}
190+
});
191+
controller.abort();
192+
expect(mockFetch.calls[0].init.signal.aborted).to.be.true;
193+
// The subscription will not be closed until the error fires when the promise resolves.
194+
expect(subscription.closed).to.be.false;
195+
});
196+
});

spec/support/default.opts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
--reporter dot
88

99
--check-leaks
10-
--globals WebSocket,FormData,XDomainRequest,ActiveXObject
10+
--globals WebSocket,FormData,XDomainRequest,ActiveXObject,fetch,AbortController
1111

1212
--recursive
1313
--timeout 5000

src/fetch/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { fromFetch } from '../internal/observable/dom/fetch';

src/fetch/package.json

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "rxjs/fetch",
3+
"typings": "./index.d.ts",
4+
"main": "./index.js",
5+
"module": "../_esm5/fetch/index.js",
6+
"es2015": "../_esm2015/fetch/index.js",
7+
"sideEffects": false
8+
}

src/internal/observable/dom/fetch.ts

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Observable } from '../../Observable';
2+
3+
/**
4+
* Uses [the Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to
5+
* make an HTTP request.
6+
*
7+
* **WARNING** Parts of the fetch API are still experimental. `AbortController` is
8+
* required for this implementation to work and use cancellation appropriately.
9+
*
10+
* Will automatically set up an internal [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)
11+
* in order to teardown the internal `fetch` when the subscription tears down.
12+
*
13+
* If a `signal` is provided via the `init` argument, it will behave like it usually does with
14+
* `fetch`. If the provided `signal` aborts, the error that `fetch` normally rejects with
15+
* in that scenario will be emitted as an error from the observable.
16+
*
17+
* ### Basic Use
18+
*
19+
* ```ts
20+
* import { of } from 'rxjs';
21+
* import { fetch } from 'rxjs/fetch';
22+
* import { switchMap, catchError } from 'rxjs/operators';
23+
*
24+
* const data$ = fetch('https://api.github.com/users?per_page=5').pipe(
25+
* switchMap(response => {
26+
* if(responose.ok) {
27+
* // OK return data
28+
* return response.json();
29+
* } else {
30+
* // Server is returning a status requiring the client to try something else.
31+
* return of({ error: true, message: `Error ${response.status}` });
32+
* }
33+
* }),
34+
* catchError(err => {
35+
* // Network or other error, handle appropriately
36+
* console.error(err);
37+
* return of({ error: true, message: error.message })
38+
* })
39+
* );
40+
*
41+
* data$.subscribe({
42+
* next: result => console.log(result),
43+
* complete: () => console.log('done')
44+
* })
45+
* ```
46+
*
47+
* @param input The resource you would like to fetch. Can be a url or a request object.
48+
* @param init A configuration object for the fetch.
49+
* [See MDN for more details](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters)
50+
* @returns An Observable, that when subscribed to performs an HTTP request using the native `fetch`
51+
* function. The {@link Subscription} is tied to an `AbortController` for the the fetch.
52+
*/
53+
export function fromFetch(input: string | Request, init?: RequestInit): Observable<Response> {
54+
return new Observable<Response>(subscriber => {
55+
const controller = new AbortController();
56+
const signal = controller.signal;
57+
let outerSignalHandler: () => void;
58+
let unsubscribed = false;
59+
60+
if (init) {
61+
// If we a signal is provided, just have it teardown. It's a cancellation token, basically.
62+
if (init.signal) {
63+
outerSignalHandler = () => {
64+
if (!signal.aborted) {
65+
controller.abort();
66+
}
67+
};
68+
init.signal.addEventListener('abort', outerSignalHandler);
69+
}
70+
init.signal = signal;
71+
} else {
72+
init = { signal };
73+
}
74+
75+
fetch(input, init).then(response => {
76+
subscriber.next(response);
77+
subscriber.complete();
78+
}).catch(err => {
79+
if (!unsubscribed) {
80+
// Only forward the error if it wasn't an abort.
81+
subscriber.error(err);
82+
}
83+
});
84+
85+
return () => {
86+
unsubscribed = true;
87+
controller.abort();
88+
};
89+
});
90+
}

tools/make-umd-compat-bundle.js

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ rollupBundle({
88
'rxjs/operators': 'dist-compat/esm5_for_rollup/src/operators/index.js',
99
'rxjs/webSocket': 'dist-compat/esm5_for_rollup/src/webSocket/index.js',
1010
'rxjs/ajax': 'dist-compat/esm5_for_rollup/src/ajax/index.js',
11+
'rxjs/fetch': 'dist-compat/esm5_for_rollup/src/fetch/index.js',
1112
'rxjs/internal-compatibility': 'dist-compat/esm5_for_rollup/src/internal-compatibility/index.js',
1213
'rxjs': 'dist-compat/esm5_for_rollup/src/index.js',
1314
},

tsconfig/tsconfig.base.json

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// entry-points
1414
"../src/index.ts",
1515
"../src/ajax/index.ts",
16+
"../src/fetch/index.ts",
1617
"../src/operators/index.ts",
1718
"../src/testing/index.ts",
1819
"../src/webSocket/index.ts",

tsconfig/tsconfig.legacy-reexport.json

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"rxjs/internal-compatibility": ["../dist/typings/internal-compatibility/index"],
1717
"rxjs/testing": ["../dist/typings/testing/index"],
1818
"rxjs/ajax": ["../dist/typings/ajax/index"],
19+
"rxjs/fetch": ["../dist/typings/fetch/index"],
1920
"rxjs/operators": ["../dist/typings/operators/index"],
2021
"rxjs/webSocket": ["../dist/typings/webSocket/index"],
2122
"rxjs-compat": ["../dist-compat/typings/compat/Rx"],

0 commit comments

Comments
 (0)