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: add mfe config api #30473

Merged
merged 10 commits into from
Jul 8, 2022
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: 1 addition & 1 deletion .github/workflows/pylint-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
- module-name: lms-1
path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/"
- module-name: lms-2
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py"
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py"
- module-name: openedx-1
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/"
- module-name: openedx-2
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/unit-test-shards.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"lms/djangoapps/tests/",
"lms/djangoapps/user_tours/",
"lms/djangoapps/verify_student/",
"lms/djangoapps/mfe_config_api/",
"lms/envs/",
"lms/lib/",
"lms/tests.py"
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
0001 MFE CONFIG API
####################

Status
******

Accepted

Context
*******

Currently, MFE settings are set via command line environment variables or an .env file that is read during the build process, causing the operators to rebuild mfes each time when any variables are changed. The creation of the ``mfe_config_api`` allows configuration at runtime and avoids rebuilds.
`MFE Configuration during Runtime`_.

Decision
********

- A lightweight API will be created that returns the mfe configuration variables from the site configuration or django settings. `PR Discussion about django settings`_
- The API will be enabled or disabled using the setting ``ENABLE_MFE_CONFIG_API``.
- The API will take the mfe configuration in the ``MFE_CONFIG`` keyset in the site configuration (admin > site configuration > your domain) or in django settings.
- This API allows to consult the configurations by specific MFE. Making a request like ``api/v1/mfe_config?mfe=mymfe`` will return the configuration defined in ``MFE_CONFIG_MYMFE`` merged with the ``MFE_CONFIG`` configuration.
- The API will have a mechanism to cache the response with ``MFE_CONFIG_API_CACHE_TIMEOUT`` variable.
- The API will live in lms/djangoapps because this is not something Studio needs to serve and it is a lightweight API. `PR Discussion`_
- The API will not require authentication or authorization.
- The API request and response will be like:

Request::

GET http://lms.base.com/api/v1/mfe_config?mfe=learning

Response::

{
"BASE_URL": "https://name_of_mfe.example.com",
"LANGUAGE_PREFERENCE_COOKIE_NAME": "example-language-preference",
"CREDENTIALS_BASE_URL": "https://credentials.example.com",
"DISCOVERY_API_BASE_URL": "https://discovery.example.com",
"LMS_BASE_URL": "https://courses.example.com",
"LOGIN_URL": "https://courses.example.com/login",
"LOGOUT_URL": "https://courses.example.com/logout",
"STUDIO_BASE_URL": "https://studio.example.com",
"LOGO_URL": "https://courses.example.com/logo.png"

}

Consequences
************

- We have to change all the mfes so that they take the information from the API. `Issue MFE runtime configuration in frontend-wg`_
- Initialize the MFE could have a delay due to the HTTP method.
- `Site configuration is going to be deprecated`_ so later we have to clean the code that uses site configuration.
- The operator is responsible for configuring the settings in site configuration or django settings.
- We can have duplicate keys in site configuration (example: we can have a logo definition for each mfe).
- If the request is made from a domain that does not have a site configuration, it returns django settings.

Rejected Alternatives
**********************

- It was not made as a plugin or IDA because it is a lightweight implementation `PR Discussion`_

References
**********

.. _MFE Configuration during Runtime: https://docs.google.com/document/d/1-FHIQmyeQZu3311x8eYUNMru4JX7Yb3UlqjmJxvM8do/edit?usp=sharing

.. _PR Discussion: https://github.com/openedx/edx-platform/pull/30473#issuecomment-1146176151

.. _Site configuration is going to be deprecated: https://github.com/openedx/platform-roadmap/issues/21

.. _Issue MFE runtime configuration in frontend-wg: https://github.com/openedx/frontend-wg/issues/103

