├── .gitignore ├── .npmignore ├── README.md ├── package.json ├── src └── index.js └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | npm-debug.log 3 | node_modules 4 | lib 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cycle Fetch Driver 2 | 3 | A [Cycle.js](http://cycle.js.org) [Driver](http://cycle.js.org/drivers.html) for making HTTP requests, using the [Fetch API](https://fetch.spec.whatwg.org/). 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install @cycle/fetch 9 | ``` 10 | 11 | ## API 12 | 13 | ### ```makeFetchDriver(scheduler: Scheduler) -> fetchDriver: function``` 14 | 15 | Factory that returns a fetch driver. It takes an optional ```scheduler``` argument to pass into ```fromPromise```. 16 | 17 | ## Usage 18 | 19 | Basics: 20 | 21 | ```js 22 | import 'whatwg-fetch' // polyfill if you want to support older browsers 23 | import Cycle from '@cycle/core'; 24 | import { makeFetchDriver } from '@cycle/fetch'; 25 | 26 | function main(responses) { 27 | // ... 28 | } 29 | 30 | const drivers = { 31 | HTTP: makeFetchDriver() 32 | } 33 | 34 | Cycle.run(main, drivers); 35 | ``` 36 | 37 | Simple and normal use case: 38 | 39 | ```js 40 | function main({ DOM, HTTP }) { 41 | const HELLO_URL = 'http://localhost:8080/hello'; 42 | const request$ = Rx.Observable.just(HELLO_URL); 43 | const vtree$ = HTTP 44 | .byUrl(HELLO_URL) 45 | .mergeAll() 46 | .flatMap(res => res.text()) // We expect this to be "Hello World" 47 | .startWith('Loading...') 48 | .map(text => 49 | h('div.container', [ 50 | h('h1', text) 51 | ]) 52 | ); 53 | 54 | return { 55 | DOM: vtree$, 56 | HTTP: request$ 57 | }; 58 | } 59 | ``` 60 | 61 | Select all the responses for a certain key: 62 | 63 | ```js 64 | function main({ DOM, HTTP }) { 65 | const HELLO_URL = 'http://localhost:8080/hello'; 66 | const request$ = Rx.Observable.just({ 67 | key: 'hello', 68 | url: HELLO_URL 69 | }); 70 | const vtree$ = HTTP 71 | .byKey('hello') 72 | .mergeAll() 73 | .flatMap(res => res.text()) // We expect this to be "Hello World" 74 | .startWith('Loading...') 75 | .map(text => 76 | h('div.container', [ 77 | h('h1', text) 78 | ]) 79 | ); 80 | 81 | return { 82 | DOM: vtree$, 83 | HTTP: request$ 84 | }; 85 | } 86 | ``` 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cycle/fetch", 3 | "version": "4.0.0", 4 | "description": "A Cycle.js Driver for making HTTP requests through fetch", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "standard": "standard src", 8 | "pretest": "npm run standard", 9 | "test": "babel-node test/*.js | faucet", 10 | "prepublish": "mkdir -p lib && babel -d lib/ src/" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/cyclejs/cycle-fetch-driver.git" 15 | }, 16 | "author": "Seggy Umboh", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/cyclejs/cycle-fetch-driver/issues" 20 | }, 21 | "homepage": "https://github.com/cyclejs/cycle-fetch-driver#readme", 22 | "devDependencies": { 23 | "babel": "^5.8.23", 24 | "faucet": "0.0.1", 25 | "standard": "^5.3.1", 26 | "tape": "^4.2.0" 27 | }, 28 | "peerDependencies": { 29 | "rx": "^4.0.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Rx from 'rx' 2 | 3 | function getUrl (request) { 4 | return request.input && request.input.url || request.url 5 | } 6 | 7 | function normalizeRequest (input) { 8 | const request = typeof input === 'string' 9 | ? { url: input } 10 | : { ...input } 11 | if (!request.key) { 12 | request.key = getUrl(request) 13 | } 14 | return request 15 | } 16 | 17 | function byKey (response$$, key) { 18 | return response$$ 19 | .filter(response$ => response$.request.key === key) 20 | } 21 | 22 | function byUrl (response$$, url) { 23 | return response$$ 24 | .filter(response$ => getUrl(response$.request) === url) 25 | } 26 | 27 | // scheduler option is for testing because Reactive-Extensions/RxJS#976 28 | export function makeFetchDriver (scheduler) { 29 | return function fetchDriver (request$) { 30 | const response$$ = new Rx.ReplaySubject(1) 31 | request$ 32 | .map(normalizeRequest) 33 | .subscribe( 34 | request => { 35 | const { input, url, init } = request 36 | const response$ = Rx.Observable.fromPromise(global.fetch(input || url, init), scheduler) 37 | response$.request = request 38 | response$$.onNext(response$) 39 | }, 40 | response$$.onError.bind(response$$), 41 | response$$.onCompleted.bind(response$$) 42 | ) 43 | response$$.byKey = byKey.bind(null, response$$) 44 | response$$.byUrl = byUrl.bind(null, response$$) 45 | return response$$ 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import Rx from 'rx' 3 | import { parse as parseUrl } from 'url' 4 | import { makeFetchDriver } from '../src' 5 | 6 | const { onNext } = Rx.ReactiveTest 7 | let originalFetch, fetches 8 | 9 | function compareMessages (t, actual, expected) { 10 | t.equal(actual.length, expected.length, 'messages should be same length') 11 | expected.forEach((message, i) => { 12 | t.ok( 13 | Rx.internals.isEqual(actual[i], message), 14 | 'message should be equal' 15 | ) 16 | }) 17 | } 18 | 19 | function mockFetch (input, init) { 20 | const url = input.url || input 21 | const resource = parseUrl(url).pathname.replace('/', '') 22 | fetches.push(Array.prototype.slice.apply(arguments)) 23 | return Promise.resolve({ 24 | url, 25 | status: 200, 26 | statusText: 'OK', 27 | ok: true, 28 | data: resource 29 | }) 30 | } 31 | 32 | function setup () { 33 | fetches = [] 34 | } 35 | 36 | test('before', t => { 37 | originalFetch = global.fetch 38 | global.fetch = mockFetch 39 | t.end() 40 | }) 41 | 42 | test('makeFetchDriver', t => { 43 | setup() 44 | const fetchDriver = makeFetchDriver() 45 | t.ok(typeof fetchDriver === 'function', 'should return a function') 46 | t.end() 47 | }) 48 | 49 | test('fetchDriver', t => { 50 | setup() 51 | const url = 'http://api.test/resource' 52 | const fetchDriver = makeFetchDriver() 53 | const request$ = Rx.Observable.just({ url }) 54 | fetchDriver(request$) 55 | .mergeAll() 56 | .toArray() 57 | .subscribe( 58 | responses => { 59 | t.equal(responses.length, 1) 60 | const response = responses[0] 61 | t.equal(response.url, url) 62 | t.equal(fetches.length, 1, 'should call fetch once') 63 | t.deepEqual(fetches[0], [ 'http://api.test/resource', undefined ], 64 | 'should call fetch with url and no options') 65 | t.end() 66 | }, 67 | t.error 68 | ) 69 | }) 70 | 71 | test('fetchDriver should support multiple requests', t => { 72 | setup() 73 | const scheduler = new Rx.TestScheduler() 74 | const request1 = 'http://api.test/resource1' 75 | const request2 = 'http://api.test/resource2' 76 | const requests = [ 77 | { ticks: 300, value: request1 }, 78 | { ticks: 400, value: request2 }, 79 | { ticks: 500, value: request1 } 80 | ] 81 | const responses = requests.map(request => ( 82 | { ticks: request.ticks + 120, value: request.value.split('/').pop() } 83 | )) 84 | const requestMessages = requests.map(request => onNext(request.ticks, request.value)) 85 | const request$ = scheduler.createHotObservable(...requestMessages) 86 | const oldFetch = global.fetch 87 | global.fetch = (url, init) => { 88 | const response = responses.shift() 89 | return scheduler.createResolvedPromise(response.ticks, response.value) 90 | } 91 | const fetchDriver = makeFetchDriver(scheduler) 92 | const { messages } = scheduler.startScheduler(() => ( 93 | fetchDriver(request$) 94 | .mergeAll() 95 | )) 96 | compareMessages(t, messages, [ 97 | onNext(421, 'resource1'), 98 | onNext(521, 'resource2'), 99 | onNext(621, 'resource1') 100 | ]) 101 | global.fetch = oldFetch 102 | t.end() 103 | }) 104 | 105 | test('fetchDriver should support string requests', t => { 106 | setup() 107 | const fetchDriver = makeFetchDriver() 108 | const request1 = 'http://api.test/resource1' 109 | fetchDriver(Rx.Observable.just(request1)) 110 | .byKey(request1) 111 | .mergeAll() 112 | .toArray() 113 | .subscribe( 114 | responses => { 115 | t.equal(responses.length, 1) 116 | responses.forEach(response => { 117 | t.equal(response.data, 'resource1', 'should return resource1') 118 | }) 119 | t.end() 120 | } 121 | ) 122 | }) 123 | 124 | test('fetchDriver should support Request object', t => { 125 | setup() 126 | const fetchDriver = makeFetchDriver() 127 | const request1 = { 128 | url: 'http://api.test/resource1' 129 | } 130 | fetchDriver(Rx.Observable.just({ input: request1 })) 131 | .byKey(request1.url) 132 | .mergeAll() 133 | .toArray() 134 | .subscribe( 135 | responses => { 136 | t.equal(responses.length, 1) 137 | responses.forEach(response => { 138 | t.equal(response.data, 'resource1', 'should return resource1') 139 | }) 140 | t.end() 141 | } 142 | ) 143 | }) 144 | 145 | test('fetchDriver should support multiple subscriptions', t => { 146 | function checkFetchCount () { 147 | t.equal(fetches.length, 1, 'should call fetch once') 148 | if (++checkCount === 2) t.end() 149 | } 150 | setup() 151 | let checkCount = 0 152 | const url = 'http://api.test/resource' 153 | const fetchDriver = makeFetchDriver() 154 | const request$ = Rx.Observable.just({ url }) 155 | const responses$ = fetchDriver(request$) 156 | .mergeAll() 157 | .toArray() 158 | responses$ 159 | .subscribe(checkFetchCount, t.error) 160 | responses$ 161 | .subscribe(checkFetchCount, t.error) 162 | }) 163 | 164 | test('byUrl should support request url', t => { 165 | setup() 166 | const request1 = { url: 'http://api.test/resource1', key: 'resource1' } 167 | const request2 = { url: 'http://api.test/resource2', key: 'resource2' } 168 | const fetchDriver = makeFetchDriver() 169 | const request$ = Rx.Observable.of(request1, request2) 170 | fetchDriver(request$) 171 | .byUrl(request2.url) 172 | .mergeAll() 173 | .toArray() 174 | .subscribe( 175 | responses => { 176 | t.equal(responses.length, 1) 177 | t.equal(responses[0].data, 'resource2') 178 | t.end() 179 | }, 180 | t.error 181 | ) 182 | }) 183 | 184 | test('byUrl should support input url', t => { 185 | setup() 186 | const request1 = { input: { url: 'http://api.test/resource1' }, key: 'resource1' } 187 | const request2 = { input: { url: 'http://api.test/resource2' }, key: 'resource2' } 188 | const fetchDriver = makeFetchDriver() 189 | const request$ = Rx.Observable.of(request1, request2) 190 | fetchDriver(request$) 191 | .byUrl(request2.input.url) 192 | .mergeAll() 193 | .toArray() 194 | .subscribe( 195 | responses => { 196 | t.equal(responses.length, 1) 197 | t.equal(responses[0].data, 'resource2') 198 | t.end() 199 | }, 200 | t.error 201 | ) 202 | }) 203 | 204 | test('after', t => { 205 | global.fetch = originalFetch 206 | t.end() 207 | }) 208 | --------------------------------------------------------------------------------