Redux sagas for Google YOLO

I've been working for a bit lately to get into React. Initially, I had built the stack: webpack, React, Redux, etc, all by hand. At some point I got over confident, and thought I was ready to go into the deep end. At that point, I swapped over into react boilerplate to 'really' develop something. Huge points won me over, hot reloading, 'some' guidance on the way it should be configured, and community to draw from.

More a sign of my over confidence, I decided I'd just start by doing an integration with Google's YOLO (You Only Login Once). The first major shock I received was the brand new concept of Redux Sagas. For those unfamiliar, it might be best summarized as an event driven system, a remix of RXJS and other subscription systems. It took quite a while to learn the basics, because they had examples that didn't match up to what felt like real world examples. To that end, I contribute my own little attempt. It's an alpha version, but it does show the details in a state you should be easily able to understand.

A major reason I never threw up my hands and walked away from this 'not tangled' (but totally complex snarl) is that I'm learning that everything really boils down to the state. The state is your source of truth in ways I was never used to in Angular. If it doesn't go through a reducer, it doesn't exist. That makes sagas very attractive. They can access the auth credentials, refresh them if required, set the update states, and all the rest without over involving the rendering of the component. The entire async / background / 'generator' function can be a bit of a rabbit hole all its own. For official documentation check out Mozilla's JavaScript generator functions. For now, please let your head work the general abstraction that if you see yield, take (any variety), race, or function*, background threads are issued and might return a value when it's damn well ready.

A fair warning, after the App component is mounted, things begin jumping file to file, fast. There are many paths that branch back onto each other. The best way to think of it conceptually is this:

Overview of purpose

saga.js - App business logic - Handles boot logic
saga.googleYolo.js - Google YOLO logic - Handles boot logic only if credentials are not found
saga.api.js - Api logic - No boot logic

Making an api call

  1. // Inside your component's saga you can dispatch an api request action
  2. yield put(apiActions.apiUserDeleteRequestAction(user.username));
  3.  
  4. // You'll setup a race condition, waiting for success for failure
  5. // Note that the returned object deconstruction must match the object keys
  6. const { deleteAction, errorAction } = yield race({
  7.   deleteAction: take(API_USER_DELETE_REQUEST_SUCCESS),
  8.   errorAction: take(API_USER_DELETE_REQUEST_FAILURE),
  9. });
  10.  
  11. // Alert your component's *state* to the side effects. Your reducer is listening for this
  12. if (errorAction) {
  13.   yield put(actions.userDeleteFailureAction(errorAction.error));
  14. }
  15.  
  16. if (deleteAction) {
  17.   yield put(actions.userDeleteSuccessAction()); // This might well result in a redirect using something like: yield put(push('/auth'));
  18. }

Walkthrough of a single path

All this wouldn't be much use if I didn't provide a solid breakdown of how the thing functions. While there are multiple happy paths, really it's kind of all happy paths due to the handle of failures race() conditions encourage.

Let's outline this in English, and I'll highlight portions of the code after:

- App component: constructor()
-- Set the window.onload required by Google YOLO to fire an api loaded action
- App - did mount
-- Dispatch the app boot action

(At any point)
- Google YOLO saga receives the api loaded notification, and sets state.googleYolo.api

(Following app boot)
- App saga receives the app boot action
- Redirect to home
- Fire an independent process to read auth

(At any point)
- App saga receives receives boot auth read
- Checks for cookies
- Fires app boot credentials unavailable action

(At the point both app boot credentials unavailable action AND Google YOLO api available have happened)
- Google YOLO saga fires the googleYolo.retrieve() api
- Google YOLO saga fires the api action authenticate with google account credentials
- Google YOLO saga fires the app user identity request action
- Api saga fires the app user identity success action
- Google YOLO saga fires the user login success action
- App saga redirects to the /profile page

*phew* - And that's the simple happy path

And again... in code