.. _PR Discussion about django settings: https://github.com/openedx/edx-platform/pull/30473#discussion_r916263245
Empty file.
135 changes: 135 additions & 0 deletions lms/djangoapps/mfe_config_api/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Test the use cases of the views of the mfe api.
"""

from unittest.mock import call, patch

import ddt
from django.conf import settings
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase


@ddt.ddt
class MFEConfigTestCase(APITestCase):
"""
Test the use case that exposes the site configuration with the mfe api.
"""
def setUp(self):
self.mfe_config_api_url = reverse("mfe_config_api:config")
return super().setUp()

@patch("lms.djangoapps.mfe_config_api.views.configuration_helpers")
def test_get_mfe_config(self, configuration_helpers_mock):
"""Test the get mfe config from site configuration with the mfe api.
Expected result:
- The get_value method of the configuration_helpers in the views is called once with the
parameters ("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {})).
- The status of the response of the request is a HTTP_200_OK.
- The json of the response of the request is equal to the mocked configuration.
"""
configuration_helpers_mock.get_value.return_value = {"EXAMPLE_VAR": "value"}
response = self.client.get(self.mfe_config_api_url)

configuration_helpers_mock.get_value.assert_called_once_with("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {}))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json(), {"EXAMPLE_VAR": "value"})

@patch("lms.djangoapps.mfe_config_api.views.configuration_helpers")
def test_get_mfe_config_with_queryparam(self, configuration_helpers_mock):
"""Test the get mfe config with a query param from site configuration.
Expected result:
- The get_value method of the configuration_helpers in the views is called twice, once with the
parameters ("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {})) and once with the parameters
("MFE_CONFIG_MYMFE", getattr(settings, "MFE_CONFIG_MYMFE", {})).
and one for get_value("MFE_CONFIG_MYMFE", getattr(settings, "MFE_CONFIG_MYMFE", {})).
- The json of the response is the merge of both mocked configurations.
"""
configuration_helpers_mock.get_value.side_effect = [{"EXAMPLE_VAR": "value", "OTHER": "other"},
{"EXAMPLE_VAR": "mymfe_value"}]

response = self.client.get(f"{self.mfe_config_api_url}?mfe=mymfe")
self.assertEqual(response.status_code, status.HTTP_200_OK)
calls = [call("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {})),
call("MFE_CONFIG_MYMFE", getattr(settings, "MFE_CONFIG_MYMFE", {}))]
configuration_helpers_mock.get_value.assert_has_calls(calls)
self.assertEqual(response.json(), {"EXAMPLE_VAR": "mymfe_value", "OTHER": "other"})

@patch("lms.djangoapps.mfe_config_api.views.configuration_helpers")
@ddt.data(
[{}, {}, {}],
[{"EXAMPLE_VAR": "value"}, {}, {"EXAMPLE_VAR": "value"}],
[{}, {"EXAMPLE_VAR": "mymfe_value"}, {"EXAMPLE_VAR": "mymfe_value"}],
[{"EXAMPLE_VAR": "value"}, {"EXAMPLE_VAR": "mymfe_value"}, {"EXAMPLE_VAR": "mymfe_value"}],
[{"EXAMPLE_VAR": "value", "OTHER": "other"}, {"EXAMPLE_VAR": "mymfe_value"},
{"EXAMPLE_VAR": "mymfe_value", "OTHER": "other"}],
)
@ddt.unpack
def test_get_mfe_config_with_queryparam_multiple_configs(
self,
mfe_config,
mfe_config_mymfe,
expected_response,
configuration_helpers_mock
):
"""Test the get mfe config with a query param and different settings in mfe_config and mfe_config_mfe inside
the site configuration to test that the merge of the configurations is done correctly and mymfe config take
precedence.
In the ddt data the following structure is being passed:
[mfe_config, mfe_config_mymfe, expected_response]
Expected result:
- The get_value method of the configuration_helpers in the views is called twice, once with the
parameters ("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {})) and once with the parameters
("MFE_CONFIG_MYMFE", getattr(settings, "MFE_CONFIG_MYMFE", {})).
- The json of the response is the expected_response passed by ddt.data.
"""
configuration_helpers_mock.get_value.side_effect = [mfe_config, mfe_config_mymfe]

response = self.client.get(f"{self.mfe_config_api_url}?mfe=mymfe")
self.assertEqual(response.status_code, status.HTTP_200_OK)
calls = [call("MFE_CONFIG", getattr(settings, "MFE_CONFIG", {})),
call("MFE_CONFIG_MYMFE", getattr(settings, "MFE_CONFIG_MYMFE", {}))]
configuration_helpers_mock.get_value.assert_has_calls(calls)
self.assertEqual(response.json(), expected_response)

def test_get_mfe_config_from_django_settings(self):
"""Test that when there is no site configuration, the API takes the django settings.
Expected result:
- The status of the response of the request is a HTTP_200_OK.
- The json response is equal to MFE_CONFIG in lms/envs/test.py"""
response = self.client.get(self.mfe_config_api_url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json(), getattr(settings, "MFE_CONFIG", {}))

def test_get_mfe_config_with_queryparam_from_django_settings(self):
"""Test that when there is no site configuration, the API with queryparam takes the django settings.
Expected result:
- The status of the response of the request is a HTTP_200_OK.
- The json response is equal to MFE_CONFIG merged with MFE_CONFIG_MYMFE in lms/envs/test.py
"""
response = self.client.get(f"{self.mfe_config_api_url}?mfe=mymfe")
self.assertEqual(response.status_code, status.HTTP_200_OK)
expected_response = getattr(settings, "MFE_CONFIG", {})
expected_response.update(getattr(settings, "MFE_CONFIG_MYMFE", {}))
self.assertEqual(response.json(), expected_response)

@patch("lms.djangoapps.mfe_config_api.views.configuration_helpers")
@override_settings(ENABLE_MFE_CONFIG_API=False)
def test_404_get_mfe_config(self, configuration_helpers_mock):
"""Test the 404 not found response from get mfe config.
Expected result:
- The get_value method of configuration_helpers is not called.
- The status of the response of the request is a HTTP_404_NOT_FOUND.
"""
response = self.client.get(self.mfe_config_api_url)
configuration_helpers_mock.get_value.assert_not_called()
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
10 changes: 10 additions & 0 deletions lms/djangoapps/mfe_config_api/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
""" URLs configuration for the mfe api."""

from django.urls import path

from lms.djangoapps.mfe_config_api.views import MFEConfigView

app_name = 'mfe_config_api'
urlpatterns = [
path('', MFEConfigView.as_view(), name='config'),
]
52 changes: 52 additions & 0 deletions lms/djangoapps/mfe_config_api/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
MFE API Views for useful information related to mfes.
"""

from django.conf import settings
from django.http import HttpResponseNotFound, JsonResponse
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
from rest_framework import status
from rest_framework.views import APIView

from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers


class MFEConfigView(APIView):
"""
Provides an API endpoint to get the MFE_CONFIG from site configuration.
"""

@method_decorator(cache_page(settings.MFE_CONFIG_API_CACHE_TIMEOUT))
def get(self, request):
"""
GET /api/v1/mfe_config
or
GET /api/v1/mfe_config?mfe=name_of_mfe

**GET Response Values**
```
{
"BASE_URL": "https://name_of_mfe.example.com",
"LANGUAGE_PREFERENCE_COOKIE_NAME": "example-language-preference",
"CREDENTIALS_BASE_URL": "https://credentials.example.com",
"DISCOVERY_API_BASE_URL": "https://discovery.example.com",
"LMS_BASE_URL": "https://courses.example.com",
"LOGIN_URL": "https://courses.example.com/login",
"LOGOUT_URL": "https://courses.example.com/logout",
"STUDIO_BASE_URL": "https://studio.example.com",
"LOGO_URL": "https://courses.example.com/logo.png"
}
```
"""

if not settings.ENABLE_MFE_CONFIG_API:
return HttpResponseNotFound()

mfe_config = configuration_helpers.get_value('MFE_CONFIG', getattr(settings, 'MFE_CONFIG', {}))
if request.query_params.get('mfe'):
mfe = str(request.query_params.get('mfe')).upper()
mfe_config.update(configuration_helpers.get_value(
f'MFE_CONFIG_{mfe}', getattr(settings, f'MFE_CONFIG_{mfe}', {})))

return JsonResponse(mfe_config, status=status.HTTP_200_OK)
40 changes: 40 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3251,6 +3251,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring

# Blockstore
'blockstore.apps.bundles',

# MFE API
'lms.djangoapps.mfe_config_api',
]

######################### CSRF #########################################
Expand Down Expand Up @@ -5139,3 +5142,40 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
COURSE_LIVE_GLOBAL_CREDENTIALS = {}

PERSONALIZED_RECOMMENDATION_COOKIE_NAME = 'edx-user-personalized-recommendation'

# .. toggle_name: ENABLE_MFE_CONFIG_API
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
# .. toggle_description: Set to True to enable MFE Config API. This is disabled by
# default.
# .. toggle_use_cases: open_edx
# .. toggle_creation_date: 2022-05-20
# .. toggle_target_removal_date: None
# .. toggle_warnings: None
# .. toggle_tickets: None
ENABLE_MFE_CONFIG_API = False

# .. setting_name: MFE_CONFIG
# .. setting_implementation: DjangoSetting
# .. setting_default: {}
# .. setting_description: Is a configuration that will be exposed by the MFE Config API to be consumed by the mfes
# Example: {
# "BASE_URL": "https://name_of_mfe.example.com",
# "LANGUAGE_PREFERENCE_COOKIE_NAME": "example-language-preference",
# "CREDENTIALS_BASE_URL": "https://credentials.example.com",
# "DISCOVERY_API_BASE_URL": "https://discovery.example.com",
# "LMS_BASE_URL": "https://courses.example.com",
# "LOGIN_URL": "https://courses.example.com/login",
# "LOGOUT_URL": "https://courses.example.com/logout",
# "STUDIO_BASE_URL": "https://studio.example.com",
# "LOGO_URL": "https://courses.example.com/logo.png"
# }
# .. setting_use_cases: open_edx
# .. setting_creation_date: 2022-07-08
MFE_CONFIG = {}

# .. setting_name: MFE_CONFIG_API_CACHE_TIMEOUT
# .. setting_default: 60*5
# .. setting_description: The MFE Config API response will be cached during the
# specified time
MFE_CONFIG_API_CACHE_TIMEOUT = 60 * 5
13 changes: 13 additions & 0 deletions lms/envs/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,3 +647,16 @@
"SECRET": "***",
"URL": "***",
}

################## MFE API ####################
ENABLE_MFE_CONFIG_API = True
MFE_CONFIG = {
"BASE_URL": "https://name_of_mfe.example.com",
"LANGUAGE_PREFERENCE_COOKIE_NAME": "example-language-preference",
"LOGO_URL": "https://courses.example.com/logo.png"
}

MFE_CONFIG_MYMFE = {
"LANGUAGE_PREFERENCE_COOKIE_NAME": "mymfe-language-preference",
"LOGO_URL": "https://courses.example.com/mymfe-logo.png"
}
5 changes: 5 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -1022,3 +1022,8 @@
urlpatterns += [
path('api/instructor_task/', include('lms.djangoapps.instructor_task.rest_api.urls')),
]

# MFE API urls
urlpatterns += [
path('api/v1/mfe_config', include(('lms.djangoapps.mfe_config_api.urls', 'lms.djangoapps.mfe_config_api'), namespace='mfe_config_api'))
]