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

[REFERENCE] feat: introduce Gateway Aggregation / Backend-for-Frontend abstraction via LearnerPortalBFFAPIView #572

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions enterprise_access/apps/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,9 @@
),
]

# BFFs
urlpatterns += [
path('bffs/learner/<page_route>/', views.LearnerPortalBFFAPIView.as_view(), name='learner-portal-bff'),
]

urlpatterns += router.urls
1 change: 1 addition & 0 deletions enterprise_access/apps/api/v1/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@
SubsidyAccessPolicyRedeemViewset,
SubsidyAccessPolicyViewSet
)
from .bffs import LearnerPortalBFFAPIView
59 changes: 59 additions & 0 deletions enterprise_access/apps/api/v1/views/bffs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""
Enterprise BFFs for MFEs.
"""

from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.authentication import get_authorization_header, SessionAuthentication
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name

from enterprise_access.apps.bffs.context import HandlerContext
from enterprise_access.apps.bffs.handlers import LearnerPortalHandlerFactory
from enterprise_access.apps.bffs.response_builder import LearnerPortalResponseBuilderFactory


class LearnerPortalBFFAPIView(APIView):
"""
API view for learner portal BFF routes.
"""

authentication_classes = [JwtAuthentication]
permission_classes = [IsAuthenticated]

def post(self, request, page_route, *args, **kwargs):
"""
Handles GET requests for learner-specific routes.

Args:
request (Request): The request object.
route (str): The specific learner portal route (e.g., 'dashboard').

Returns:
Response: The response data formatted by the response builder.
"""

# Create the context based on the request
context = HandlerContext(page_route=page_route, request=request)

# Use the LearnerPortalResponseBuilderFactory to get the appropriate response builder
response_builder = LearnerPortalResponseBuilderFactory.get_response_builder(context)

try:
# Use the LearnerHandlerFactory to get the appropriate handler
handler = LearnerPortalHandlerFactory.get_handler(context)

# Load and process data using the handler
handler.load_and_process()
except Exception as exc:
context.add_error(
user_message="An error occurred while processing the request.",
developer_message=f"Error: {exc}",
severity="error",
)

# Build the response data and status code
response_data, status_code = response_builder.build()

return Response(response_data, status=status_code)
53 changes: 53 additions & 0 deletions enterprise_access/apps/api_client/base_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import crum
import requests

from edx_django_utils.monitoring import set_custom_attribute
from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name


def get_request_id():
"""
Helper to get the request id - usually set via an X-Request-ID header
"""
request = crum.get_current_request()
if request is not None and request.headers is not None:
return request.headers.get('X-Request-ID')
else:
return None
Comment on lines +8 to +16
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[inform] Replicated from here



class BaseUserApiClient(requests.Session):
"""
A requests Session that includes the Authorization and User-Agent headers from the original request.
"""
def __init__(self, original_request, **kwargs):
super().__init__(**kwargs)
self.original_request = original_request

self.headers = {}

if self.original_request:
# If Authorization header is present in the original request, pass through to subsequent request headers
if 'Authorization' in self.original_request.headers:
self.headers['Authorization'] = self.original_request.headers['Authorization']

# If no Authorization header, check for JWT in cookies
jwt_token = self.original_request.COOKIES.get(jwt_cookie_name())
if 'Authorization' not in self.headers and jwt_token is not None:
self.headers['Authorization'] = f'JWT {jwt_token}'

# Add X-Request-ID header if applicable
request_id = get_request_id()
if self.headers.get('X-Request-ID') is None and request_id is not None:
self.headers['X-Request-ID'] = request_id

def request(self, method, url, headers=None, **kwargs): # pylint: disable=arguments-differ
if headers:
headers.update(self.headers)
else:
headers = self.headers

# Set `api_client` as a custom attribute for monitoring, reflecting the API client's module path
set_custom_attribute('api_client', 'enterprise_access.apps.api_client.base_user.BaseUserApiClient')

return super().request(method, url, headers=headers, **kwargs)
72 changes: 72 additions & 0 deletions enterprise_access/apps/api_client/license_manager_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.conf import settings

from enterprise_access.apps.api_client.base_oauth import BaseOAuthClient
from enterprise_access.apps.api_client.base_user import BaseUserApiClient

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,3 +63,74 @@ def assign_licenses(self, user_emails, subscription_uuid):
except requests.exceptions.HTTPError as exc:
logger.exception(exc)
raise


class LicenseManagerUserApiClient(BaseUserApiClient):
"""
API client for calls to the license-manager service. This client is used for user-specific calls,
passing the original Authorization header from the originating request.
"""

api_base_url = f"{settings.LICENSE_MANAGER_URL}/api/v1/"
learner_licenses_endpoint = f"{api_base_url}learner-licenses/"
license_activation_endpoint = f"{api_base_url}license-activation/"

def auto_apply_license_endpoint(self, customer_agreement_uuid):
return f"{self.api_base_url}customer-agreement/{customer_agreement_uuid}/auto-apply/"

def get_subscription_licenses_for_learner(self, enterprise_customer_uuid):
"""
Get subscription licenses for a learner.

