Skip to content

Commit

Permalink
feat: introduce Gateway Aggregation / Backend-for-Frontend abstractio…
Browse files Browse the repository at this point in the history
…n via LearnerPortalBFFAPIView
  • Loading branch information
adamstankiewicz committed Oct 9, 2024
1 parent 813442a commit fcb96e8
Show file tree
Hide file tree
Showing 14 changed files with 889 additions and 0 deletions.
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)
49 changes: 49 additions & 0 deletions enterprise_access/apps/api_client/base_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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


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 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.
3 changes: 3 additions & 0 deletions enterprise_access/apps/bffs/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from django.contrib import admin

# Register your models here.
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

0 comments on commit fcb96e8

Please sign in to comment.