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

Initial implementation #1

Merged
merged 14 commits into from
Feb 27, 2017
Merged

Initial implementation #1

merged 14 commits into from
Feb 27, 2017

Conversation

dodsky
Copy link
Contributor

@dodsky dodsky commented Feb 14, 2017

No description provided.

@dodsky dodsky self-assigned this Feb 14, 2017
@dodsky dodsky requested a review from vedrani February 14, 2017 17:12
Copy link
Contributor

@vedrani vedrani left a comment

Choose a reason for hiding this comment

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

I commented this file a lot, but please use it only as suggestion. I think you did a great job :) I didn't review tests, I will look it in second iteration. 🥇

}

return token;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This should maybe be default way of handling authorization, but we should also allow to override it in config from Builder.

@@ -0,0 +1,25 @@
export function formatBearer(token) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is only for tests? If so move it to /test/helpers

src/const.js Outdated
export const ENVIRONMENT_IS_REACT_NATIVE = typeof navigator === 'object' && navigator.product === 'ReactNative';
export const ENVIRONMENT_IS_NODE = typeof process === 'object' && typeof require === 'function';
export const ENVIRONMENT_IS_WEB = typeof window === 'object';
export const ENVIRONMENT_IS_WORKER = typeof importScripts === 'function';
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be changed into functions with camelCase and moved maybe to service/environment.js

src/index.js Outdated
return fetchInterceptor(fetch, ...args);
};
})(env.fetch);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

This function with above check of environment should be encapsulated into environment that would be called in index.js like environment.init(); only once upon index.js load. Names are just suggestions.

src/index.js Outdated
refreshToken: null,
};

let refreshAccessTokenPromise = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

All of above let variables should be changed into const and moved into encapsulated parts of lib that use them.

src/index.js Outdated
/**
* Configures fetch token intercept
*/
export function configure(initConfig) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Interceptor class and Interceptor class should pass part of config to AccessToken provider?

Copy link
Contributor Author

@dodsky dodsky Feb 16, 2017

Choose a reason for hiding this comment

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

I'm currently passing the whole config to AccessTokenProvider until we settle on the design. It's easy to extract necessary methods in the end.

src/index.js Outdated
STATUS_OK,
} from './const';

let config = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Document each config key

src/index.js Outdated

return response;
})
.then(response => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Please check usage of .then sometimes it's not necessary, I think once you encapsulate logic into methods that need for .then will diminish. You explained me concept of config methods that returns promise, and for that cases you don't have other options.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This whole part was redesigned into requestUnit idea as part of fetchInterceptor class and fetchWithRetry method.

src/index.js Outdated
if (!tokens.accessToken) {
// check if we are alredy fetching it
if (!refreshAccessTokenPromise) {
// as a side-effect refreshTokenPromise is set
Copy link
Contributor

Choose a reason for hiding this comment

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

Don't like side-effect :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sideeffect is now part of accessTokenProvider :)

src/index.js Outdated
outerReject(error);
} else {
// if we fail to resolve refresh token, and it's not internal error
// just pass the request normally
Copy link
Contributor

Choose a reason for hiding this comment

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

This part is weird, I wouldn't expect 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.

Here's how normal fetch behaves:
fetch(url).then(result).catch(error)

  1. catch is called only on network errors (e.g. DNS unresolved)
  2. then is called on all other responses, including 401

Initially I've solved this flow by throwing this exception, then capturing it in main fetch promise and resolving accordingly. In new version I did it without throwing exceptions.

@dodsky
Copy link
Contributor Author

dodsky commented Feb 16, 2017

These are all great suggestions, I've already fixed some od these suggestions on the following commit, like encapsulating refresh token handling into separate class, and your ideja od interceptor correlates with my idea od interceptor provider. Also to enable promisifying any method od config I've converted a request into a smarter object which I've called 'requestUnit' which passes through interceptor promise pipeline and holds resolved values of all config functions. This effectively enables us to cashe values for resolved config methods and whole process acts as a middleware chain (got the ideja from node). :)

* fetchInterceptor - main class for intercepting
* accessTokenProvider - access token resolver
multiple services to increase readability as per PR comments
@dodsky dodsky requested a review from vedrani February 16, 2017 22:54
Copy link
Contributor

@vedrani vedrani left a comment

Choose a reason for hiding this comment

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

We should discuss some aspects of code.

expect(result).to.be.equal('token');
});
})
});
Copy link
Contributor

Choose a reason for hiding this comment

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

new line :trollface:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

image

