diff --git a/lms/djangoapps/mfe_config_api/tests/test_views.py b/lms/djangoapps/mfe_config_api/tests/test_views.py index 0dfc63e82790..da63c2a0ad90 100644 --- a/lms/djangoapps/mfe_config_api/tests/test_views.py +++ b/lms/djangoapps/mfe_config_api/tests/test_views.py @@ -7,11 +7,13 @@ import ddt from django.core.cache import cache from django.conf import settings -from django.test import override_settings +from django.test import SimpleTestCase, override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from lms.djangoapps.mfe_config_api.views import mfe_name_to_app_id + # Default legacy configuration values, used in tests to build a correct expected response default_legacy_config = { "COURSE_ABOUT_TWITTER_ACCOUNT": "@YourPlatformTwitterAccount", @@ -294,3 +296,313 @@ def side_effect(key, default=None): # Value in original MFE_CONFIG not overridden by catalog config should be preserved self.assertEqual(data["PRESERVED_SETTING"], "preserved") + + +class MfeNameToAppIdTests(SimpleTestCase): + """Tests for the mfe_name_to_app_id helper.""" + + def test_simple_name(self): + self.assertEqual(mfe_name_to_app_id("authn"), "org.openedx.frontend.app.authn") + + def test_kebab_case_name(self): + self.assertEqual( + mfe_name_to_app_id("learner-dashboard"), + "org.openedx.frontend.app.learnerDashboard", + ) + + def test_multi_segment_kebab(self): + self.assertEqual( + mfe_name_to_app_id("course-authoring"), + "org.openedx.frontend.app.courseAuthoring", + ) + + def test_three_segment_kebab(self): + self.assertEqual( + mfe_name_to_app_id("admin-portal-enterprise"), + "org.openedx.frontend.app.adminPortalEnterprise", + ) + + +class FrontendSiteConfigTestCase(APITestCase): + """Tests for the FrontendSiteConfigView endpoint.""" + + def setUp(self): + self.url = reverse("mfe_config_api:frontend_site_config") + cache.clear() + return super().setUp() + + @override_settings(ENABLE_MFE_CONFIG_API=False) + def test_404_when_disabled(self): + """API returns 404 when ENABLE_MFE_CONFIG_API is False.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_site_level_keys_translated(self, configuration_helpers_mock): + """Keys that map to RequiredSiteConfig/OptionalSiteConfig appear at the top level in camelCase.""" + mfe_config = { + "SITE_NAME": "Test Site", + "BASE_URL": "https://apps.example.com", + "LMS_BASE_URL": "https://courses.example.com", + "LOGIN_URL": "https://courses.example.com/login", + "LOGOUT_URL": "https://courses.example.com/logout", + "LOGO_URL": "https://courses.example.com/logo.png", + "ACCESS_TOKEN_COOKIE_NAME": "edx-jwt", + "LANGUAGE_PREFERENCE_COOKIE_NAME": "lang-pref", + "USER_INFO_COOKIE_NAME": "edx-user-info", + "CSRF_TOKEN_API_PATH": "/csrf/api/v1/token", + "REFRESH_ACCESS_TOKEN_API_PATH": "/login_refresh", + "SEGMENT_KEY": "abc123", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return mfe_config + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # RequiredSiteConfig + self.assertEqual(data["siteName"], "Test Site") + self.assertEqual(data["baseUrl"], "https://apps.example.com") + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + self.assertEqual(data["loginUrl"], "https://courses.example.com/login") + self.assertEqual(data["logoutUrl"], "https://courses.example.com/logout") + # OptionalSiteConfig + self.assertEqual(data["headerLogoImageUrl"], "https://courses.example.com/logo.png") + self.assertEqual(data["accessTokenCookieName"], "edx-jwt") + self.assertEqual(data["languagePreferenceCookieName"], "lang-pref") + self.assertEqual(data["userInfoCookieName"], "edx-user-info") + self.assertEqual(data["csrfTokenApiPath"], "/csrf/api/v1/token") + self.assertEqual(data["refreshAccessTokenApiPath"], "/login_refresh") + self.assertEqual(data["segmentKey"], "abc123") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_unmapped_keys_in_app_config(self, configuration_helpers_mock): + """Keys that don't map to SiteConfig fields are included in each app's config.""" + mfe_config = { + "LMS_BASE_URL": "https://courses.example.com", + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + "STUDIO_BASE_URL": "https://studio.example.com", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return mfe_config + if key == "MFE_CONFIG_OVERRIDES": + return {"authn": {"SOME_KEY": "value"}} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + # Site-level key translated to top level + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + # Unmapped MFE_CONFIG keys appear in commonAppConfig (not at the top level) + self.assertNotIn("CREDENTIALS_BASE_URL", data) + common = data["commonAppConfig"] + self.assertEqual(common["CREDENTIALS_BASE_URL"], "https://credentials.example.com") + self.assertEqual(common["STUDIO_BASE_URL"], "https://studio.example.com") + # Legacy config keys also appear in commonAppConfig + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, common) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_apps_from_overrides(self, configuration_helpers_mock): + """Each MFE_CONFIG_OVERRIDES entry becomes an app with shared base config + overrides.""" + mfe_config_overrides = { + "authn": { + "ALLOW_PUBLIC_ACCOUNT_CREATION": True, + "ACTIVATION_EMAIL_SUPPORT_LINK": None, + }, + "learner-dashboard": { + "LEARNING_BASE_URL": "http://apps.local.openedx.io:2000", + "ENABLE_PROGRAMS": False, + }, + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "SHARED_SETTING": "shared_value", + } + if key == "MFE_CONFIG_OVERRIDES": + return mfe_config_overrides + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertIn("apps", data) + self.assertEqual(len(data["apps"]), 2) + + # Shared config (unmapped MFE_CONFIG keys + legacy config) is in commonAppConfig. + common = data["commonAppConfig"] + self.assertEqual(common["SHARED_SETTING"], "shared_value") + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, common) + + # Apps should be sorted by MFE name; each carries only its own overrides. + authn = data["apps"][0] + self.assertEqual(authn["appId"], "org.openedx.frontend.app.authn") + self.assertEqual(authn["config"]["ALLOW_PUBLIC_ACCOUNT_CREATION"], True) + self.assertIsNone(authn["config"]["ACTIVATION_EMAIL_SUPPORT_LINK"]) + # Shared keys are NOT duplicated into per-app config + self.assertNotIn("SHARED_SETTING", authn["config"]) + + dashboard = data["apps"][1] + self.assertEqual(dashboard["appId"], "org.openedx.frontend.app.learnerDashboard") + self.assertEqual(dashboard["config"]["LEARNING_BASE_URL"], "http://apps.local.openedx.io:2000") + self.assertEqual(dashboard["config"]["ENABLE_PROGRAMS"], False) + self.assertNotIn("SHARED_SETTING", dashboard["config"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_app_overrides_separate_from_common(self, configuration_helpers_mock): + """App-specific overrides appear in per-app config; shared keys in commonAppConfig.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {"SOME_KEY": "base_value"} + if key == "MFE_CONFIG_OVERRIDES": + return {"authn": {"SOME_KEY": "overridden_value"}} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertEqual(data["commonAppConfig"]["SOME_KEY"], "base_value") + self.assertEqual(data["apps"][0]["config"]["SOME_KEY"], "overridden_value") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_site_level_keys_stripped_from_app_overrides(self, configuration_helpers_mock): + """Site-level keys in MFE_CONFIG_OVERRIDES are stripped from app config.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "LOGO_URL": "https://courses.example.com/logo.png", + } + if key == "MFE_CONFIG_OVERRIDES": + return { + "authn": { + "BASE_URL": "https://authn.example.com", + "LOGIN_URL": "https://authn.example.com/login", + "APP_SPECIFIC_KEY": "app_value", + }, + } + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + app_config = data["apps"][0]["config"] + # Site-level keys from overrides must not appear in app config + self.assertNotIn("BASE_URL", app_config) + self.assertNotIn("LOGIN_URL", app_config) + # Non-site-level override keys are kept + self.assertEqual(app_config["APP_SPECIFIC_KEY"], "app_value") + # Site-level keys from overrides also must not appear in commonAppConfig + self.assertNotIn("BASE_URL", data["commonAppConfig"]) + self.assertNotIn("LOGIN_URL", data["commonAppConfig"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_no_apps_when_no_overrides(self, configuration_helpers_mock): + """The apps key is omitted when MFE_CONFIG_OVERRIDES is empty.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {"LMS_BASE_URL": "https://courses.example.com"} + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertNotIn("apps", data) + # commonAppConfig is still present with legacy keys + self.assertIn("commonAppConfig", data) + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, data["commonAppConfig"]) + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_unmapped_keys_in_common_app_config_without_overrides(self, configuration_helpers_mock): + """Unmapped MFE_CONFIG keys appear in commonAppConfig even without overrides.""" + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return { + "LMS_BASE_URL": "https://courses.example.com", + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + "STUDIO_BASE_URL": "https://studio.example.com", + } + if key == "MFE_CONFIG_OVERRIDES": + return {} + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + # Site-level key is promoted to the top level + self.assertEqual(data["lmsBaseUrl"], "https://courses.example.com") + # Unmapped keys are preserved in commonAppConfig + common = data["commonAppConfig"] + self.assertEqual(common["CREDENTIALS_BASE_URL"], "https://credentials.example.com") + self.assertEqual(common["STUDIO_BASE_URL"], "https://studio.example.com") + + @patch("lms.djangoapps.mfe_config_api.views.configuration_helpers") + def test_invalid_override_entry_skipped(self, configuration_helpers_mock): + """Non-dict override entries are silently skipped.""" + mfe_config_overrides = { + "authn": {"SOME_KEY": "value"}, + "broken": "not-a-dict", + } + + def side_effect(key, default=None): + if key == "MFE_CONFIG": + return {} + if key == "MFE_CONFIG_OVERRIDES": + return mfe_config_overrides + return default + configuration_helpers_mock.get_value.side_effect = side_effect + + response = self.client.get(self.url) + data = response.json() + + self.assertEqual(len(data["apps"]), 1) + self.assertEqual(data["apps"][0]["appId"], "org.openedx.frontend.app.authn") + + def test_from_django_settings(self): + """When there is no site configuration, the API uses django settings.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + + # settings.MFE_CONFIG in test.py has LANGUAGE_PREFERENCE_COOKIE_NAME and LOGO_URL + self.assertEqual(data.get("languagePreferenceCookieName"), "example-language-preference") + self.assertEqual(data.get("headerLogoImageUrl"), "https://courses.example.com/logo.png") + + # Legacy config keys live in commonAppConfig + for legacy_key in default_legacy_config: + self.assertIn(legacy_key, data["commonAppConfig"]) + + # MFE_CONFIG_OVERRIDES in test.py has mymfe and yourmfe + self.assertIn("apps", data) + app_ids = [app["appId"] for app in data["apps"]] + self.assertIn("org.openedx.frontend.app.mymfe", app_ids) + self.assertIn("org.openedx.frontend.app.yourmfe", app_ids) + + # Site-level keys from overrides (LANGUAGE_PREFERENCE_COOKIE_NAME, + # LOGO_URL in test settings) are stripped from per-app config + for app in data["apps"]: + self.assertNotIn("LANGUAGE_PREFERENCE_COOKIE_NAME", app["config"]) + self.assertNotIn("LOGO_URL", app["config"]) diff --git a/lms/djangoapps/mfe_config_api/urls.py b/lms/djangoapps/mfe_config_api/urls.py index 8f63406a9afd..02bd7b18a7a1 100644 --- a/lms/djangoapps/mfe_config_api/urls.py +++ b/lms/djangoapps/mfe_config_api/urls.py @@ -1,10 +1,13 @@ -""" URLs configuration for the mfe api.""" +"""URLs configuration for the mfe api.""" from django.urls import path -from lms.djangoapps.mfe_config_api.views import MFEConfigView +from lms.djangoapps.mfe_config_api.views import MFEConfigView, FrontendSiteConfigView -app_name = 'mfe_config_api' +app_name = "mfe_config_api" urlpatterns = [ - path('', MFEConfigView.as_view(), name='config'), + path("", MFEConfigView.as_view(), name="config"), + path( + "/frontend_site", FrontendSiteConfigView.as_view(), name="frontend_site_config" + ), ] diff --git a/lms/djangoapps/mfe_config_api/views.py b/lms/djangoapps/mfe_config_api/views.py index 0ab71b151b88..93b266053c54 100644 --- a/lms/djangoapps/mfe_config_api/views.py +++ b/lms/djangoapps/mfe_config_api/views.py @@ -13,6 +13,67 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +def get_legacy_config() -> dict: + """ + Return legacy configuration values available in either site configuration or django settings. + """ + return { + "ENABLE_COURSE_SORTING_BY_START_DATE": configuration_helpers.get_value( + "ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"], + ), + "HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID": configuration_helpers.get_value( + "homepage_promo_video_youtube_id", None + ), + "HOMEPAGE_COURSE_MAX": configuration_helpers.get_value( + "HOMEPAGE_COURSE_MAX", settings.HOMEPAGE_COURSE_MAX + ), + "COURSE_ABOUT_TWITTER_ACCOUNT": configuration_helpers.get_value( + "course_about_twitter_account", settings.PLATFORM_TWITTER_ACCOUNT + ), + "NON_BROWSABLE_COURSES": not settings.FEATURES.get("COURSES_ARE_BROWSABLE"), + "ENABLE_COURSE_DISCOVERY": settings.FEATURES["ENABLE_COURSE_DISCOVERY"], + } + + +def get_mfe_config() -> dict: + """Return common MFE configuration from settings or site configuration. + + Returns: + A dictionary of configuration values shared across all MFEs. + """ + mfe_config = ( + configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) or {} + ) + if not isinstance(mfe_config, dict): + return {} + return mfe_config + + +def get_mfe_config_overrides() -> dict: + """Return all MFE-specific overrides from settings or site configuration. + + Returns: + A dictionary keyed by MFE name, where each value is a dict of + per-MFE overrides. Non-dict entries are filtered out. + """ + mfe_config_overrides = ( + configuration_helpers.get_value( + "MFE_CONFIG_OVERRIDES", + settings.MFE_CONFIG_OVERRIDES, + ) + or {} + ) + if not isinstance(mfe_config_overrides, dict): + return {} + + return { + name: overrides + for name, overrides in mfe_config_overrides.items() + if isinstance(overrides, dict) + } + + class MFEConfigView(APIView): """ Provides an API endpoint to get the MFE configuration from settings (or site configuration). @@ -22,7 +83,7 @@ class MFEConfigView(APIView): @apidocs.schema( parameters=[ apidocs.query_parameter( - 'mfe', + "mfe", str, description="Name of an MFE (a.k.a. an APP_ID).", ), @@ -38,8 +99,8 @@ def get(self, request): See [DEPR ticket](https://github.com/openedx/edx-platform/issues/37210) for more details. - The compatability means that settings from the legacy locations will continue to work but - the settings listed below in the `_get_legacy_config` function should be added to the MFE + The compatibility means that settings from the legacy locations will continue to work but + the settings listed below in the `get_legacy_config` function should be added to the MFE config by operators. **Usage** @@ -72,49 +133,159 @@ def get(self, request): if not settings.ENABLE_MFE_CONFIG_API: return HttpResponseNotFound() - # Get values from django settings (level 6) or site configuration (level 5) - legacy_config = self._get_legacy_config() + mfe_name = ( + str(request.query_params.get("mfe")) + if request.query_params.get("mfe") + else None + ) - # Get values from mfe configuration, either from django settings (level 4) or site configuration (level 3) - mfe_config = configuration_helpers.get_value("MFE_CONFIG", settings.MFE_CONFIG) + merged_config = ( + get_legacy_config() + | get_mfe_config() + | get_mfe_config_overrides().get(mfe_name, {}) + ) - # Get values from mfe overrides, either from django settings (level 2) or site configuration (level 1) - mfe_config_overrides = {} - if request.query_params.get("mfe"): - mfe = str(request.query_params.get("mfe")) - app_config = configuration_helpers.get_value( - "MFE_CONFIG_OVERRIDES", - settings.MFE_CONFIG_OVERRIDES, - ) - mfe_config_overrides = app_config.get(mfe, {}) + return JsonResponse(merged_config, status=status.HTTP_200_OK) - # Merge the three configs in the order of precedence - merged_config = legacy_config | mfe_config | mfe_config_overrides - return JsonResponse(merged_config, status=status.HTTP_200_OK) +# Translation map from legacy SCREAMING_SNAKE_CASE MFE_CONFIG keys to +# camelCase field names matching frontend-base's RequiredSiteConfig and +# OptionalSiteConfig interfaces. +# See https://github.com/openedx/frontend-base/blob/main/types.ts +SITE_CONFIG_TRANSLATION_MAP = { + # RequiredSiteConfig + "SITE_NAME": "siteName", + "BASE_URL": "baseUrl", + "LMS_BASE_URL": "lmsBaseUrl", + "LOGIN_URL": "loginUrl", + "LOGOUT_URL": "logoutUrl", + # OptionalSiteConfig + "LOGO_URL": "headerLogoImageUrl", + "ACCESS_TOKEN_COOKIE_NAME": "accessTokenCookieName", + "LANGUAGE_PREFERENCE_COOKIE_NAME": "languagePreferenceCookieName", + "USER_INFO_COOKIE_NAME": "userInfoCookieName", + "CSRF_TOKEN_API_PATH": "csrfTokenApiPath", + "REFRESH_ACCESS_TOKEN_API_PATH": "refreshAccessTokenApiPath", + "SEGMENT_KEY": "segmentKey", +} - @staticmethod - def _get_legacy_config() -> dict: - """ - Return legacy configuration values available in either site configuration or django settings. + +def mfe_name_to_app_id(mfe_name: str) -> str: + """Convert a legacy MFE name to a frontend-base appId. + + Converts kebab-case MFE names (e.g. ``"learner-dashboard"``) to + reverse-domain appIds (e.g. ``"org.openedx.frontend.app.learnerDashboard"``). + """ + parts = mfe_name.split("-") + camel_case = parts[0] + "".join(part.capitalize() for part in parts[1:]) + return f"org.openedx.frontend.app.{camel_case}" + + +class FrontendSiteConfigView(APIView): + """ + Provides an API endpoint intended for frontend site configuration. + + It exists to support incremental migration to a frontend-site-oriented config surface. + """ + + @method_decorator(cache_page(settings.MFE_CONFIG_API_CACHE_TIMEOUT)) + def get(self, request): """ - return { - "ENABLE_COURSE_SORTING_BY_START_DATE": configuration_helpers.get_value( - "ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"] - ), - "HOMEPAGE_PROMO_VIDEO_YOUTUBE_ID": configuration_helpers.get_value( - "homepage_promo_video_youtube_id", - None - ), - "HOMEPAGE_COURSE_MAX": configuration_helpers.get_value( - "HOMEPAGE_COURSE_MAX", - settings.HOMEPAGE_COURSE_MAX - ), - "COURSE_ABOUT_TWITTER_ACCOUNT": configuration_helpers.get_value( - "course_about_twitter_account", - settings.PLATFORM_TWITTER_ACCOUNT - ), - "NON_BROWSABLE_COURSES": not settings.FEATURES.get("COURSES_ARE_BROWSABLE"), - "ENABLE_COURSE_DISCOVERY": settings.FEATURES["ENABLE_COURSE_DISCOVERY"], + Return frontend site configuration as converted from legacy MFE configuration. + + Translates the flat SCREAMING_SNAKE_CASE ``MFE_CONFIG`` / ``MFE_CONFIG_OVERRIDES`` + settings into the camelCase structure expected by `frontend-base SiteConfig + `_. + + * Keys that correspond to ``RequiredSiteConfig`` or ``OptionalSiteConfig`` fields + are promoted to the top level under their camelCase name. + * All remaining keys become the base ``config`` for every app entry. + * Each entry in ``MFE_CONFIG_OVERRIDES`` becomes an element of the ``apps`` array, + with its override dict merged on top of the shared base config. + + **Usage** + + GET /api/mfe_config/v1/frontend_site + + **GET Response Values** + ``` + { + "siteName": "My Open edX Site", + "baseUrl": "https://apps.example.com", + "lmsBaseUrl": "https://courses.example.com", + "loginUrl": "https://courses.example.com/login", + "logoutUrl": "https://courses.example.com/logout", + "headerLogoImageUrl": "https://courses.example.com/logo.png", + "accessTokenCookieName": "edx-jwt-cookie-header-payload", + "languagePreferenceCookieName": "openedx-language-preference", + "userInfoCookieName": "edx-user-info", + "csrfTokenApiPath": "/csrf/api/v1/token", + "refreshAccessTokenApiPath": "/login_refresh", + "segmentKey": null, + "commonAppConfig": { + "CREDENTIALS_BASE_URL": "https://credentials.example.com", + "STUDIO_BASE_URL": "https://studio.example.com", + ... + }, + "apps": [ + { + "appId": "org.openedx.frontend.app.authn", + "config": { + "ACTIVATION_EMAIL_SUPPORT_LINK": null, + "ALLOW_PUBLIC_ACCOUNT_CREATION": true + } + }, + { + "appId": "org.openedx.frontend.app.learnerDashboard", + "config": { + "LEARNING_BASE_URL": "http://apps.local.openedx.io:2000", + "ENABLE_PROGRAMS": false + } + } + ] } + ``` + """ + if not settings.ENABLE_MFE_CONFIG_API: + return HttpResponseNotFound() + + # Collect configuration from all sources. + mfe_config = get_mfe_config() + mfe_config_overrides = get_mfe_config_overrides() + + # Split MFE_CONFIG into site-level (translated to camelCase) and app-level. + # Legacy config seeds common_app_config at lowest precedence. + site_config = {} + common_app_config = get_legacy_config() + for key, value in mfe_config.items(): + if key in SITE_CONFIG_TRANSLATION_MAP: + site_config[SITE_CONFIG_TRANSLATION_MAP[key]] = value + else: + common_app_config[key] = value + + # Always include the shared app config so that unmapped MFE_CONFIG + # keys are available even when no per-app overrides are defined. + site_config["commonAppConfig"] = common_app_config + + # Build the apps array: each app only gets its own overrides: + # frontend-base merges commonAppConfig into each app's config. + # Site-level keys are stripped from per-app overrides so they don't + # leak into app config. + apps = [] + for mfe_name in sorted(mfe_config_overrides): + overrides = { + k: v + for k, v in mfe_config_overrides[mfe_name].items() + if k not in SITE_CONFIG_TRANSLATION_MAP + } + apps.append( + { + "appId": mfe_name_to_app_id(mfe_name), + "config": overrides, + } + ) + + if apps: + site_config["apps"] = apps + + return JsonResponse(site_config, status=status.HTTP_200_OK)