StudioKit Net Library
A library for declarative, configurable data (API) access with built-in retry, periodic refresh, and concurrency handling.
For in-vivo examples of how to use this library, see the example react app and the example react native app
Installation
Install this library and redux-saga as a dependency
yarn add studiokit-net-js
-
yarn add redux-saga
(which depends onredux
itself) - Create a
reducers.js
module that includes the reducer from this library, i.e.import { combineReducers } from 'redux' import { reducers as netReducers } from 'studiokit-net-js' export default combineReducers({ models: netReducers.fetchReducer })
- Create an
endpointMappings.js
module specifying a mapping of any APIs you will call in your application. All configuration properties are set under_config
. Fetch request specific default properties are set on_config.fetch
, i.e.const endpointMappings = { publicData: { _config: { fetch: { path: 'https://httpbin.org/get', queryParams: { foo: 'bar' } } } } } export default endpointMappings
- Create a
rootSaga.js
module that includes the fetchSaga from this library, i.e.import { all } from 'redux-saga/effects' import { sagas as netSagas } from 'studiokit-net-js' import endpointMappings from '../../endpointMappings' export default function* rootSaga() { yield all({ fetchSaga: netSagas.fetchSaga( endpointMappings, 'https://yourapp.com' ) }) }
- Wire up your store in your app (perhaps in
index.js
) with the above, i.e.import createSagaMiddleware from 'redux-saga' import { createStore, applyMiddleware } from 'redux' import reducer from './redux/reducers' import rootSaga from './redux/sagas/rootSaga' const sagaMiddleware = createSagaMiddleware() const store = createStore( reducer, applyMiddleware(sagaMiddleware) ) sagaMiddleware.run(rootSaga)
Usage
Once you have the above steps completed, you can dispatch actions to the store and the data will be fetched and populated in the redux store, i.e.
import { dispatchAction } from '../services/actionService'
import { actions as netActions } from 'studiokit-net-js'
.
.
.
store.dispatch({ type: netActions.DATA_REQUESTED, modelName: 'publicData' })
Once the data is fetched, it will live in the redux store at the models.publicData key, i.e.
models: {
publicData: {
data: { foo: 'bar', baz: ['quux', 'fluux']},
isFetching: false,
hasError: false,
fetchedAt: "2017-05-23T20:38:11.103Z"
}
}
API
Actions are dispatched using the following keys in the action object for configuring the request
type FetchAction = {
modelName: string,
guid?: string
method?: string,
headers?: Object,
queryParams?: Object,
pathParams?: Object,
noStore?: boolean,
period?: number,
taskId?: string,
noRetry?: boolean
contentType?: string
}
-
modelName
refers to the path to the fetch configuration key found inendpointMappings.js
-
guid
is an optional pre-generated (by your application) GUID that will be attached to a fetch result's data, to be stored in redux and used to match request results in components -
method
is an optional string used as the HTTP Method for the fetch. Otherwise will use the method set inendpointMappings.js
, or'GET'
-
headers
is an optional object used as key/value pairs to populate the request headers -
queryParams
is an optional object used as key/value pairs to populate the query parameters -
pathParams
is an optional array of values to be replaced in the fetch path using pattern matching, in order, e.g.[1, 2]
and/collection/{:id}/subcollection/{:id}
=>/collection/1/subcollection/2
-
noStore
is an optional boolean that, if true, indicates the request should be made without storing the response in the redux store -
period
is an optional number of milliseconds after which a request should repeat when dispatching a recurring fetch -
taskId
is a string that must be passed to a recurring fetch for future cancellation -
noRetry
will prevent the use of the default logarithmic backoff retry strategy
The following actions can be dispatched
-
DATA_REQUESTED
: This will fetch the data specified at themodelName
key of the action -
PERIODIC_DATA_REQUESTED
: This will fetch the data specified at themodelName
key at an interval specified by theperiod
key in the action. This also requires you to generate and pass ataskId
key for subsequent cancellation -
PERIODIC_TERMINATION_REQUESTED
: This will cause the periodic fetch identified bytaskId
to be cancelled -
DATA_REQUESTED_USE_LATEST
: This will fetch data specified at themodelName
key, using only the latest result in time if multiple requests are dispatched at the same time (i.e. others are started with the samemodelName
before some are completed)
Examples
Given the following endpointMappings.js
{
basicData: {
_config: {
fetch: {
path: 'https://httpbin.org/get'
}
}
},
futurama: {
_config: {
fetch: {
path: 'https://www.planetexpress.com/api/goodNewsEveryone',
queryParams: {
doctor: 'zoidberg'
}
}
}
},
theWalkers: {
_config: {
fetch: {
path: 'https://thewalkingdead/api/walker/{:walkerId}',
pathParams: {
walkerId: 1
}
}
}
}.
theOffice: {
_config: {
fetch: {
path: 'https://dundermifflin.com/api/paper'
headers: {
'Content-Type': 'x-beet-farmer'
}
}
}
},
aGrouping: {
apiOne: {
_config: {
fetch: {
path: '/api/one'
}
}
},
apiTwo: {
_config: {
fetch: {
path: '/api/two/{{models.futurama.zoidberg}}'
}
}
}
},
basicPost: {
_config: {
fetch: {
path: '/api/createSomeThing'
method: 'POST'
}
}
},
basicPostTwo: {
_config: {
fetch: {
path: '/api/createSomeKnownThing'
method: 'POST',
body: { person: 'Fry' }
}
}
},
entities: {
_config: {
fetch: {
path: '/api/entities'
},
isCollection: true
}
},
topLevelEntities: {
_config: {
isCollection: true
},
secondLevelEntities: {
_config: {
isCollection: true
}
}
}
}
You can make the following types of requests:
Basic Fetch
Nested Model
Add Headers
Add Query Params
Periodic Fetch
Cancel Periodic Fetch
No Store
Post
Collections
Nested Collections
Basic fetch:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicData'
})
request generated
GET https://httpbin.org/get
resulting redux
{
models: {
basicData: {
foo: 'bar',
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
}
}
}
Nested model:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'aGrouping.apiOne'
})
request generated
GET https://myapp.com/api/one
resulting redux
{
models: {
aGrouping: {
apiOne: {
foo: 'bar',
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
}
}
}
}
Add headers:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicData',
headers: {'Accept-Charset': 'utf-8'}
})
request generated
Accept-Charset: utf-8
GET https://httpbin.org/get
resulting redux
Same as basic fetch above, with possibly different data, depending on response relative to additional header
Note: Headers specified in the action will be merged with headers specified in endpointMappings.js
with the headers in the action taking precedence
Add query params:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicData',
queryParams: {robot: 'bender'}
})
request generated
GET https://httpbin.org/get?robot=bender
resulting redux
Same as basic fetch above, with possibly different data, depending on response relative to new query params
Note: Query parameters specified in the action will be merged with query parameters specified in endpointMappings.js
with the query params in the action taking precedence
Add route params:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'theWalkers',
queryParams: {walkerId: 1}
})
request generated
GET https://thewalkingdead/api/walker/1
resulting redux
Same as basic fetch above, with possibly different data, depending on response relative to new route params
Note: Route parameters specified in the action will be merged with route parameters specified in endpointMappings.js
with the route params in the action taking precedence
Periodic fetch:
dispatch
store.dispatch({
type: netActions.PERIODIC_DATA_REQUESTED,
modelName: 'basicData',
period: 1000,
taskId: 'something-random'
})
request generated
GET https://httpbin.org/get
resulting redux
Same as basic fetch above, but refreshing every 1000ms, replacing the data
key in redux with new data and updating the fetchedAt
key
Cancel periodic fetch:
dispatch
store.dispatch({
type: netActions.PERIODIC_TERMINATION_REQUESTED,
modelName: 'basicData',
taskId: 'something-random'
})
request generated
None
resulting redux
Same as basic fetch above with data
and fetchedAt
reflecting the most recent fetch before the cancellation request
No store:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicData',
noStore: true
})
request generated
GET https://httpbin.org/get
resulting redux
No change to the redux store. Your application can create its own sagas and use take
and friends in redux-saga
, however, to watch for responses and cause side-effects
Post:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicPost',
body: {
ruleOne: "Don't talk about Fight Club",
ruleTwo: "Don't talk about Fight Club"
}
})
request generated
Content-Type: application/json
POST https://myapp.com/api/createSomeThing
{"ruleOne": "Don't talk about Fight Club","ruleTwo": "Don't talk about Fight Club"}
resulting redux
Same as basic fetch above, with the data
key containing the response data from the POST
request
Post with form data:
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'basicPost',
body: new FormData(),
contentType: 'multipart/form-data'
})
request generated
Content-Type: multipart/form-data; XXX boundary--------
POST https://myapp.com/api/createSomeThing
(formData values)
resulting redux
Same as basic fetch above, but with Content-Type equals to multipart/form-data
Collections
GET all
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'entities'
})
request generated
GET https://myapp.com/api/entities
resulting redux
{
models: {
entities: {
1: {
id: 1,
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
},
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
}
}
}
GET item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'entities',
pathParams: [1]
})
request generated
GET https://myapp.com/api/entities/1
resulting redux
Updates item in store at entities.1
POST item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'entities',
method: 'POST',
body: {
name: 'entity name'
}
})
request generated
Content-Type: application/json
POST https://myapp.com/api/entities/1
{"name": "entity name"}
resulting redux
Adds item in store at entities
under the return object's id
Note
During the request, status is stored in entities
under a guid
key, which can be provided in the action for tracking
PATCH item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'entities',
method: 'PATCH'
pathParams: [1],
body: {
op: 'replace',
path: 'Name',
value: 'updated group name'
}
})
request generated
Content-Type: application/json
PATCH https://myapp.com/api/entities/1
{"op": "replace", "path": "Name", "value": "updated group name"}
resulting redux
Updates item in store at entities.1
Note
See http://jsonpatch.com/
DELETE Entity
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'entities',
method: 'DELETE'
pathParams: [1]
})
request generated
DELETE https://myapp.com/api/entities/1
resulting redux
Removes item in store at entities.1
Nested Collections
Nested collections behave the same as normal collections, but require a pathParams
to have at least one value per nested level.
GET all
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'topLevelEntities.secondLevelEntities',
pathParams: [1]
})
request generated
GET https://myapp.com/api/topLevelEntities/1/secondLevelEntities
resulting redux
{
models: {
topLevelEntities: {
1: {
id: 1,
secondLevelEntities: {
999: {
id: 999,
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
},
...,
_metadata: {
isFetching: false,
hasError: false,
fetchedAt: '2017-05-23T20:38:11.103Z'
}
}
}
}
}
}
Stores item in object as key/value pairs in store at topLevelEntities.1.secondLevelEntities
GET item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'topLevelEntities.secondLevelEntities',
pathParams: [1, 999]
})
request generated
GET https://myapp.com/api/topLevelEntities/1/secondLevelEntities/999
resulting redux
Updates item in store at topLevelEntities.1.secondLevelEntities.999
POST item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'topLevelEntities.secondLevelEntities',
pathParams: [1],
method: 'POST',
body: {
name: 'entity name'
}
})
request generated
Content-Type: application/json
POST https://myapp.com/api/topLevelEntities/1/secondLevelEntities
{"name": "entity name"}
resulting redux
Adds item in store at topLevelEntities.1.secondLevelEntities
under the return object's id
Note
During the request, status is stored in topLevelEntities.1.secondLevelEntities
under a guid
key, which can be provided in the action for tracking
PATCH item
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'topLevelEntities.secondLevelEntities',
method: 'PATCH'
pathParams: [1, 999],
body: {
op: 'replace',
path: 'Name',
value: 'updated group name'
}
})
request generated
Content-Type: application/json
PATCH https://myapp.com/api/topLevelEntities/1/secondLevelEntities/999
{"op": "replace", "path": "Name", "value": "updated group name"}
resulting redux
Updates item in store at topLevelEntities.1.secondLevelEntities.999
Note
See http://jsonpatch.com/
DELETE Entity
dispatch
store.dispatch({
type: netActions.DATA_REQUESTED,
modelName: 'topLevelEntities.secondLevelEntities',
method: 'DELETE'
pathParams: [1, 999]
})
request generated
DELETE https://myapp.com/api/topLevelEntities/1/secondLevelEntities/999
resulting redux
Removes item in store at topLevelEntities.1.secondLevelEntities.999
Development
During development of this library, you can clone this project and use
yarn link
to make the module available to another project's node_modules
on the same computer without having to publish to a repo and pull to the other project. In the other folder, you can use
yarn link studiokit-net-js
to add studiokit-foo-js
to the consuming project's node_modules
Build
Because this is a module, the source has to be transpiled to ES5 since the consuming project won't transpile anything in node_modules
yarn build
will transpile everything in /src
to /lib
. /lib/index.js
is the entry point indicated in package.json
During development, you can run
yarn build:watch
and babel will rebuild the /lib
folder when any file in /src
changes.
When you commit, a commit hook will automatically regenerate /lib
Deploy
This packaged is deployed via the npm repository. Until we add commit hooks for deployment, it must be published via yarn publish