package.json Outdated
"access"
],
"author": "Shoutem",
"license": "MIT",
Copy link
Contributor

Choose a reason for hiding this comment

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

BSD? custom?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note, this should probably be updated on redux io repo too.

package.json Outdated
"author": "Shoutem",
"license": "MIT",
"bugs": {
"url": ""
Copy link
Contributor

Choose a reason for hiding this comment

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

link to github issues page?

isResponseUnauthorized,
} from './services/http';

export class AccessTokenProvider {
Copy link
Contributor

Choose a reason for hiding this comment

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

Add nice jsdoc comment /** about this class, explaining it to others

refresh() {
// if token resolver is not authorized it should just resolve
if (!this.isAuthorized()) {
return Promise.resolve();
Copy link
Contributor

Choose a reason for hiding this comment

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

Should it reject?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If it rejects it will cause a chain reaction which will cause fetchIntercept chain to reject also, which in turn will reject an initial fetch request which is undesirable as we don't want to break the fetch spec which currently states that fetch will only reject on network errors and this is not a network error.

But indeed it could be potentially problematic if you call renew without calling authorize first as it will silently ignore your renew requests. Two solutions here: silently fail with console error, or raise an exception which will be caught by fetchInterceptor which won't propagate it out of fetch call, but catch it, perhaps again log it, and then resolve as normal request would. I feel that the second solution only raises the handling to upper level without any flow control benefits as this can't be handled.

Copy link
Contributor

Choose a reason for hiding this comment

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

Looking from outside, I would want for my requests to fail with 40x error with equal behavior as in case that I don't use fetch-token-intercept and didn't add proper Auth header. Also it would be useful to see additional error or warning from fetch-token-intercept about need to call authorize with instruction to pass refreshToken.

.then(this.fetchRequest)
.then(this.shouldInvalidateAccessToken)
.then(this.invalidateAccessToken)
.then(this.handleUnauthorizedRequest)
Copy link
Contributor

Choose a reason for hiding this comment

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

handleUnauthorizedRequest hides that you are renewing token. Maybe this could be more explicit, by rejecting and then in rejectHandler try to resolve problem?

this.config.onResponse(response);
}

outerResolve(response);
Copy link
Contributor

Choose a reason for hiding this comment

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

This code in then block could be in handleResponse()?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only reason that it isn't is because it needs access to methods for resolving fetch promise (outerResolve and outerReject are basically mapped like this fetch.then(outerResolve).then(outerReject)), but when I though about it, it totally makes sense to put references of outer fetch reject and resolve in request unit as any stage of pipeline could do

shouldFetch: !!accessToken,
}))
.then(this.authorizeRequest)
.then(this.fetchRequest)
Copy link
Contributor

Choose a reason for hiding this comment

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

This block in if (isResponseUnauthorized(response)) { is problematic, what happens if it fails twice? You only have one retry right? Should you reuse fetchWithRetry and move request through flow once again?

}

const bearerRegex = /^Bearer (.+)$/;
const matches = bearerRegex.exec(authorizationHeaderValue);
Copy link
Contributor

Choose a reason for hiding this comment

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

string.match(regex)

});
});

it('should propagate 401 for multiple requests', function (done) {
Copy link
Contributor

Choose a reason for hiding this comment

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

What happens here? Can we test what happens if one request gets multiple 401s?

  1. Request is emitted with expired token
  2. Response is 401
  3. token is refreshed, request is reemitted
  4. Response is 401
  5. token is refreshed, request is reemitted
  6. Response is 401
  7. token is refreshed, request is reemitted
    ...

Copy link
Contributor Author

@dodsky dodsky Feb 23, 2017

Choose a reason for hiding this comment

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

Wrote a test, it will retry as many times as it is defined on config with fetchRetryCount property. (should stop after retry count is exceeded and resolve unauthorized)

Bump versoin
Refactored tests
Minor renames and improvements
@dodsky dodsky requested a review from vedrani February 23, 2017 23:53
Copy link
Contributor

@vedrani vedrani left a comment

Choose a reason for hiding this comment

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

There is a couple of minor changes.

@@ -0,0 +1,59 @@
{
Copy link
Contributor

Choose a reason for hiding this comment

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

You don't ahve README file, add it. I know you don't have time to write everything. But maybe you could just add short description about what this lib solves and add link to somethnig like this:
https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/

renew() {
// if token resolver is not authorized it should just resolve
if (!this.isAuthorized()) {
console.log('Please authorize provider before renewing or check shouldIntercept config.');
Copy link
Contributor

Choose a reason for hiding this comment

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

warning

* @param refreshToken
* @param accessToken
*/
authorize(refreshToken, accessToken) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need to pass accessToken here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I want to allow passing both refresh token and access token for two reasons:

