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