Skip to content

Commit

Permalink
feat: support conversation to/from snakecase on axios client
Browse files Browse the repository at this point in the history
  • Loading branch information
long74100 committed May 24, 2022
1 parent 043acfe commit 3492930
Show file tree
Hide file tree
Showing 7 changed files with 183 additions and 0 deletions.
44 changes: 44 additions & 0 deletions docs/decisions/0006-middleware-support-for-http-clients.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
Middleware Support for HTTP clients
===================================

Status
------

Accepted

Context
-------

We currently expose HTTP clients(axios instances) via ``getAuthenticatedHttpClient`` and ``getHttpClient`` used to make API requests
in our MFEs. There are instances where it would be helpful if consumers could apply middleware to these clients.
For example the `axios-case-converter <https://www.npmjs.com/package/axios-case-converter>`_ package provides
a middleware that handles snake-cased <-> camelCase conversions via axios interceptors. This middleware would allow our MFEs to
avoid having to do this conversion manually.

Decision
--------

The ``initialize`` function provided in the initialize module initializes the ``AxiosJwtAuthService`` provided by ``@edx/frontend-platform``.
We will add an optional param ``authMiddleware``, an array of middleware functions that will be applied to all http clients in
the ``AxiosJwtAuthService``.

Consumers will install the middleware they want to use and provide it to ``initialize``::

initialize({
messages: [appMessages],
requireAuthenticatedUser: true,
hydrateAuthenticatedUser: true,
authMiddleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })],
});

If a consumer chooses not to use ``initialize`` and instead the ``configure`` function, the middleware can be passed in the options param::

configure({
loggingService: getLoggingService(),
config: getConfig(),
options: {
middleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })]
}
});

We decided to let consumers install their own middleware packages, removing the need to install the dependency as part of ``@edx/frontend-platform``.
58 changes: 58 additions & 0 deletions docs/how_tos/automatic-case-conversion.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#####################################################################
How to: Convert SnakeCase to CamelCase automatically for API Requests
#####################################################################

Introduction
************

When using the HTTP client from ``@edx/frontend-platform``, you are making an API request to an
Open edX service which requires you to handle snake-cased <-> camelCase conversions manually. The manual conversion quickly gets
tedious, and is error prone if you forget to do it.

Here is how you can configure the HTTP client to automatically convert snake_case <-> camelCase for you.

How do I use configure automatic case conversion?
*************************************************

You want to install `axios-case-converter <https://www.npmjs.com/package/axios-case-converter>`_, and add it
as a middleware when calling ``initialize`` in the consumer::

import axiosCaseConverter from 'axios-case-converter';

initialize({
messages: [],
requireAuthenticatedUser: true,
hydrateAuthenticatedUser: true,
authMiddleware: [axiosCaseConverter],
});

Or, if you choose to use ``configure`` instead::

import axiosCaseConverter from 'axios-case-converter';

configure({
loggingService: getLoggingService(),
config: getConfig(),
options: {
middleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })]
}
});

By default the middleware will convert camelCase -> snake_case for payloads, and snake_case -> camelCase for responses.
If you want to customize middleware behavior, i.e. only have responses transformed, you can configure it like this::
initialize({
messages: [],
requireAuthenticatedUser: true,
hydrateAuthenticatedUser: true,
authMiddleware: [(client) => axiosCaseConverter(client, {
// options for the middleware
ignoreHeaders: true, // don't convert headers
caseMiddleware: {
requestInterceptor: (config) => {
return config;
}
}
})],
});

See `axios-case-converter <https://github.com/mpyw/axios-case-converter>`_ for more details on configurations supported by the package.
25 changes: 25 additions & 0 deletions src/auth/AxiosJwtAuthService.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,29 @@ class AxiosJwtAuthService {
this.cachedHttpClient = this.httpClient;
logFrontendAuthError(this.loggingService, `configureCache failed with error: ${e.message}`);
});

this.middleware = options.middleware;
this.applyMiddleware(options.middleware);
}

