Problem
On my project, we had such a dependencies graph.
Now React Native now properly warn dependency cycles.
That is important because, while commonjs 1 and ES6 modules 2 supports (“kind of”, they don’t crash and the algorithm is deterministic but not realy logical) circular deps, the exact result depends a lot from the context. If you use something that have circular deps, by design, the value might be undefined. That will cause a bug in your app.
The actual piece
In order to do the API calls we use wretch. Despite the name, wretch is the most glorious piece of technology to help you doing API calls in Typescript: it is well designed, well documented, the creator answers quickly. Check it out.
The main benefit of wretch is imuability. We can do:
import wretch from "wretch";
// You can define an api with base url
const api = wretch("https://exaxample.com");
// you can reuse the base api to define a intermediate api that cache aggressivly
const cachedApi = api.headers({
"If-Unmodified-Since": "Wed, 21 Oct 2015 07:28:00 GMT"
});
// Later
api.get("/matches").json();
cachedApi
.get("/media")
.query({ imageId: 254331 })
.json();
We did architecture the api layer this way:
So:
- the
sagas
orchestrate the call in an async way sagas
callressources
that perform the actual call given some params, extract the result and give back the validated data- the
ressources
use a list of base url from anendpoint
file
Let’s have an example!
The saga member.saga.ts
call UserRessource
with parameters to get the data from the api, then normalize it and dispatch action so reducer can modify redux state:
import { UserRessource } from "ressources/users";
function* fetchUserProfileSaga(userId: string) {
const userData = yield call(UserRessource.getById, userId, {
include: "photo"
});
// with normalizer
const { user, photo } = yield call(UserWithPhotoNormalizer.normalize);
// for example with https://github.com/piotrwitek/typesafe-actions
yield put(userAction.success(user));
yield put(photoAction.success(photo));
}
//
export const saga = function*() {
yield all([takeLatest(userAction.fetchRequest(), fetchUserProfileSaga)]);
};
The UserRessource
do the call:
import { ApiEndpoint } from "endpoint";
interface IUserApi {
data: IUser;
}
export class UserRessource {
public static async getById(id: string) {
const json = await ApiEndpoint.user
.url("/" + id)
.get() // the call is done here
.json<IUserApi>();
// ... do validation here, maybe with io-ts
return json.data;
}
}
The user endpoint provide the base url:
import wretch from "wretch";
export class ApiEndpoint {
public static get base() {
return wretch("http://example.com").headers({ "X-Platform": "mobile" });
}
public static get user() {
return ApiEndpoint.base.url("/user");
}
}
So far so good !
When we add auth middleware stuff becomes dirtier.
As you know JWT are mean to be short lived so client shall refresh them from time to time.
To centralize the logic we can use wretch middlewares:
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (
url: string,
opts: WretcherOptions
): Promise<WretcherResponse> => {
// the middleware
};
};
We first check if the token is expired or almost expired. As JWT is not encrypted (just signed), we can do that client side.
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (
url: string,
opts: WretcherOptions
): Promise<WretcherResponse> => {
// modify the request
const parsedAuthorization = /Bearer (\w+)/.exec(opts.headers.authorization); const actualJWT = parsedAuthorization[1]; const actualJWTPayload = jsonwebtoken.decode(actualJWT); const actualJWTExpirationDate = actualJWTPayload.exp; const isJWTAlmostExpired = actualJWTExpirationDate - Date.now() / 1000 < 15;
// ..
};
};
If not, we actually perform the request by awaiting next
. That will be familiar to you if you ever used koa
.
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (): Promise<WretcherResponse> => {
// ....
const isJWTAlmostExpired = actualJWTExpirationDate - Date.now() / 1000 < 15;
let response; if (!isJWTAlmostExpired) { response = await next(url, opts); } else { // ...
}
// ..
};
};
If not let’s request a new token first. We get the refresh token from “cookie/locastore/whatever” and fetch AuthRessource
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (): Promise<WretcherResponse> => {
// ....
let response;
if (!isJWTAlmostExpired) {
// ...
} else { const refreshToken = await getItem("refreshToken"); const token = new AuthRessource().refresh(refreshToken); const newOpts = { ...opts, headers: { ...opts.headers, authorization: "Bearer " + token } }; response = await next(url, newOpts); }
// ...
};
};
To be sure, we can analyse the response and retry if needed
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (): Promise<WretcherResponse> => {
// ....
let response;
if (!isJWTAlmostExpired) {
response = await next(url, opts);
} else {
// ...
response = await next(url, newOpts);
}
if (response.status === 401) {
const refreshToken = await getItem("refreshToken");
const token = new AuthRessource().refresh(refreshToken); const newOpts = { ...opts, headers: { ...opts.headers, authorization: "Bearer " + token } }; response = await next(url, newOpts); } };};
And return the response:
export const loginMiddleware: Middleware = () => {
return (next: FetchLike) => async (): Promise<WretcherResponse> => {
// ....
let response;
// ...
return response; };
};
The AuthRessource
is simple.
export class AuthRessource {
async login(username: string, password: string) {
await new ApiEndpoint.auth.post({
type: "credential",
username,
password
}).json();
}
async refresh(refreshToken: string) {
await new ApiEndpoint.auth.post({
type: "refreshToken",
refreshToken
}).json();
}
}
But now you see the problem:
And boom a dependency cycle.
:/
In the next article we will look how to break the dependency cycle.
-
CommonJS
exports an incomplete version of the module, until the dependency is resolved. Example:in
main.js
const a = require("./a").test; console.log(a.test); // undefined require("./b"); console.log(a.test); // 10
in
a.js
export.test = require("./b").test;
in
b.js
const a = require("./a"); exports.test = 10;
See for more info in node doc.
↩ -
ES6 works as expected as they export reference, not value. However during transpilation, import will be converted to CommonJS.
See 2ality.com nice article about the difference.
↩