Fragmented Thought

Redux sagas for Google YOLO

By

Published

Lance Gliser

Heads up! This content is more than six months old. Take some time to verify everything still works as expected.

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

// Inside your component's saga you can dispatch an api request action
yield put(apiActions.apiUserDeleteRequestAction(user.username));

// You'll setup a race condition, waiting for success for failure
// Note that the returned object deconstruction must match the object keys
const { deleteAction, errorAction } = yield race({
    deleteAction: take(API_USER_DELETE_REQUEST_SUCCESS),
    errorAction: take(API_USER_DELETE_REQUEST_FAILURE),
});

// Alert your component's _state_ to the side effects. Your reducer is listening for this
if (errorAction) {
    yield put(actions.userDeleteFailureAction(errorAction.error));
}

if (deleteAction) {
    yield put(actions.userDeleteSuccessAction()); // This might well result in a redirect using something like: yield put(push('/auth'));
}

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

import { withRouter } from "react-router-dom";
// ...
import appSaga from "./saga";
import appReducer from "./reducer";

import googleYoloSaga from "./saga.googleYolo";
import googleYoloReducer from "./reducer.googleYolo";

import apiSaga from "./saga.api";
import apiReducer from "./reducer.api";
// ...

export class App extends React.PureComponent {
  // eslint-disable-line react/prefer-stateless-function
  // ...
  render() {
    // ...
    <Switch>
      {" "}
      // This will be activated by the withRouter part of our composition to allow
      saga yield push(put('/path'));
      <Route exact path="/" component={HomePage} />
      <Route exact path="/auth" component={AuthPage} />
      <Route exact path="/profile" component={ProfilePage} />
      <Route component={NotFoundPage} />
    </Switch>;
    // ...
  }
}

const withConnect = connect(mapStateToProps, mapDispatchToProps);

const withAppReducer = injectReducer({ key: "app", reducer: appReducer });
const withAppSaga = injectSaga({ key: "app", saga: appSaga });

const withGoogleYoloReducer = injectReducer({
  key: "googleYolo",
  reducer: googleYoloReducer,
});
const withGoogleYoloSaga = injectSaga({
  key: "googleYolo",
  saga: googleYoloSaga,
});

const withApiReducer = injectReducer({ key: "api", reducer: apiReducer });
const withApiSaga = injectSaga({ key: "api", saga: apiSaga });

export default compose(
  withAppReducer,
  withAppSaga,
  withGoogleYoloReducer,
  withGoogleYoloSaga,
  withApiReducer,
  withApiSaga,
  withRouter, // Without this, yield put(push('/path')) will not cause the <Router> to swap containers
  withConnect
)(App);

app/index.js - activating our sagas

// ...

export class App extends React.PureComponent {
  // eslint-disable-line react/prefer-stateless-function
  constructor(props) {
    super(props);

    window.onGoogleYoloLoad = (api) => {
      props.dispatch(actions.googleYoloLoadedAction(api));
    };
  }

  componentDidMount() {
    this.props.dispatch(actions.appStartAction());
  }
  // ...
}

saga.googleYolo.js

import { all, take, takeEvery, call, put, select, race } from 'redux-saga/effects';

import * as actions from './actions';
import * as apiActions from './actions.api';

import { makeSelectGoogleYoloApi } from './selectors';

import {
// ...
} from './constants';
import {
// ...
} from './constants.api';

const getGoogleYoloApi = makeSelectGoogleYoloApi();

// Root saga
// single entry point to start all Sagas at once
export default function* rootSaga() {
    yield all([
        googleAuthWatcher(),
        yield takeEvery(GOOGLE_YOLO_HINT, handleGoogleYoloHint),
        yield takeEvery(GOOGLE_YOLO_RETRIEVE_CREDENTIALS_SUCCESS, handleGoogleYoloCredentialsSuccess),
        yield takeEvery(GOOGLE_YOLO_RETRIEVE_CREDENTIALS_FAILURE, handleGoogleYoloCredentialsFailure),
        // ...
    ]);
}

// ...

/**
 * Saga watchers and workers
 */
/**
 * Watcher for the google yolo api loaded and auth token read actions
 * If there is no auth token, it attempts a google sign in
 */
function_ googleAuthWatcher() {
  const [credentialsUnavailableAction, googleYoloAction] = yield all([ // eslint-disable-line no-unused-vars
      take(APP_START_CREDENTIALS_UNAVAILABLE),
      take(GOOGLE_YOLO_LOADED),
  ]);

    yield handleGoogleAuthWatcher(googleYoloAction);
}

saga.js

import { push } from 'react-router-redux';
import { fork, all, race, take, takeEvery, put, select } from 'redux-saga/effects';
import Cookies from 'js-cookie';

import * as actions from './actions';
import * as apiActions from './actions.api';
import { makeSelectCurrentUser } from './selectors';

import {
// ...
} from './constants';
import {
// ...
} from './constants.api';

const selectCurrentUser = makeSelectCurrentUser();

/**
 * Saga watchers and workers
 */

