Repository: jackfranklin/remote-data-js Branch: master Commit: 5f0f8445c3d5 Files: 15 Total size: 19.8 KB Directory structure: gitextract_8910mnw4/ ├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── __tests__/ │ ├── from-promise-test.js │ └── remote-data-test.js ├── package.json ├── prettier.config.js └── src/ ├── index.js ├── request.js └── states.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .babelrc ================================================ { "presets": ["es2015"], "plugins": ["transform-object-rest-spread"] } ================================================ FILE: .eslintrc.js ================================================ module.exports = { extends: ['unobtrusive', 'prettier'], plugins: ['prettier'], parser: 'babel-eslint', env: { browser: true, jest: true, es6: true, }, rules: { 'prettier/prettier': 'error', }, } ================================================ FILE: .gitignore ================================================ lib/ ================================================ FILE: .npmignore ================================================ ================================================ FILE: .travis.yml ================================================ language: node_js node_js: - "8" ================================================ FILE: CHANGELOG.md ================================================ ### 0.2.1 - 5th Sept 2016 - ensure `onChange` is kept when a new instance is created ### 0.2.0 - 5th Sept 2016 - `case` statement support - tests refactored to use Jest ### 0.1.0 - 11th June 2016 - Initial "beta" release ================================================ FILE: LICENSE.md ================================================ The MIT License (MIT) Copyright (c) 2016 Jack Franklin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # RemoteData.js Inspired by Kris Jenkins' [RemoteData](http://package.elm-lang.org/packages/krisajenkins/elm-exts/25.1.0/Exts-RemoteData) Elm package, this library provides an object for representing remote data in your application. [![Build Status](https://travis-ci.org/jackfranklin/remote-data-js.svg?branch=master)](https://travis-ci.org/jackfranklin/remote-data-js) ``` npm install --save remote-data-js ``` ## Motivations By representing the data and the state of the request in one object it becomes impossible for you to have data that's out of sync. A typical app might model the data as: ``` { loading: true, data: undefined } ``` And then update the values when the request succeeds. However, this really is one piece of information that is now represented across two keys, and as such it can become out of sync. Instead, `RemoteData` models both the _request_ and the _data_ in one object, so they can never be out of sync with each other. A `RemoteData` instance has one of four states: * `NOT_ASKED` - you've got started the request yet * `PENDING` - the request is in flight * `SUCCESS` - we have data from the request * `FAILURE` - the request went wrong, we have an error for it You can check the status of a `RemoteData` instance and therefore represent data in your application accordingly. Additionally, `RemoteData` instances are _never_ mutated, but pass a new version of themselves through callbacks. This means any mutation bugs with rendering off your remote data instances are not a concern, and that this library can play nicely with React, Redux and others. ## Example ```js import RemoteData from 'remote-data' const githubPerson = new RemoteData({ url: username => `https://api.github.com/users/${username}`, onChange: remoteData => console.log('State changed!', remoteData), }) // then later on githubPerson .fetch('jackfranklin') .then(remoteData => { // it worked fine console.log(remoteData.isSuccess()) // true console.log(remoteData.data) // github api data console.log(remoteData.response.status) // 200 }) .catch(remoteData => { // something went wrong console.log(remoteData.isSuccess()) // false console.log(remoteData.isFailure()) // true console.log(remoteData.data) // error info console.log(remoteData.response.status) // response status code }) ``` ## API ### Creating `RemoteData` instances The configuration you can provide when creating a new instance of RemoteData are as follows: ```js const instance = new RemoteData({ url: (name) => `https://api.github.com/users/${username}` onChange: (newInstance) => {...}, parse: (response) => response.json, fetchOptions: {} }); ``` These are fully documented below: * `url: String | Function`: if given a string, this will be the URL that the request is made to. If it's a function it will be called when `fetch` is called, passing any arguments through. For example, `remoteData.fetch('jack')` will call the `url` function, passing `jack` as the argument. * `onChange: Function`: a function called whenever the state of a remote data instance changes. This is passed in the new RemoteData instance. * `parse: Function`: a function used to parse the `Response` from the HTTP request. Defaults to `response.json()`. * `fetchOptions: Object`: an object that is passed through to `fetch` and allows you to configure headers and any other request options. ### Making Requests To make a request, call `fetch` on the `RemoteData` instance: ```js const githubPerson = new RemoteData({ url: name => `https://api.github.com/users/${username}`, onChange: newGithubPerson => console.log(newGithubPerson), }) githubPerson.fetch('jackfranklin') ``` A promise is returned and the value it will resolve to is the new `RemoteData` instance: ```js githubPerson.fetch('jackfranklin').then(newData => { console.log(newData.data) // GitHub API data, parsed from JSON console.log(newData.response.status) // status code console.log(newData.state) // 'SUCCESS' }) ``` ### Checking the status of a request You can call any of the following methods: * `isFinished()` : true if a request has succeeded or failed. * `isNotAsked()` : true if the request hasn't been asked for (this is the default state). * `isPending()` : true if the request has been started but is pending * `isFailure()` : true if the request has failed * `isSuccess()` : true if the request has succeeded You can "switch" on a RemoteData instance's state similarly to functional languages and the JavaScript [Union Type](https://www.npmjs.com/package/union-type) package: ```js githubPerson.fetch('jackfranklin').then(data => { const message = data.case({ NotAsked: () => 'Initializing...', Pending: () => 'Loading...', Success: data => renderData(data), Failure: error => renderError(error), }) }) ``` If you don't handle all four possible states, you must include a default handler named `_` (underscore): ```js githubPerson.fetch('jackfranklin').then(data => { const message = data.case({ Success: data => renderData(data), Failure: error => renderError(error), _: () => 'Loading...', }) }) ``` You can call `.data` on a request to access the data, but be aware that this _will throw an error_ if the request hasn't been asked for or is pending. You can call `.response` on a request to access the response, but be aware that this _will throw an error_ if the request hasn't been asked for or is pending. ## Making remote data instances from a promise. Let's say you have your own custom API library in your app for making API requests that returns promises. In this instance, you don't want to use RemoteData's own `fetch` based API to initiate the request, but instead you want to wrap your promise in a `RemoteData` instance: ```js import { fromPromise } from 'remote-data-js' import myCustomApiRequestLib from 'my-custom-lib' const onChange = newRemoteData => {...} const apiRequest = myCustomApiRequestLib('/foo') const remoteDataInstance = fromPromise(apiRequest, { onChange }) remoteDataInstance.isPending() // => true ``` ================================================ FILE: __tests__/from-promise-test.js ================================================ import { fromPromise } from '../src/index' it('can be constructed from a promise and be in the loading state', () => { const onChange = () => {} const prom = new Promise(resolve => resolve({ success: true })) const remoteData = fromPromise(prom, { onChange }) expect(remoteData.isPending()).toBe(true) }) it('gives the data back when it succeeds', done => { const onChange = res => { expect(res.isSuccess()).toEqual(true) expect(res.data).toEqual({ success: true }) done() } const prom = new Promise(resolve => resolve({ success: true })) const remoteData = fromPromise(prom, { onChange }) expect(remoteData.isPending()).toBe(true) }) it('gives the error back when it fails', done => { const onChange = res => { expect(res.isFailure()).toEqual(true) expect(res.data).toEqual({ error: true }) done() } /* eslint-disable prefer-promise-reject-errors */ const prom = new Promise((resolve, reject) => reject({ error: true })) /* eslint-enable prefer-promise-reject-errors */ fromPromise(prom, { onChange }) }) ================================================ FILE: __tests__/remote-data-test.js ================================================ import RemoteData from '../src/index' import fetchMock from 'fetch-mock' const mockSuccess = url => fetchMock.mock(url, { success: true }) const mockError = url => fetchMock.mock(url, 404) const resetAndMockSuccess = url => { fetchMock.restore() mockSuccess(url) } const resetAndMockError = url => { fetchMock.restore() mockError(url) } const resetAndMockWithResponse = (url, response) => { fetchMock.restore() fetchMock.mock(url, response) } const makeInstance = (obj = {}) => { const args = Object.assign( {}, { url: 'api.com/1', }, obj ) return new RemoteData(args) } it('Is NOT_ASKED by default', () => { expect(new RemoteData().isNotAsked()).toBe(true) }) describe('defining the URL', () => { it('uses a url if it is given one', () => { resetAndMockSuccess('api.com/1') const instance = makeInstance({ url: x => `api.com/${x}`, }) return instance.fetch('1').then(() => { expect(fetchMock.called('api.com/1')).toBe(true) }) }) it('uses a string if given a string', () => { resetAndMockSuccess('api.com/1') const instance = makeInstance() return instance.fetch('1').then(() => { expect(fetchMock.called('api.com/1')).toBe(true) }) }) }) it('calls onChange twice when there is a successful request', () => { resetAndMockSuccess('api.com/1') const onChange = jest.fn() const instance = makeInstance({ onChange }) return instance.fetch().then(() => { expect(onChange.mock.calls[0][0].isPending()).toBe(true) expect(onChange.mock.calls[1][0].isSuccess()).toBe(true) }) }) it('calls onChange twice for a failed request', () => { resetAndMockError('api.com/1') const onChange = jest.fn() const instance = makeInstance({ onChange }) return instance.fetch().then(() => { expect(onChange.mock.calls[0][0].isPending()).toBe(true) expect(onChange.mock.calls[1][0].isFailure()).toBe(true) }) }) it('parses as JSON by default', () => { resetAndMockWithResponse('api.com/1', { some: 'json' }) const instance = makeInstance() return instance.fetch().then(result => { expect(result.data).toEqual({ some: 'json' }) }) }) it('allows a custom parser function to be provided', () => { resetAndMockWithResponse('api.com/1', 'Hello World') const instance = makeInstance({ parse: x => x.text() }) return instance.fetch().then(result => { expect(result.data).toEqual('Hello World') }) }) it('provides `response` to allow access to the raw HTTP response', () => { resetAndMockWithResponse('api.com/1', { some: 'json' }) const instance = makeInstance() return instance.fetch().then(result => { expect(result.data).toEqual({ some: 'json' }) expect(result.response.status).toEqual(200) }) }) it('throws if you access the response when the request is not finished', () => { const instance = makeInstance() expect(() => instance.response).toThrowError( /Cannot get response for request that hasn't finished/ ) }) it('throws if you try to access the data before it has been fetched', () => { const instance = makeInstance() expect(() => instance.data).toThrowError( /Cannot get data for request that hasn't finished/ ) }) describe('the case method', () => { it('first calls the NotAsked callback', () => { const instance = makeInstance() const notAsked = jest.fn() const otherFn = jest.fn() instance.case({ NotAsked: notAsked, Pending: otherFn, Failure: otherFn, Success: otherFn, }) expect(notAsked).toBeCalled() expect(otherFn).not.toBeCalled() }) it('calls the success callback when data has been fetched', () => { resetAndMockSuccess('api.com/1') const instance = makeInstance() const successFn = jest.fn() const otherFn = jest.fn() return instance.fetch().then(result => { result.case({ NotAsked: otherFn, Pending: otherFn, Failure: otherFn, Success: successFn, }) expect(successFn).toBeCalledWith({ success: true }) expect(otherFn).not.toBeCalled() }) }) it('calls the pending callback when the request is in motion', () => { resetAndMockSuccess('api.com/1') const pendingFn = jest.fn() const otherFn = jest.fn() let count = 0 const instance = new RemoteData({ url: 'api.com/1', onChange(remoteData) { if (count++ > 0) return remoteData.case({ NotAsked: otherFn, Pending: pendingFn, Failure: otherFn, Success: otherFn, }) expect(pendingFn).toBeCalled() expect(otherFn).not.toBeCalled() }, }) return instance.fetch() }) it('calls the failure callback when the request failed', () => { resetAndMockError('api.com/1') const instance = makeInstance() const failedFn = jest.fn() const otherFn = jest.fn() return instance.fetch().then(result => { result.case({ NotAsked: otherFn, Pending: otherFn, Failure: failedFn, Success: otherFn, }) expect(failedFn).toBeCalled() expect(otherFn).not.toBeCalled() }) }) it('will call the default callback if none match', () => { resetAndMockError('api.com/1') const handler = jest.fn() const instance = makeInstance() instance.case({ _: handler, }) expect(handler).toBeCalled() }) it('copies the onChange event over when a new remote data instance is created', () => { const onChange = jest.fn() const instance = makeInstance({ onChange, }) resetAndMockSuccess('api.com/1') return instance.fetch().then(newInstance => { expect(onChange.mock.calls.length).toBe(2) expect(newInstance.onChange).toBe(onChange) }) }) }) ================================================ FILE: package.json ================================================ { "name": "remote-data-js", "version": "0.2.1", "description": "", "main": "lib/index.js", "scripts": { "build-cjs": "babel src -d lib", "prepack": "npm run build-cjs", "jest": "jest", "test": "npm run lint && npm run jest", "watch": "npm run jest --watch", "lint": "eslint src/*.js __tests__/*.js", "lint-fix": "npm run lint -- --fix" }, "keywords": [], "author": "Jack Franklin", "license": "MIT", "devDependencies": { "babel-cli": "6.9.0", "babel-core": "^6.6.0", "babel-eslint": "^8.0.3", "babel-jest": "^22.0.1", "babel-plugin-transform-object-rest-spread": "6.8.0", "babel-polyfill": "6.13.0", "babel-preset-es2015": "^6.6.0", "babel-register": "6.9.0", "eslint": "^4.11.0", "eslint-config-prettier": "^2.7.0", "eslint-config-standard": "^10.2.1", "eslint-config-unobtrusive": "^1.2.1", "eslint-plugin-import": "^2.8.0", "eslint-plugin-node": "^5.2.1", "eslint-plugin-prettier": "^2.4.0", "eslint-plugin-promise": "^3.6.0", "eslint-plugin-standard": "^3.0.1", "fetch-mock": "4.5.4", "jest": "^21.2.1", "prettier": "^1.8.2", "webpack-notifier": "1.3.0" } } ================================================ FILE: prettier.config.js ================================================ module.exports = { semi: false, trailingComma: 'es5', singleQuote: true, proseWrap: 'always', } ================================================ FILE: src/index.js ================================================ import { NOT_ASKED, PENDING, FAILURE, SUCCESS } from './states' import { makeFetchRequest } from './request' class RemoteData { constructor({ url, state = NOT_ASKED, onChange = () => {}, parse = x => x.json(), fetchOptions = {}, rawResponse, stateData, } = {}) { this.state = state this.url = url this.onChange = onChange this.parse = parse this.fetchOptions = fetchOptions this.stateData = stateData this.rawResponse = rawResponse } config() { const keys = [ 'onSuccess', 'onFailure', 'parse', 'fetchOptions', 'state', 'url', 'onChange', ] const config = {} keys.forEach(k => { config[k] = this[k] }) return config } // the default implementations here call through to the _ function // which is called if the user does not provide a function /* eslint-disable no-use-before-define */ case({ NotAsked = () => _(), Pending = () => _(), Failure = (...args) => _(...args), Success = (...args) => _(...args), _ = () => {}, }) { /* eslint-enable no-use-before-define */ switch (this.state) { case NOT_ASKED: return NotAsked() case PENDING: return Pending() case FAILURE: return Failure(this.stateData) case SUCCESS: return Success(this.stateData) } } isFinished() { return this.isFailure() || this.isSuccess() } isPending() { return this.state === PENDING } isNotAsked() { return this.state === NOT_ASKED } isFailure() { return this.state === FAILURE } isSuccess() { return this.state === SUCCESS } get data() { if (this.isFinished()) { return this.stateData } else { throw new Error("Cannot get data for request that hasn't finished") } } get response() { if (this.isFinished()) { return this.rawResponse } else { throw new Error("Cannot get response for request that hasn't finished") } } makeNewAndOnChange(opts = {}) { const newRemoteData = new this.constructor({ ...this.config(), ...opts, }) this.onChange(newRemoteData) return newRemoteData } fetch(...args) { const reqUrl = typeof this.url === 'function' ? this.url(...args) : this.url this.makeNewAndOnChange({ state: PENDING }) return makeFetchRequest(this, reqUrl) } } export const fromPromise = (promise, { onChange = () => {} } = {}) => { const instance = new RemoteData({ onChange, state: PENDING }) promise.then( result => { return instance.makeNewAndOnChange({ state: SUCCESS, stateData: result, }) }, error => { return instance.makeNewAndOnChange({ state: FAILURE, stateData: error, }) } ) return instance } export default RemoteData ================================================ FILE: src/request.js ================================================ import { FAILURE, SUCCESS } from './states' const checkStatus = response => { if (response.status >= 200 && response.status < 300) { return response } else { const error = new Error(response.statusText) error.response = response throw error } } const dataResponse = (remoteDataInstance, rawResponse, data, isError) => { return remoteDataInstance.makeNewAndOnChange({ state: isError ? FAILURE : SUCCESS, stateData: data, rawResponse, }) } const successfulResponse = (remoteDataInstance, rawResponse, data) => { return dataResponse(remoteDataInstance, rawResponse, data, false) } const failureResponse = (remoteDataInstance, rawResponse, data) => { return dataResponse(remoteDataInstance, rawResponse, data, true) } const parseAndKeepResponse = parseFn => result => { return Promise.all([ Promise.resolve(result), Promise.resolve(parseFn(result)), ]) } export const makeFetchRequest = (remoteDataInstance, url) => { return fetch(url, remoteDataInstance.fetchOptions) .then(checkStatus) .then(parseAndKeepResponse(remoteDataInstance.parse)) .then(([rawResponse, data]) => successfulResponse(remoteDataInstance, rawResponse, data) ) .catch(error => failureResponse(remoteDataInstance, error.response, error)) } ================================================ FILE: src/states.js ================================================ export const NOT_ASKED = 'REMOTE_DATA_NOT_ASKED' export const PENDING = 'REMOTE_DATA_PENDING' export const FAILURE = 'REMOTE_DATA_FAILURE' export const SUCCESS = 'REMOTE_DATA_SUCCESS'