LMS App�Code Design Patterns
Feature Flags
Models
Thin Models
Services
Services
CanvasAPIClient.send_with_refresh_and_retry()
def send_with_refresh_and_retry(self, request, schema, refresh_token):
try:
return self._helper.validated_response(request, schema).parsed_params
except CanvasAPIAccessTokenError:
if not refresh_token:
raise
new_access_token = self.get_refreshed_token(refresh_token)
return self._helper.validated_response(
request, schema, new_access_token
).parsed_params
Services Exceptions
lms/services/exceptions.py:
class ServiceError(Exception):
class LTILaunchVerificationError(ServiceError):
class NoConsumerKey(LTILaunchVerificationError):
class ConsumerKeyError(LTILaunchVerificationError):
class LTIOAuthError(LTILaunchVerificationError):
class ExternalRequestError(ServiceError):
class HAPIError(ExternalRequestError):
...
The LMS App is a SPA
Validation Schemas
marshmallow and webargs
Protecting views with validation schemas
from lms.validation import FooSchema
@view_config(..., schema=FooSchema)
def foo_view(request):
validated_param_1 = request.parsed_params["param_1"]
validated_param_2 = request.parsed_params["param_2"]
…
Validating responses with validation schemas
import requests
from lms.validation import BarSchema
response = requests.get(...)
try:
parsed_params = BarSchema(response).parse()
except lms.validation.ValidationError as err:
…
validated_param_1 = parsed_params["param_1"]
validated_param_2 = parsed_params["param_2"]
We also use schemas to serialize things
try:
lti_user_object = LaunchParamsSchema(request).lti_user()
except ValidationError:
…
>>> BearerTokenSchema(request).lti_user()
LTIUser(user_id=’...’, oauth_consumer_key=’...’, …)
>>> CanvasOAuthCallbackSchema(request).lti_user()
LTIUser(user_id=’...’, oauth_consumer_key=’...’, …)
>>> CanvasOAuthCallbackSchema(request).state_param()
‘yyz...123’
Validation Exceptions
lms/validation/_exceptions.py:
class ValidationError(Exception):
class ExpiredSessionTokenError(ValidationError):
class MissingSessionTokenError(ValidationError):
class InvalidSessionTokenError(ValidationError):
class MissingStateParamError(ValidationError):
class ExpiredStateParamError(ValidationError):
class InvalidStateParamError(ValidationError):
Local Helpers Pattern�(no util dir!)
Local Helpers
Authentication
LTIUser
class LTIUser(NamedTuple):
"""An LTI user."""
user_id: str
"""The user_id LTI launch parameter."""
oauth_consumer_key: str
"""The oauth_consumer_key LTI launch parameter."""
roles: str
"""The user's LTI roles."""
An immutable value object that represents an LTI user -- user_id, roles, etc.
Getting an LTIUser for the current request
from lms.validation import LaunchParamsSchema, BearerTokenSchema, CanvasOAu...
# Derive an LTIUser from the current request’s LTI launch params.
lti_user = LaunchParamsSchema(request).lti_user()
# Derive an LTIUser from the current request’s JWT bearer token.
lti_user = BearerTokenSchema(request).lti_user()
# Derive an LTIUser from the current request’s OAuth 2.0 `state` param
# (used when receiving OAuth 2.0 redirect requests from Canvas)
lti_user = CanvasOAuthCallbackSchema(request).lti_user()
request.lti_user
Authentication Policies
Resources
Routing sets the resource factory
The routing (routes.py) sets the resource factory for each route:
config.add_route("lti_launches", "/lti_launches", factory="lms.resources.LTILaunchResource")
config.add_route("module_item_configurations", …, factory="lms.resources.LTILaunchResource")
config.add_route("content_item_selection", …, factory="lms.resources.LTILaunchResource")
LTILaunchResource
LTILaunchResource is the “context resource” for LTI launch requests:
class LTILaunchResource:
__acl__ = [(Allow, "lti_user", "launch_lti_assignment")]
@property
def h_display_name(self):
"""Return the h user display name for the current request."""
...
@property
def h_groupid(self):
…
@property
def h_group_name(self):
...
permission view predicate (standard)
@view_defaults(..., permission="launch_lti_assignment", ...)
class BasicLTILaunchViews:
...
Views
Basic Views
More Important Views
BasicLTILaunchViews
A class containing view methods for all the similar-but-not-quite-the-same ways of launching different kinds of assignment:
@view_defaults(permission="launch_lti_assignment",
renderer="lms:templates/basic_lti_launch/basic_lti_launch.html.jinja2",
request_method="POST",
route_name="lti_launches")
class BasicLTILaunchViews:
def canvas_file_basic_lti_launch(self):
def db_configured_basic_lti_launch(self):
def url_configured_basic_lti_launch(self):
def unconfigured_basic_lti_launch(self):
def unconfigured_basic_lti_launch_not_authorized(self):
def configure_module_item(self):
Custom View Predicates
@view_config(canvas_file=True, …)
def canvas_file_basic_lti_launch(self):
@view_config(db_configured=True, …)
def db_configured_basic_lti_launch(self):
@view_config(url_configured=True, …)
def url_configured_basic_lti_launch(self):
@view_config(authorized_to_configure_assignments=True, configured=False, …)
def unconfigured_basic_lti_launch(self):
@view_config(authorized_to_configure_assignments=False, configured=False, …)
def unconfigured_basic_lti_launch_not_authorized(self)��See lms/views/predicates/ for the implementations of these custom view predicates.
Custom View Decorators
@view_defaults(decorator=[upsert_h_user, upsert_course_group, add_user_to_group], …)
class BasicLTILaunchViews:
…
@view_config(decorator=[upsert_h_user, upsert_course_group], …)
def content_item_selection(context, request):
…
See lms/views/decorators/
Error Views