/**
* Applies middleware to the axios instances in this service.
*
* @param {Array} middleware Middleware to apply.
*/
applyMiddleware(middleware = []) {
const clients = [
this.authenticatedHttpClient, this.httpClient,
this.cachedAuthenticatedHttpClient, this.cachedHttpClient,
];
try {
(middleware).forEach((middlewareFn) => {
clients.forEach((client) => client && middlewareFn(client));
});
} catch (error) {
logFrontendAuthError(this.loggingService, error);
throw error;
}
}

/**
Expand All @@ -89,6 +112,7 @@ class AxiosJwtAuthService {
if (options.useCache) {
return this.cachedAuthenticatedHttpClient;
}

return this.authenticatedHttpClient;
}

Expand All @@ -104,6 +128,7 @@ class AxiosJwtAuthService {
if (options.useCache) {
return this.cachedHttpClient;
}

return this.httpClient;
}

Expand Down
30 changes: 30 additions & 0 deletions src/auth/AxiosJwtAuthService.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,36 @@ afterEach(() => {
global.location = location;
});

describe('applyMiddleware', () => {
it('should apply all middleware to the http clients in the service', () => {
const clients = [
service.authenticatedHttpClient, service.httpClient,
service.cachedAuthenticatedHttpClient, service.cachedHttpClient,
].filter(Boolean);

const middleware1 = jest.fn();
const middleware2 = jest.fn();

service.applyMiddleware([middleware1, middleware2]);
expect(middleware1).toHaveBeenCalledTimes(clients.length);
expect(middleware2).toHaveBeenCalledTimes(clients.length);
});

it('throws an error and calls logError', () => {
const error = new Error('middleware error');
const middleware = jest.fn(() => { throw error; });

try {
service.applyMiddleware([middleware]);
} catch (e) {
expectLogFunctionToHaveBeenCalledWithMessage(
mockLoggingService.logError.mock.calls[0],
`[frontend-auth] ${error.message}`,
);
}
});
});

describe('getAuthenticatedHttpClient', () => {
beforeEach(() => {
console.error = jest.fn();
Expand Down
21 changes: 21 additions & 0 deletions src/auth/MockAuthService.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ class MockAuthService {
this.httpClient = axios.create();
}

/**
* A Jest mock function (jest.fn())
*
* Applies middleware to the axios instances in this service.
*
* @param {Array} middleware Middleware to apply.
*/
applyMiddleware(middleware = []) {
const clients = [
this.authenticatedHttpClient, this.httpClient,
this.cachedAuthenticatedHttpClient, this.cachedHttpClient,
];
try {
(middleware).forEach((middlewareFn) => {
clients.forEach((client) => client && middlewareFn(client));
});
} catch (error) {
throw new Error(`Failed to apply middleware: ${error.message}.`);
}
}

/**
* A Jest mock function (jest.fn())
*
Expand Down
4 changes: 4 additions & 0 deletions src/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ function applyOverrideHandlers(overrides) {
* to use.
* @param {*} [options.analyticsService=SegmentAnalyticsService] The `AnalyticsService`
* implementation to use.
* @param {*} [options.authMiddleware=[]] An array of middleware to apply to http clients in the auth service.
* @param {*} [options.requireAuthenticatedUser=false] If true, turns on automatic login
* redirection for unauthenticated users. Defaults to false, meaning that by default the
* application will allow anonymous/unauthenticated sessions.
Expand All @@ -209,6 +210,7 @@ export async function initialize({
loggingService = NewRelicLoggingService,
analyticsService = SegmentAnalyticsService,
authService = AxiosJwtAuthService,
authMiddleware = [],
requireAuthenticatedUser: requireUser = false,
hydrateAuthenticatedUser: hydrateUser = false,
messages,
Expand All @@ -235,7 +237,9 @@ export async function initialize({
configureAuth(authService, {
loggingService: getLoggingService(),
config: getConfig(),
middleware: authMiddleware,
});

await handlers.auth(requireUser, hydrateUser);
publish(APP_AUTH_INITIALIZED);

Expand Down
1 change: 1 addition & 0 deletions src/initialize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('initialize', () => {
expect(configureAuth).toHaveBeenCalledWith(AxiosJwtAuthService, {
loggingService: getLoggingService(),
config,
middleware: [],
});
expect(configureAnalytics).toHaveBeenCalledWith(SegmentAnalyticsService, {
config,
Expand Down

0 comments on commit 3492930

Please sign in to comment.