app/index.js - importing and composing the app to handle our sagas

  1. import { withRouter } from 'react-router-dom';
  2. // ...
  3. import appSaga from './saga';
  4. import appReducer from './reducer';
  5.  
  6. import googleYoloSaga from './saga.googleYolo';
  7. import googleYoloReducer from './reducer.googleYolo';
  8.  
  9. import apiSaga from './saga.api';
  10. import apiReducer from './reducer.api';
  11. // ...
  12.  
  13. export class App extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function
  14.   // ...
  15.   render() {
  16.      // ...
  17.         <Switch> // This will be activated by the withRouter part of our composition to allow saga yield push(put('/path'));
  18.           <Route exact path="/" component={HomePage} />
  19.           <Route exact path="/auth" component={AuthPage} />
  20.           <Route exact path="/profile" component={ProfilePage} />
  21.           <Route component={NotFoundPage} />
  22.         </Switch>
  23.      // ...
  24.   }
  25. }
  26.  
  27. const withConnect = connect(mapStateToProps, mapDispatchToProps);
  28.  
  29. const withAppReducer = injectReducer({ key: 'app', reducer: appReducer });
  30. const withAppSaga = injectSaga({ key: 'app', saga: appSaga });
  31.  
  32. const withGoogleYoloReducer = injectReducer({ key: 'googleYolo', reducer: googleYoloReducer });
  33. const withGoogleYoloSaga = injectSaga({ key: 'googleYolo', saga: googleYoloSaga });
  34.  
  35. const withApiReducer = injectReducer({ key: 'api', reducer: apiReducer });
  36. const withApiSaga = injectSaga({ key: 'api', saga: apiSaga });
  37.  
  38. export default compose(
  39.   withAppReducer,
  40.   withAppSaga,
  41.   withGoogleYoloReducer,
  42.   withGoogleYoloSaga,
  43.   withApiReducer,
  44.   withApiSaga,
  45.   withRouter, // Without this, yield put(push('/path')) will not cause the <Router> to swap containers
  46.   withConnect,
  47. )(App);

app/index.js - activating our sagas

  1. // ...
  2.  
  3. export class App extends React.PureComponent { // eslint-disable-line react/prefer-stateless-function
  4.   constructor(props) {
  5.     super(props);
  6.  
  7.     window.onGoogleYoloLoad = (api) => {
  8.       props.dispatch(actions.googleYoloLoadedAction(api));
  9.     };
  10.   }
  11.  
  12.   componentDidMount() {
  13.     this.props.dispatch(actions.appStartAction());
  14.   }
  15.   // ...
  16.  
  17. }

saga.googleYolo.js

  1. import { all, take, takeEvery, call, put, select, race } from 'redux-saga/effects';
  2.  
  3. import * as actions from './actions';
  4. import * as apiActions from './actions.api';
  5.  
  6. import { makeSelectGoogleYoloApi } from './selectors';
  7.  
  8. import {
  9.   // ...
  10. } from './constants';
  11. import {
  12.   // ...
  13. } from './constants.api';
  14.  
  15. const getGoogleYoloApi = makeSelectGoogleYoloApi();
  16.  
  17. // Root saga
  18. // single entry point to start all Sagas at once
  19. export default function* rootSaga() {
  20.   yield all([
  21.     googleAuthWatcher(),
  22.     yield takeEvery(GOOGLE_YOLO_HINT, handleGoogleYoloHint),
  23.     yield takeEvery(GOOGLE_YOLO_RETRIEVE_CREDENTIALS_SUCCESS, handleGoogleYoloCredentialsSuccess),
  24.     yield takeEvery(GOOGLE_YOLO_RETRIEVE_CREDENTIALS_FAILURE, handleGoogleYoloCredentialsFailure),
  25.     // ...
  26.   ]);
  27. }
  28.  
  29. // ...
  30.  
  31. /**
  32.  * Saga watchers and workers
  33.  */
  34.  
  35. /**
  36.  * Watcher for the google yolo api loaded and auth token read actions
  37.  * If there is no auth token, it attempts a google sign in
  38.  */
  39. function* googleAuthWatcher() {
  40.   const [credentialsUnavailableAction, googleYoloAction] = yield all([ // eslint-disable-line no-unused-vars
  41.     take(APP_START_CREDENTIALS_UNAVAILABLE),
  42.     take(GOOGLE_YOLO_LOADED),
  43.   ]);
  44.  
  45.   yield handleGoogleAuthWatcher(googleYoloAction);
  46. }

saga.js

  1. import { push } from 'react-router-redux';
  2. import { fork, all, race, take, takeEvery, put, select } from 'redux-saga/effects';
  3. import Cookies from 'js-cookie';
  4.  
  5. import * as actions from './actions';
  6. import * as apiActions from './actions.api';
  7. import { makeSelectCurrentUser } from './selectors';
  8.  
  9. import {
  10.   // ...
  11. } from './constants';
  12. import {
  13.   // ...
  14. } from './constants.api';
  15.  
  16. const selectCurrentUser = makeSelectCurrentUser();
  17.  
  18. /**
  19.  * Saga watchers and workers
  20.  */
  21.  
  22. // Root saga
  23. // single entry point to start all Sagas at once
  24. export default function* rootSaga() {
  25.   yield all([
  26.     // App
  27.     watchAppStart(),
  28.     yield takeEvery(APP_START_CREDENTIALS_LOADED, handleAppStartCredentialsLoadedAction),
  29.     yield takeEvery(APP_START_CREDENTIALS_UNAVAILABLE, handleAppStartCredentialsUnavailableAction),
  30.     // Log in / Log out
  31.     yield takeEvery(USER_LOG_IN_START, handleUserLoginStart),
  32.     yield takeEvery(USER_LOG_IN_SUCCESS, handleUserLoginSuccess),
  33.     yield takeEvery(USER_LOG_IN_FAILURE, handleUserLoginFailure),
  34.     yield takeEvery(USER_LOG_OUT, handleUserLogout),
  35.     // ...
  36.   ]);
  37. }
  38.  
  39. // ...
  40.  
  41. /**
  42.  * App
  43.  */
  44.  
  45. /**
  46.  * Watcher for the app start
  47.  */
  48. function* watchAppStart() {
  49.   yield takeEvery(APP_START, handleAppStart);
  50. }
  51.  
  52. /**
  53.  * Worker for app start
  54.  */
  55. function* handleAppStart() {
  56.   yield put(push('/'));
  57.  
  58.   // Fire boot tasks async as much as possible
  59.   yield fork(appStartAuth);
  60. }
  61.  
  62. /**
  63.  * Handles the auth start boot check
  64.  */
  65. function* appStartAuth() {
  66.   // Check auth cookies
  67.   const credentials = Cookies.getJSON(AUTH_COOKIE_NAME);
  68.   if (credentials) {
  69.     yield put(actions.appStartCredentialsLoadedAction(credentials));
  70.     return;
  71.   }
  72.  
  73.   // If there's no refresh token, it's over
  74.   const refreshToken = Cookies.get(REFRESH_TOKEN_COOKIE_NAME);
  75.   if (!refreshToken) {
  76.     yield put(actions.appStartCredentialsUnavailableAction());
  77.     return;
  78.   }
  79.  
  80.   // ...
  81. }
  82.  
  83. /**
  84.  * Handles the auth start boot check
  85.  */
  86. function* handleAppStartCredentialsUnavailableAction() {
  87.   yield put(push('/auth'));
  88. }

saga.googleYolo.js

  1. // ...
  2. /**
  3.  * Calls the google yolo api to cancel the retrieve credentials
  4.  *
  5.  * @param googleYolo
  6.  * @returns {Promise|Promise.<T>|*}
  7.  */
  8. function callGoogleYoloRetrieve(googleYolo) {
  9.   return googleYolo.retrieve(googleYoloOptions)
  10.     .then((response) => ({ response }))
  11.     .catch((error) => ({ error }));
  12. }
  13.  
  14. // ...
  15.  
  16. /**
  17.  * Google yolo api watcher handler - fires the aysnc request for credentials
  18.  */
  19. function* handleGoogleAuthWatcher(action) {
  20.   yield put({ type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_START });
  21.   const { response, error } = yield call(callGoogleYoloRetrieve, action.api);
  22.  
  23.   // ...
  24. }

saga.api.js

  1. import { all, takeLatest, call, put, select, race, take } from 'redux-saga/effects';
  2. import deepmerge from 'deepmerge';
  3. import Cookies from 'js-cookie';
  4.  
  5. import * as apiActions from './actions.api';
  6.  
  7. import { makeSelectAuth, makeSelectRefreshToken } from './selectors';
  8.  
  9. import * as constants from './constants';
  10. import * as apiConstants from './constants.api';
  11.  
  12. const selectAuth = makeSelectAuth();
  13. const selectAuthRefreshToken = makeSelectRefreshToken();
  14.  
  15. // ...
  16.  
  17. // Root saga
  18. // single entry point to start all Sagas at once
  19. export default function* rootSaga() {
  20.   yield all([
  21.     // Boot
  22.     yield takeLatest(constants.APP_START_CREDENTIALS_LOADED, handleAppStartCredentialsLoaded),
  23.     // Auth
  24.     yield takeLatest(apiConstants.API_AUTH_CREDENTIALS_REQUEST, handleApiAuthCredentialsRequest),
  25.     yield takeLatest(apiConstants.API_AUTH_GOOGLE_REQUEST, handleApiAuthGoogleRequest),
  26.     yield takeLatest(apiConstants.API_AUTH_REFRESH_REQUEST, handleApiAuthRefreshRequest),
  27.     yield takeLatest(apiConstants.API_IDENTITY_REQUEST, handleApiIdentityRequest),
  28.     // User
  29.     yield takeLatest(apiConstants.API_USER_REQUEST, handleApiUserRequest),
  30.     yield takeLatest(apiConstants.API_USER_DELETE_REQUEST, handleApiUserDeleteRequest),
  31.   ]);
  32. }
  33.  
  34. // ..
  35.  
  36. function* handleApiAuthGoogleRequest(action) {
  37.   const endpoint = `${API_URL}/v1/authGoogle`;
  38.   const body = JSON.stringify({
  39.     grant_type: 'password',
  40.     scope: '', // This is all stub for now anyway
  41.     client: `${CLIENT_TYPE}+${API_VERSION}`,
  42.     credentials: action.credentials,
  43.   });
  44.   const mergedOptions = Object.assign({}, baseOptions, { method: 'POST', body });
  45.  
  46.   try {
  47.     const credentials = yield call(request, undefined, endpoint, mergedOptions);
  48.     yield updateAuthCredentials(credentials);
  49.     yield put(apiActions.apiAuthGoogleSuccessAction(credentials));
  50.   } catch (error) {
  51.     yield put(apiActions.apiAuthGoogleFailureAction(error));
  52.   }
  53. }

saga.googleYolo.js

  1. // ...
  2. /**
  3.  * Google yolo api watcher handler - fires the aysnc request for credentials
  4.  */
  5. function* handleGoogleAuthWatcher(action) {
  6.   // ...
  7.   const { response, error } = yield call(callGoogleYoloRetrieve, action.api);
  8.  
  9.   const { response, error } = yield call(callGoogleYoloRetrieve, action.api);
  10.  
  11.   if (response) {
  12.     yield put({ type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_SUCCESS, credentials: response });
  13.   } else {
  14.     yield put({ type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_FAILURE, error });
  15.   }
  16. }

saga.api.js

  1. // ...
  2. /**
  3.  * Worker for user login success
  4.  */
  5. function* handleUserLoginSuccess() {
  6.   yield put(push('/profile'));
  7. }

The full gist of it:

I tried embedding this, but it was just a bit too large. If you want the full dump of files, please review https://gist.github.com/lancegliser/af41c0a52e9bd87975286dbfdbdca9a4.

Add new comment

Plain text

  • No HTML tags allowed.
  • Web page addresses and e-mail addresses turn into links automatically.
  • Lines and paragraphs break automatically.
By submitting this form, you accept the Mollom privacy policy.