Arguments:
enterprise_customer_uuid (str): UUID of the enterprise customer
Returns:
dict: Dictionary representation of json returned from API
"""
query_params = {
'enterprise_customer_uuid': enterprise_customer_uuid,
}
url = self.learner_licenses_endpoint
try:
response = self.get(url, params=query_params, timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT)
return response.json()
except requests.exceptions.HTTPError as exc:
logger.exception(f"Failed to get subscription licenses for learner: {exc}")
raise

def activate_license(self, activation_key):
"""
Activate a license.

Arguments:
license_uuid (str): UUID of the license to activate
"""
try:
url = self.license_activation_endpoint
query_params = {
'activation_key': activation_key,
}
response = self.post(url, params=query_params, timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT)
response.raise_for_status()
if response.status_code == 204: # Response contains no content
return None
return response.json()
except requests.exceptions.HTTPError as exc:
logger.exception(f"Failed to activate license: {exc}")
raise

def auto_apply_license(self, customer_agreement_uuid):
"""
Activate a license.

Arguments:
license_uuid (str): UUID of the license to activate
"""
try:
url = self.auto_apply_license_endpoint(customer_agreement_uuid=customer_agreement_uuid)
response = self.post(url, timeout=settings.LICENSE_MANAGER_CLIENT_TIMEOUT)
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as exc:
logger.exception(f"Failed to auto-apply license: {exc}")
raise
Empty file.
6 changes: 6 additions & 0 deletions enterprise_access/apps/bffs/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BffsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'enterprise_access.apps.bffs'
51 changes: 51 additions & 0 deletions enterprise_access/apps/bffs/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
HandlerContext for bffs app.
"""

class HandlerContext:
"""
A context object for managing the state throughout the lifecycle of a Backend-for-Frontend (BFF) request.

The `HandlerContext` class stores request information, the current route, loaded data, and any errors
that may occur during the request.

Attributes:
request: The original request object containing information about the incoming HTTP request.
route: The route for which the response is being generated.
data: A dictionary to store data loaded and processed by the handlers.
errors: A list to store errors that occur during request processing.
"""

def __init__(self, request, page_route):
"""
Initializes the HandlerContext with request information, route, and optional initial data.

Args:
request: The incoming HTTP request.
page_route: The route identifier for the request.
"""
self.page_route = page_route
self.request = request
self.user = request.user
self.data = {} # Stores processed data for the response
self.errors = [] # Stores any errors that occur during processing
self.enterprise_customer_uuid = None
self.lms_user_id = None

def add_error(self, user_message, developer_message, severity='error'):
"""
Adds an error to the context.

Args:
user_message (str): A user-friendly error message.
developer_message (str): A more detailed error message for debugging purposes.
severity (str): The severity level of the error ('error' or 'warning'). Defaults to 'error'.
"""
if not (user_message and developer_message):
raise ValueError("User message and developer message are required for errors.")

self.errors.append({
"user_message": user_message,
"developer_message": developer_message,
"severity": severity,
})
Loading
Loading