  1. perhaps you already have both stored externally and both are valid (why forcing a renew?)
  2. RFC spec allows authorization response to return both https://tools.ietf.org/html/rfc6749#page-10

}

handleAccessToken(token, resolve) {
this.tokens.accessToken = token;
Copy link
Contributor

Choose a reason for hiding this comment

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

this.tokens = {...this.tokens, accessToken}

return this.config.parseAccessToken(response);
}

handleAccessToken(token, resolve) {
Copy link
Contributor

Choose a reason for hiding this comment

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

accessToken

invalidateAccessToken(requestUnit) {
const { shouldIntercept, shouldInvalidateAccessToken } = requestUnit;

if (shouldIntercept && shouldInvalidateAccessToken) {
Copy link
Contributor

Choose a reason for hiding this comment

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

same here

}

if (this.config.onResponse) {
this.config.onResponse(response);
Copy link
Contributor

Choose a reason for hiding this comment

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

What if someone in onResponse function doesn't know how to handle response with clone is it going to destroy it before fetchResolve, should you pass response.clone here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As this is the last callback it won't matter, but generally speaking, when accessing body you should clone first, because if anyone also has to read it after you it will fail I don't clone it first to avoid clone overhead when it's not necessary. Also, I've designed onResponse as fire and forget as we don't wait for events to finish during fetch intercept.

throw new Error(error);
}

isConfigValid() {
Copy link
Contributor

Choose a reason for hiding this comment

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

maybe put it higher in class to be closer to call

this.message = 'Retry count has been exceeded';
this.requestUnit = requestUnit;

// Use V8's native method if available, otherwise fallback
Copy link
Contributor

Choose a reason for hiding this comment

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

What we gain with this? It only for Chrome?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

http://stackoverflow.com/a/27724419/1008529
...with the enhancement that stack traces work on Firefox and other browsers.


RetryCountExceededException.prototype = Object.create(Error.prototype);
RetryCountExceededException.prototype.name = "RetryCountExceededException";
RetryCountExceededException.prototype.constructor = RetryCountExceededException;
Copy link
Contributor

Choose a reason for hiding this comment

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

new line.
Is there a ES6 way to do this? using extends

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Babel 6.x does not correctly support extending core classes yet:
http://stackoverflow.com/questions/31089801/extending-error-in-javascript-with-es6-syntax
babel/babel#3083

there's a babel plugin https://www.npmjs.com/package/babel-plugin-transform-builtin-extend that enables extending from Error and Array. Don't know how we feel about this..

Copy link
Contributor

Choose a reason for hiding this comment

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

I would add this babel plugin, It's only overhead during transpiling but simplifies code.

Added readme
Minor renames in AccessTokenProvider and FetchInterceptor
Version bumped
@dodsky dodsky merged commit 0921a59 into master Feb 27, 2017
Copy link

@zrumenjak zrumenjak left a comment

Choose a reason for hiding this comment

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

Looks awesome 👍

//Event invoked when access token has changed
onAccessTokenChange: null,

//Event invoked when response is resolved

Choose a reason for hiding this comment

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

You usually have a single space after // except in the examples above :)

}
```

All required methods support returning a promise to enable reading of body.

Choose a reason for hiding this comment

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

Maybe ... return a promise to allow reading of the request body.

}

/**
* Returns current authorization for fetch fetchInterceptor

Choose a reason for hiding this comment

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

fetch fetchInterceptor is a bit confusing here...

}

/**
* Authorizes fetch interceptor with given renew token

Choose a reason for hiding this comment

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

refresh?

resolveIntercept(resolve, reject, ...args) {
const request = new Request(...args);
const { accessToken } = this.accessTokenProvider.getAuthorization();
const requestUnit = this.createRequestUnit(request, resolve, reject);

Choose a reason for hiding this comment

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

What is a request unit? Maybe add a short description to explain it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renamed to RequestContext, also added comments on createRequestContext method describing what it represents.

.catch(this.handleUnauthorizedRequest);
}

createRequestUnit(request, fetchResolve, fetchReject) {

Choose a reason for hiding this comment

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

It wasn't clear to me what a request unit should be from its name. Maybe we should consider renaming it to something like RequestContext or possibly RequestTask.

@dodsky dodsky deleted the initial-implementation branch April 5, 2017 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants