Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cleanup Network.js code. Fix persisted request handling. #8306

Merged
merged 4 commits into from
Mar 25, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ const CONST = {
API_OFFLINE: 'session.offlineMessageRetry',
UNKNOWN_ERROR: 'Unknown error',
REQUEST_CANCELLED: 'AbortError',
FAILED_TO_FETCH: 'Failed to fetch',
ENSURE_BUGBOT: 'ENSURE_BUGBOT',
},
NETWORK: {
METHOD: {
Expand All @@ -308,10 +310,9 @@ const CONST = {
PROCESS_REQUEST_DELAY_MS: 1000,
MAX_PENDING_TIME_MS: 10 * 1000,
},
HTTP_STATUS_CODE: {
JSON_CODE: {
SUCCESS: 200,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_AUTHENTICATED: 407,
},
NVP: {
IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'isFirstTimeNewExpensifyUser',
Expand Down
112 changes: 22 additions & 90 deletions src/libs/API.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import _ from 'underscore';
import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
import CONST from '../CONST';
import CONFIG from '../CONFIG';
import ONYXKEYS from '../ONYXKEYS';
import getPlaidLinkTokenParameters from './getPlaidLinkTokenParameters';
import redirectToSignIn from './actions/SignInRedirect';
import isViaExpensifyCashNative from './isViaExpensifyCashNative';
Expand All @@ -12,87 +10,10 @@ import Log from './Log';
import * as Network from './Network';
import updateSessionAuthTokens from './actions/Session/updateSessionAuthTokens';
import setSessionLoadingAndError from './actions/Session/setSessionLoadingAndError';
import * as NetworkStore from './Network/NetworkStore';
import enhanceParameters from './Network/enhanceParameters';

let isAuthenticating;
let credentials;
let authToken;
let currentUserEmail;

function checkRequiredDataAndSetNetworkReady() {
if (_.isUndefined(authToken) || _.isUndefined(credentials)) {
return;
}

Network.setIsReady(true);
}

Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
callback: (val) => {
credentials = val || null;
checkRequiredDataAndSetNetworkReady();
},
});

Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
authToken = lodashGet(val, 'authToken', null);
currentUserEmail = lodashGet(val, 'email', null);
checkRequiredDataAndSetNetworkReady();
},
});

/**
* Does this command require an authToken?
*
* @param {String} command
* @return {Boolean}
*/
function isAuthTokenRequired(command) {
return !_.contains([
'Log',
'Graphite_Timer',
'Authenticate',
'GetAccountStatus',
'SetPassword',
'User_SignUp',
'ResendValidateCode',
'ResetPassword',
'User_ReopenAccount',
'ValidateEmail',
], command);
}

/**
* Adds default values to our request data
*
* @param {String} command
* @param {Object} parameters
* @returns {Object}
*/
function addDefaultValuesToParameters(command, parameters) {
const finalParameters = {...parameters};

if (isAuthTokenRequired(command) && !parameters.authToken) {
finalParameters.authToken = authToken;
}

finalParameters.referer = CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER;

// This application does not save its authToken in cookies like the classic Expensify app.
// Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies
// and prevents interfering with the cookie authToken that Expensify classic uses.
finalParameters.api_setCookie = false;

// Unless email is already set include current user's email in every request and the server logs
finalParameters.email = lodashGet(parameters, 'email', currentUserEmail);

return finalParameters;
}

// Tie into the network layer to add auth token to the parameters of all requests
Network.registerParameterEnhancer(addDefaultValuesToParameters);

/**
* Function used to handle expired auth tokens. It re-authenticates with the API and
Expand All @@ -118,7 +39,7 @@ function handleExpiredAuthToken(originalCommand, originalParameters, originalTyp
return reauthenticate(originalCommand)
.then(() => {
// Now that the API is authenticated, make the original request again with the new authToken
const params = addDefaultValuesToParameters(originalCommand, originalParameters);
const params = enhanceParameters(originalCommand, originalParameters);
return Network.post(originalCommand, params, originalType);
})
.catch(() => (
Expand Down Expand Up @@ -159,8 +80,10 @@ Network.registerResponseHandler((queuedRequest, response) => {
});
}

if (response.jsonCode === 407) {
// Credentials haven't been initialized. We will not be able to re-authenticates with the API
if (response.jsonCode === CONST.JSON_CODE.NOT_AUTHENTICATED) {
const credentials = NetworkStore.getCredentials();

// Credentials haven't been initialized. We will not be able to re-authenticate with the API
const unableToReauthenticate = (!credentials || !credentials.autoGeneratedLogin
|| !credentials.autoGeneratedPassword);

Expand All @@ -169,13 +92,19 @@ Network.registerResponseHandler((queuedRequest, response) => {
// of the new response created by handleExpiredAuthToken.
const shouldRetry = lodashGet(queuedRequest, 'data.shouldRetry');
if (!shouldRetry || unableToReauthenticate) {
queuedRequest.resolve(response);
if (queuedRequest.resolve) {
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
queuedRequest.resolve(response);
}
return;
}

handleExpiredAuthToken(queuedRequest.command, queuedRequest.data, queuedRequest.type)
.then(queuedRequest.resolve)
.catch(queuedRequest.reject);
.then(queuedRequest.resolve || (() => Promise.resolve()))
.catch(queuedRequest.reject || (() => Promise.resolve()));
return;
}

if (!queuedRequest.resolve) {
return;
}

Expand All @@ -197,8 +126,10 @@ Network.registerErrorHandler((queuedRequest, error) => {
// Set an error state and signify we are done loading
setSessionLoadingAndError(false, 'Cannot connect to server');

// Reject the queued request with an API offline error so that the original caller can handle it.
queuedRequest.reject(new Error(CONST.ERROR.API_OFFLINE));
// Reject the queued request with an API offline error so that the original caller can handle it
if (queuedRequest.reject) {
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
queuedRequest.reject(new Error(CONST.ERROR.API_OFFLINE));
}
});

/**
Expand Down Expand Up @@ -280,6 +211,7 @@ function Authenticate(parameters) {
* @returns {Promise}
*/
function reauthenticate(command = '') {
const credentials = NetworkStore.getCredentials();
return Authenticate({
useExpensifyLogin: false,
partnerName: CONFIG.EXPENSIFY.PARTNER_NAME,
Expand All @@ -297,7 +229,7 @@ function reauthenticate(command = '') {
// Update authToken in Onyx and in our local variables so that API requests will use the
// new authToken
updateSessionAuthTokens(response.authToken, response.encryptedAuthToken);
authToken = response.authToken;
NetworkStore.setAuthToken(response.authToken);

// The authentication process is finished so the network can be unpaused to continue
// processing requests
Expand Down
8 changes: 7 additions & 1 deletion src/libs/HttpUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
method,
body,
})
.then(response => response.json());
.then((response) => {
if (!response.ok) {
throw Error(response.statusText);
}

return response.json();
});
}

/**
Expand Down
85 changes: 85 additions & 0 deletions src/libs/Network/NetworkStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import lodashGet from 'lodash/get';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import ONYXKEYS from '../../ONYXKEYS';

let credentials;
let authToken;
let currentUserEmail;
let networkReady = false;

/**
* @param {Boolean} ready
*/
function setIsReady(ready) {
networkReady = ready;
}

function checkRequiredDataAndSetNetworkReady() {
if (_.isUndefined(authToken) || _.isUndefined(credentials)) {
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
return;
roryabraham marked this conversation as resolved.
Show resolved Hide resolved
}

setIsReady(true);
}

Onyx.connect({
key: ONYXKEYS.SESSION,
callback: (val) => {
authToken = lodashGet(val, 'authToken', null);
currentUserEmail = lodashGet(val, 'email', null);
checkRequiredDataAndSetNetworkReady();
},
});

Onyx.connect({
key: ONYXKEYS.CREDENTIALS,
callback: (val) => {
credentials = val || null;
checkRequiredDataAndSetNetworkReady();
},
});

/**
* @returns {String}
*/
function getAuthToken() {
return authToken;
}

/**
* @param {String} newAuthToken
*/
function setAuthToken(newAuthToken) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why expose this function to set the authToken directly, instead updating SESSION.authToken in Onyx and relying on the Onyx.connect callback to update the local authToken?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good eye. It's not quite a hack, but close. Setting values in Onyx always takes at least one tick of the event loop and updates subscribers late. If you look at where this is used it might make more sense.

App/src/libs/API.js

Lines 236 to 237 in 9c6c2a9

authToken: parameters.authToken,
shouldRetry: false,

Inside reauthenticate() we are setting the "local" authToken since anything called after reauthenticate() will need immediate access to the authToken - otherwise it will use the old one again and get a 407 again.

I'll leave a comment here. Basically, Onyx being Onyx though.

Copy link
Contributor

@roryabraham roryabraham Mar 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, makes sense.

Setting values in Onyx always takes at least one tick of the event loop and updates subscribers late ... Basically, Onyx being Onyx though

Is there a way we could fix this? Maybe we could create an issue in the Onyx repo?

Copy link
Contributor Author

@marcaaron marcaaron Mar 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we even tried once, but it broke things in unexpected ways and there is a high likelihood that it may break stuff again. Anyone using Onyx.merge() is expecting to have those values available on the next tick (or later). Our current tests are pretty dependent on that "feature" as well so it's gonna be interesting if we ever try and do anything about it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm seems to me like things might be a bit easier to reason about if one could safely assume than any value written in Onyx will be updated immediately, but I'll take your word for it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They would 1000% be easier. And we could still do it. But whoever does it will need to check everything carefully and probably remove a lot of waitForPromisesToResolve() calls from our tests 😂

Copy link
Contributor

@roryabraham roryabraham Mar 25, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created an issue: Expensify/react-native-onyx#123

It's not very detail-rich, but will hopefully explore more when I have some spare cycles.

authToken = newAuthToken;
}

