Reputation: 139
Background:
i am using epics for managing requests.
for every request i send a token, that may expire, but can be refreshed within a grace period.
i am using the token for every request, but before sending any request i want to check if the token is expired or not, and if expired and with grace period, then first refresh the token and then proceed with the corresponding action
all the requests have their own epics.
Now what i trying is a pre hook on all actions to check token possibly refresh it and then proceed with actions.
Hope this explains.
// epics for querying data
// these epics are using a token, that is stored in redux state.
const getMenuEpic = action$ => ....
const getFoodListEpic = action$ => ....
const getFoodItemEpic = action$ => ....
...
// helper function to check
// if token has expired
const hasTokenExpired = (token) => .....
// refresh token
// this returns a new token in the promise
const refreshToken = fetch('http://.../refresh-toekn')
// i am trying to make an epic, that will fire
// before any actions in the application
// stop the action (let's call it action A)
// get token from redux state,
// verify if is valid or not
// if invalid call refresh token (async process),
// and when refresh token finished, proceed with the incoming action A
// if the token was valid then continue with action A.
const refreshEpic = (action$, store) =>
action$.map(() => store.getState().token)
.filter(Boolean)
.filter(token => hasTokenExpired(token))
.mergeMap(() => refreshToken()) ...
......
but this approach is not working for refreshEpic
Upvotes: 1
Views: 2416
Reputation: 15401
It's not possible to truly prevent an action from reaching your reducers--it's actually already been through them before it's given to your epics--instead you can dispatch an action which signals the intent to fetch but isn't actually what triggers it. e.g. UI dispatches FETCH_SOMETHING and an epic sees it, confirms there's a valid refresh token (or gets a new one), then emits another action to actually trigger the fetch e.g. FETCH_SOMETHING_WITH_TOKEN.
In this particular case though you'll likely have many epics with the same requirement and it could get tedious to do it that way. There are many ways to make this easier. Here are a couple:
You could write a helper which does the checking for you and if it needs a refresh it will request and wait for it before proceeding. I would personally handle the actual refresh in separate dedicated epic so that you prevent multiple concurrent requests to refresh and other things like that.
const requireValidToken = (action$, store, callback) => {
const needsRefresh = hasTokenExpired(store.getState().token);
if (needsRefresh) {
return action$.ofType(REFRESH_TOKEN_SUCCESS)
.take(1)
.takeUntil(action$.ofType(REFRESH_TOKEN_FAILED))
.mergeMap(() => callback(store.getState().token))
.startWith({ type: REFRESH_TOKEN });
} else {
return callback(store.getState().token);
}
};
const getMenuEpic = (action$, store) =>
action$.ofType(GET_MENU)
.switchMap(() =>
requireValidToken(action$, store, token =>
actuallyGetMenu(token)
.map(response => getMenuSuccess(response))
)
);
EDIT: This was my original suggestion but it's much more complicated than the one above. It has some benefits too, but IMO the one above will be easier to use and maintain.
You could instead create a "super-epic", an epic which itself composes and delegates to other epics. The root epic is an example of a super-epic. (I just made up that term right now...lol)
One thing we'll probably want to do is differentiate between any random action and ones which require an auth token--you don't want to check for the auth token and refresh it for every single action ever dispatched. A simple way would be to include some metadata in the action, like { meta: { requiresAuth: true } }
This is much more complex, but has pros over the other solution as well. Here's a rough idea of what I'm talking about, but it's untested and probably not 100% thought-out. Consider it inspiration rather than copy-pasta.
// action creator helper to add the requiresAuth metadata
const requiresAuth = action => ({
...action,
meta: {
...action.meta,
requiresAuth: true
}
});
// action creators
const getMenu = id => requiresAuth({
type: 'GET_MENU',
id
});
const getFoodList = () => requiresAuth({
type: 'GET_FOOD_LIST'
});
// epics
const getMenuEpic = action$ => stuff
const getFoodListEpic = action$ => stuff
const refreshTokenEpic = action$ =>
action$.ofType(REFRESH_TOKEN)
// If there's already a pending refreshToken() we'll ignore the new
// request to do it again since its redundant. If you instead want to
// cancel the pending one and start again, use switchMap()
.exhaustMap(() =>
Observable.from(refreshToken())
.map(response => ({
type: REFRESH_TOKEN_SUCCESS,
token: response.token
}))
// probably should force user to re-login or whatevs
.catch(error => Observable.of({
type: REFRESH_TOKEN_FAILED,
error
}))
);
// factory to create a "super-epic" which will only
// pass along requiresAuth actions when we have a
// valid token, refreshing it if needed before.
const createRequiresTokenEpic = (...epics) => (action$, ...rest) => {
// The epics we're delegating for
const delegatorEpic = combineEpics(...epics);
// We need some way to emit REFRESH_TOKEN actions
// so I just hacked it with a Subject. There is
// prolly a more elegant way to do this but #YOLO
const output$ = new Subject();
// This becomes action$ for all the epics we're delegating
// for. This will hold off on giving an action to those
// epics until we have a valid token. But remember,
// this doesn't delay your *reducers* from seeing it
// as its already been through them!
const filteredAction$ = action$
.mergeMap(action => {
if (action.meta && action.meta.requiresAuth) {
const needsRefresh = hasTokenExpired(store.getState().token);
if (needsRefresh) {
// Kick off the refreshing of the token
output$.next({ type: REFRESH_TOKEN });
// Wait for a successful refresh
return action$.ofType(REFRESH_TOKEN_SUCCESS)
.take(1)
.mapTo(action)
.takeUntil(action$.ofType(REFRESH_TOKEN_FAILED));
// Its wise to handle the case when refreshing fails.
// This example just gives up and never sends the
// original action through because presumably
// this is a fatal app state and should be handled
// in refreshTokenEpic (.e.g. force relogin)
}
}
// Actions which don't require auth are passed through as-is
return Observable.of(action);
});
return Observable.merge(
delegatorEpic(filteredAction$, ...rest),
output$
);
};
const requiresTokenEpic = createRequiresTokenEpic(getMenuEpic, getFoodList, ...etc);
As mentioned, there are many ways to approach this problem. I can invision creating some sort of helper function you use inside your epics that require tokens instead of this "super-epic" approach. Do what seems less complicated for you.
Upvotes: 7