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

feat: MFE configuration at runtime (#335) #4

Merged
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=
MFE_CONFIG_API_URL=
APP_ID=
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
IGNORED_ERROR_REGEX=
MFE_CONFIG_API_URL=
APP_ID=
3 changes: 2 additions & 1 deletion example/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from '@edx/frontend-platform/react';
import { APP_INIT_ERROR, APP_READY, initialize } from '@edx/frontend-platform';
import { subscribe } from '@edx/frontend-platform/pubSub';
import { IntlProvider } from '@edx/frontend-platform/i18n';

import './index.scss';
import ExamplePage from './ExamplePage';
Expand All @@ -32,7 +33,7 @@ subscribe(APP_READY, () => {
});

subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
ReactDOM.render(<IntlProvider><ErrorPage message={error.message} /></IntlProvider>, document.getElementById('root'));
});

initialize({
Expand Down
4 changes: 4 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ let config = {
LOGO_TRADEMARK_URL: process.env.LOGO_TRADEMARK_URL,
LOGO_WHITE_URL: process.env.LOGO_WHITE_URL,
FAVICON_URL: process.env.FAVICON_URL,
MFE_CONFIG_API_URL: process.env.MFE_CONFIG_API_URL,
APP_ID: process.env.APP_ID,
};

/**
Expand Down Expand Up @@ -193,4 +195,6 @@ export function ensureConfig(keys, requester = 'unspecified application code') {
* @property {string} LOGO_TRADEMARK_URL
* @property {string} LOGO_WHITE_URL
* @property {string} FAVICON_URL
* @property {string} MFE_CONFIG_API_URL
* @property {string} APP_ID
*/
32 changes: 31 additions & 1 deletion src/initialize.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ import {
publish,
} from './pubSub';
// eslint-disable-next-line import/no-cycle
import { getConfig } from './config';
import {
getConfig, mergeConfig,
} from './config';
import {
configure as configureLogging, getLoggingService, NewRelicLoggingService, logError,
} from './logging';
Expand All @@ -76,6 +78,7 @@ import {
APP_ANALYTICS_INITIALIZED,
APP_READY, APP_INIT_ERROR,
} from './constants';
import configureCache from './auth/LocalForageCache';

/**
* A browser history or memory history object created by the [history](https://github.com/ReactTraining/history)
Expand Down Expand Up @@ -127,6 +130,32 @@ export async function auth(requireUser, hydrateUser) {
hydrateAuthenticatedUser();
}
}
/*
* Set or overrides configuration through an API.
* This method allows runtime configuration.
* Set a basic configuration when an error happen and allow initError and display the ErrorPage.
*/

export async function runtimeConfig() {
try {
const { MFE_CONFIG_API_URL, APP_ID } = getConfig();

if (MFE_CONFIG_API_URL) {
const apiConfig = { headers: { accept: 'application/json' } };
const apiService = await configureCache();

const params = new URLSearchParams();
params.append('mfe', APP_ID);
const url = `${MFE_CONFIG_API_URL}?${params.toString()}`;

const { data } = await apiService.get(url, apiConfig);
mergeConfig(data);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error with config API', error.message);
}
}

/**
* The default handler for the initialization lifecycle's `analytics` phase.
Expand Down Expand Up @@ -222,6 +251,7 @@ export async function initialize({

// Configuration
await handlers.config();
await runtimeConfig();
publish(APP_CONFIG_INITIALIZED);

// Logging
Expand Down
113 changes: 113 additions & 0 deletions src/initialize.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,51 @@ import {
import { configure as configureAnalytics, SegmentAnalyticsService } from './analytics';
import { configure as configureI18n } from './i18n';
import { getConfig } from './config';
import configureCache from './auth/LocalForageCache';

jest.mock('./logging');
jest.mock('./auth');
jest.mock('./analytics');
jest.mock('./i18n');
jest.mock('./auth/LocalForageCache');

let config = null;
const newConfig = {
common: {
SITE_NAME: 'Test Case',
LOGO_URL: 'http://test.example.com:18000/theme/logo.png',
LOGO_TRADEMARK_URL: 'http://test.example.com:18000/theme/logo.png',
LOGO_WHITE_URL: 'http://test.example.com:18000/theme/logo.png',
ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload',
FAVICON_URL: 'http://test.example.com:18000/theme/favicon.ico',
CSRF_TOKEN_API_PATH: '/csrf/api/v1/token',
DISCOVERY_API_BASE_URL: 'http://test.example.com:18381',
PUBLISHER_BASE_URL: 'http://test.example.com:18400',
ECOMMERCE_BASE_URL: 'http://test.example.com:18130',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference',
LEARNING_BASE_URL: 'http://test.example.com:2000',
LMS_BASE_URL: 'http://test.example.com:18000',
LOGIN_URL: 'http://test.example.com:18000/login',
LOGOUT_URL: 'http://test.example.com:18000/logout',
STUDIO_BASE_URL: 'http://studio.example.com:18010',
MARKETING_SITE_BASE_URL: 'http://test.example.com:18000',
ORDER_HISTORY_URL: 'http://test.example.com:1996/orders',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://test.example.com:18000/login_refresh',
SEGMENT_KEY: '',
USER_INFO_COOKIE_NAME: 'edx-user-info',
IGNORED_ERROR_REGEX: '',
CREDENTIALS_BASE_URL: 'http://test.example.com:18150',
},
auth: {
INFO_EMAIL: '[email protected]',
ACTIVATION_EMAIL_SUPPORT_LINK: 'http//support.test.com',
},
learning: {
LEGACY_THEME_NAME: 'example',
DISCUSSIONS_MFE_BASE_URL: 'http://test.example.com:2002',
},
};

describe('initialize', () => {
beforeEach(() => {
config = getConfig();
Expand Down Expand Up @@ -239,4 +277,79 @@ describe('initialize', () => {
expect(overrideHandlers.ready).not.toHaveBeenCalled();
expect(overrideHandlers.initError).toHaveBeenCalledWith(new Error('uhoh!'));
});

it('should initialize the app with runtime configuration', async () => {
config.MFE_CONFIG_API_URL = 'http://localhost:18000/api/mfe/v1/config';
config.APP_ID = 'auth';
configureCache.mockReturnValueOnce(Promise.resolve({
get: (url) => {
const params = new URL(url).search;
const mfe = new URLSearchParams(params).get('mfe');
return ({ data: { ...newConfig.common, ...newConfig[mfe] } });
},
}));

const messages = { i_am: 'a message' };
await initialize({ messages });

expect(configureCache).toHaveBeenCalled();
expect(configureLogging).toHaveBeenCalledWith(NewRelicLoggingService, { config });
expect(configureAuth).toHaveBeenCalledWith(AxiosJwtAuthService, {
loggingService: getLoggingService(),
config,
});
expect(configureAnalytics).toHaveBeenCalledWith(SegmentAnalyticsService, {
config,
loggingService: getLoggingService(),
httpClient: getAuthenticatedHttpClient(),
});
expect(configureI18n).toHaveBeenCalledWith({
messages,
config,
loggingService: getLoggingService(),
});

expect(fetchAuthenticatedUser).toHaveBeenCalled();
expect(ensureAuthenticatedUser).not.toHaveBeenCalled();
expect(hydrateAuthenticatedUser).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
expect(config.SITE_NAME).toBe(newConfig.common.SITE_NAME);
expect(config.INFO_EMAIL).toBe(newConfig.auth.INFO_EMAIL);
expect(Object.values(config).includes(newConfig.learning.DISCUSSIONS_MFE_BASE_URL)).toBeFalsy();
});

it('should initialize the app with the build config when runtime configuration fails', async () => {
config.MFE_CONFIG_API_URL = 'http://localhost:18000/api/mfe/v1/config';
// eslint-disable-next-line no-console
console.error = jest.fn();
configureCache.mockReturnValueOnce(Promise.reject(new Error('Api fails')));

const messages = { i_am: 'a message' };
await initialize({
messages,
});

expect(configureCache).toHaveBeenCalled();
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith('Error with config API', 'Api fails');
expect(configureLogging).toHaveBeenCalledWith(NewRelicLoggingService, { config });
expect(configureAuth).toHaveBeenCalledWith(AxiosJwtAuthService, {
loggingService: getLoggingService(),
config,
});
expect(configureAnalytics).toHaveBeenCalledWith(SegmentAnalyticsService, {
config,
loggingService: getLoggingService(),
httpClient: getAuthenticatedHttpClient(),
});
expect(configureI18n).toHaveBeenCalledWith({
messages,
config,
loggingService: getLoggingService(),
});
expect(fetchAuthenticatedUser).toHaveBeenCalled();
expect(ensureAuthenticatedUser).not.toHaveBeenCalled();
expect(hydrateAuthenticatedUser).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();
});
});
2 changes: 2 additions & 0 deletions src/setupTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ process.env.LOGO_URL = 'https://edx-cdn.org/v3/default/logo.svg';
process.env.LOGO_TRADEMARK_URL = 'https://edx-cdn.org/v3/default/logo-trademark.svg';
process.env.LOGO_WHITE_URL = 'https://edx-cdn.org/v3/default/logo-white.svg';
process.env.FAVICON_URL = 'https://edx-cdn.org/v3/default/favicon.ico';
process.env.MFE_CONFIG_API_URL = '';
process.env.APP_ID = '';

/* Auth test variables

Expand Down