diff --git a/.env.development b/.env.development
index 26bd22668..5ad65ae2f 100644
--- a/.env.development
+++ b/.env.development
@@ -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=
diff --git a/.env.test b/.env.test
index 26bd22668..5ad65ae2f 100644
--- a/.env.test
+++ b/.env.test
@@ -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=
diff --git a/example/index.jsx b/example/index.jsx
index ff5513e67..37e65b8f2 100644
--- a/example/index.jsx
+++ b/example/index.jsx
@@ -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';
@@ -32,7 +33,7 @@ subscribe(APP_READY, () => {
});
subscribe(APP_INIT_ERROR, (error) => {
- ReactDOM.render(, document.getElementById('root'));
+ ReactDOM.render(, document.getElementById('root'));
});
initialize({
diff --git a/src/config.js b/src/config.js
index b6d3532a5..0f12bc41b 100644
--- a/src/config.js
+++ b/src/config.js
@@ -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,
};
/**
@@ -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
*/
diff --git a/src/initialize.js b/src/initialize.js
index 8312c9d6c..f27fe4d00 100644
--- a/src/initialize.js
+++ b/src/initialize.js
@@ -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';
@@ -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)
@@ -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.
@@ -224,6 +253,7 @@ export async function initialize({
// Configuration
await handlers.config();
+ await runtimeConfig();
publish(APP_CONFIG_INITIALIZED);
// Logging
diff --git a/src/initialize.test.js b/src/initialize.test.js
index 545b6e87c..70b4f1018 100644
--- a/src/initialize.test.js
+++ b/src/initialize.test.js
@@ -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: 'openedx@example.com',
+ 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();
@@ -240,4 +278,81 @@ 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,
+ middleware: [],
+ });
+ 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,
+ middleware: [],
+ });
+ 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();
+ });
});
diff --git a/src/setupTest.js b/src/setupTest.js
index 5bac1e543..310f306f2 100644
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -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