// Root saga
// single entry point to start all Sagas at once
export default function* rootSaga() {
yield all([
    // App
    watchAppStart(),
    yield takeEvery(APP_START_CREDENTIALS_LOADED, handleAppStartCredentialsLoadedAction),
    yield takeEvery(APP_START_CREDENTIALS_UNAVAILABLE, handleAppStartCredentialsUnavailableAction),
    // Log in / Log out
    yield takeEvery(USER_LOG_IN_START, handleUserLoginStart),
    yield takeEvery(USER_LOG_IN_SUCCESS, handleUserLoginSuccess),
    yield takeEvery(USER_LOG_IN_FAILURE, handleUserLoginFailure),
    yield takeEvery(USER_LOG_OUT, handleUserLogout),
    // ...
    ]);
}

// ...

/**
 * App
 */

/**
 * Watcher for the app start
 */
function_ watchAppStart() {
  yield takeEvery(APP_START, handleAppStart);
}

/**
 * Worker for app start
 */
function_ handleAppStart() {
    yield put(push('/'));

    // Fire boot tasks async as much as possible
    yield fork(appStartAuth);
}

/**
 * Handles the auth start boot check
 */
function_ appStartAuth() {
  // Check auth cookies
  const credentials = Cookies.getJSON(AUTH_COOKIE_NAME);
  if (credentials) {
      yield put(actions.appStartCredentialsLoadedAction(credentials));
      return;
    }

    // If there's no refresh token, it's over
    const refreshToken = Cookies.get(REFRESH_TOKEN_COOKIE_NAME);
    if (!refreshToken) {
        yield put(actions.appStartCredentialsUnavailableAction());
    return;
}

// ...
}

/**
 * Handles the auth start boot check
 */
function_ handleAppStartCredentialsUnavailableAction() {
  yield put(push('/auth'));
}

saga.googleYolo.js

// ...
/**
 * Calls the google yolo api to cancel the retrieve credentials
 *
 * @param googleYolo
 * @returns {Promise|Promise.<T>|*}
 */
function callGoogleYoloRetrieve(googleYolo) {
return googleYolo.retrieve(googleYoloOptions)
  .then((response) => ({ response }))
  .catch((error) => ({ error }));
}

// ...

/**
 * Google yolo api watcher handler - fires the aysnc request for credentials
 */
function_ handleGoogleAuthWatcher(action) {
    yield put({ type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_START });
    const { response, error } = yield call(callGoogleYoloRetrieve, action.api);

    // ...
}

saga.api.js

import {
  all,
  takeLatest,
  call,
  put,
  select,
  race,
  take,
} from "redux-saga/effects";
import deepmerge from "deepmerge";
import Cookies from "js-cookie";

import * as apiActions from "./actions.api";

import { makeSelectAuth, makeSelectRefreshToken } from "./selectors";

import * as constants from "./constants";
import * as apiConstants from "./constants.api";

const selectAuth = makeSelectAuth();
const selectAuthRefreshToken = makeSelectRefreshToken();

// ...

// Root saga
// single entry point to start all Sagas at once
export default function* rootSaga() {
  yield all([
    // Boot
    yield takeLatest(
      constants.APP_START_CREDENTIALS_LOADED,
      handleAppStartCredentialsLoaded
    ),
    // Auth
    yield takeLatest(
      apiConstants.API_AUTH_CREDENTIALS_REQUEST,
      handleApiAuthCredentialsRequest
    ),
    yield takeLatest(
      apiConstants.API_AUTH_GOOGLE_REQUEST,
      handleApiAuthGoogleRequest
    ),
    yield takeLatest(
      apiConstants.API_AUTH_REFRESH_REQUEST,
      handleApiAuthRefreshRequest
    ),
    yield takeLatest(
      apiConstants.API_IDENTITY_REQUEST,
      handleApiIdentityRequest
    ),
    // User
    yield takeLatest(apiConstants.API_USER_REQUEST, handleApiUserRequest),
    yield takeLatest(
      apiConstants.API_USER_DELETE_REQUEST,
      handleApiUserDeleteRequest
    ),
  ]);
}

// ..

function* handleApiAuthGoogleRequest(action) {
  const endpoint = `${API_URL}/v1/authGoogle`;
  const body = JSON.stringify({
    grant_type: "password",
    scope: "", // This is all stub for now anyway
    client: `${CLIENT_TYPE}+${API_VERSION}`,
    credentials: action.credentials,
  });
  const mergedOptions = Object.assign({}, baseOptions, {
    method: "POST",
    body,
  });

  try {
    const credentials = yield call(request, undefined, endpoint, mergedOptions);
    yield updateAuthCredentials(credentials);
    yield put(apiActions.apiAuthGoogleSuccessAction(credentials));
  } catch (error) {
    yield put(apiActions.apiAuthGoogleFailureAction(error));
  }
}

saga.googleYolo.js

// ...
/**
 * Google yolo api watcher handler - fires the aysnc request for credentials
 */
function* handleGoogleAuthWatcher(action) {
  // ...
  const { response, error } = yield call(callGoogleYoloRetrieve, action.api);

  const { response, error } = yield call(callGoogleYoloRetrieve, action.api);

  if (response) {
    yield put({
      type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_SUCCESS,
      credentials: response,
    });
  } else {
    yield put({ type: GOOGLE_YOLO_RETRIEVE_CREDENTIALS_FAILURE, error });
  }
}

saga.api.js

// ...
/**
 * Worker for user login success
 */
function* handleUserLoginSuccess() {
  yield put(push("/profile"));
}

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 it here .