/**
* @returns {Object}
*/
function getCredentials() {
return credentials;
}

/**
* @returns {String}
*/
function getCurrentUserEmail() {
return currentUserEmail;
}

/**
* @returns {Boolean}
*/
function isReady() {
return networkReady;
}

export {
getAuthToken,
setAuthToken,
getCredentials,
getCurrentUserEmail,
isReady,
setIsReady,
};
52 changes: 52 additions & 0 deletions src/libs/Network/enhanceParameters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import lodashGet from 'lodash/get';
import _ from 'underscore';
import CONFIG from '../../CONFIG';
import * as NetworkStore from './NetworkStore';

/**
* Does this command require an authToken?
*
* @param {String} command
* @return {Boolean}
*/
function isAuthTokenRequired(command) {
return !_.contains([
'Log',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NAB alphabetize this list

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Je refuse!

'Graphite_Timer',
'Authenticate',
'GetAccountStatus',
'SetPassword',
'User_SignUp',
'ResendValidateCode',
'ResetPassword',
'User_ReopenAccount',
'ValidateEmail',
], command);
}

/**
* Adds default values to our request data
*
* @param {String} command
* @param {Object} parameters
* @returns {Object}
*/
export default function enhanceParameters(command, parameters) {
const finalParameters = {...parameters};

if (isAuthTokenRequired(command) && !parameters.authToken) {
finalParameters.authToken = NetworkStore.getAuthToken();
}

finalParameters.referer = CONFIG.EXPENSIFY.EXPENSIFY_CASH_REFERER;
roryabraham marked this conversation as resolved.
Show resolved Hide resolved

// This application does not save its authToken in cookies like the classic Expensify app.
// Setting api_setCookie to false will ensure that the Expensify API doesn't set any cookies
// and prevents interfering with the cookie authToken that Expensify classic uses.
finalParameters.api_setCookie = false;

// Include current user's email in every request and the server logs
finalParameters.email = lodashGet(parameters, 'email', NetworkStore.getCurrentUserEmail());

return finalParameters;
}
Loading