Skip to content
Closed
113 changes: 113 additions & 0 deletions docs/decisions/0025-standardize-serializer-usage.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
Standardize Serializer Usage Across APIs
========================================

:Status: Proposed
:Date: 2026-03-09
:Deciders: API Working Group
:Technical Story: Open edX REST API Standards - Serializer standardization for consistency

Context
-------

Many Open edX platform API endpoints manually construct JSON responses using Python dictionaries instead of Django REST Framework (DRF) serializers. This leads to inconsistent schema responses, makes validation errors harder to manage, and creates unpredictable formats that AI and third-party systems struggle with.

Decision
--------

We will standardize all Open edX REST APIs to use **DRF serializers** for request and response handling.

Implementation requirements:

* All API views MUST define explicit serializers for request and response handling.
* Replace manual JSON construction with serializer-based responses.
* Use serializers for both input validation and output formatting.
* Ensure serializers are properly documented with field descriptions and validation rules.
* Maintain backward compatibility for all APIs during migration. While the goal is fully compatible DRF serializers, if that is not possible and we must make a backwards incompatible change, that change MUST be handled by creating a new version of the API and transitioning to that API using the deprecation process.

Relevance in edx-platform
-------------------------

Current patterns that should be migrated:

* **Certificates API** (``/api/certificates/v0/``) constructs JSON manually with nested dictionaries.
* **Enrollment API** endpoints manually build response objects without serializers.
* **Course API** views use hand-coded JSON responses instead of structured serializers.

Code example (target serializer usage)
--------------------------------------

**Example serializer and APIView using DRF best practices:**

.. code-block:: python

# serializers.py
from rest_framework import serializers

class CertificateSerializer(serializers.Serializer):
username = serializers.CharField(
help_text="The username of the certificate holder"
)
course_id = serializers.CharField(
help_text="The course identifier"
)
status = serializers.CharField(
help_text="The certificate status (e.g., downloadable, generating)"
)
grade = serializers.FloatField(
help_text="The final grade achieved"
)

# views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status

class CertificateAPIView(APIView):
def get(self, request):
data = {
"username": "john_doe",
"course_id": "course-v1:edX+DemoX+1T2024",
"status": "downloadable",
"grade": 0.95,
}
serializer = CertificateSerializer(data)
return Response(serializer.data, status=status.HTTP_200_OK)

Consequences
------------

Positive
~~~~~~~~

* Simplifies validation and ensures consistent response contracts.
* Improves AI compatibility through predictable data structures.
* Enables automatic schema generation and documentation.
* Reduces code duplication and maintenance overhead.

Negative / Trade-offs
~~~~~~~~~~~~~~~~~~~~~

* Requires refactoring existing endpoints that manually construct JSON.
* Initial development overhead for creating comprehensive serializers.
* May require updates to existing client code that expects legacy formats.

Alternatives Considered
-----------------------

* **Keep manual JSON construction**: rejected due to inconsistency and maintenance burden.
* **Use DRF defaults only**: rejected because explicit serializers provide better validation and documentation.
* **Use newer ways of managing API responses such as dataclasses or pydantic**: rejected due to complexity and unknowns in transitioning from two existing patterns (manual JSON and DRF serializers) to a third approach. While these python libraries offer better ergonomics, migration would require checking nested serializers, complex validation, and ModelSerializer-heavy endpoints. To move to some new format, we would want to prevent using the basic DRF Serializers any more than we do right now, but preventing new DRF serializers via linting is more complex than anticipated. This work can be revisited in the future once the platform is a bit more consistent.

Rollout Plan
------------

1. Audit existing endpoints to identify those using manual JSON construction.
2. Create a library of common serializers for shared data structures.
3. Migrate high-impact endpoints first (certificates, enrollment, courses).
4. Update tests to validate serializer-based responses.
5. Update API documentation to reflect new serializer-based contracts.

References
----------

* Open edX REST API Standards: "Serializer Usage" recommendations for API consistency.
55 changes: 55 additions & 0 deletions openedx/core/djangoapps/enrollments/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,68 @@
class CourseEnrollmentsApiListForm(Form):
"""
A form that validates the query string parameters for the CourseEnrollmentsApiListView.

ADR 0033 – OEP-68 parameter naming standardization:
- ``course_key`` is the preferred parameter name; ``course_id`` is accepted
as a deprecated alias (BC strategy §1). When both are present,
``course_key`` wins.
- ``course_keys`` is the preferred parameter name; ``course_ids`` is
accepted as a deprecated alias (same precedence rule).
Internally the cleaned_data continues to expose ``course_id`` /
``course_ids`` so call sites do not need to change. Use
:meth:`legacy_param_aliases_used` to detect when the deprecated names were
sent by the client (used to emit the ``Deprecation`` HTTP header).
"""
MAX_INPUT_COUNT = 100
# Legacy / OEP-68 alias pairs: (legacy, preferred).
_LEGACY_PARAM_ALIASES = (
("course_id", "course_key"),
("course_ids", "course_keys"),
)

username = CharField(required=False)
course_id = CharField(required=False)
course_key = CharField(required=False)
course_ids = CharField(required=False)
course_keys = CharField(required=False)
email = CharField(required=False)

def __init__(self, query_params, *args, **kwargs):
# Capture the raw param names supplied on the wire, *before* Django's
# form layer resolves aliases, so :meth:`legacy_param_aliases_used`
# can later report exactly which legacy names were used.
try:
raw_keys = set(query_params.keys())
except AttributeError:
raw_keys = set()
self._raw_param_names = raw_keys

# Coalesce OEP-68 preferred names into the legacy fields so the
# downstream view code keeps reading ``course_id`` / ``course_ids``
# without changes. Preferred wins when both are sent.
if hasattr(query_params, "copy"):
data = query_params.copy()
else:
data = dict(query_params)
for legacy_name, preferred_name in self._LEGACY_PARAM_ALIASES:
preferred_value = data.get(preferred_name)
if preferred_value:
data[legacy_name] = preferred_value

super().__init__(data, *args, **kwargs)

def legacy_param_aliases_used(self):
"""
Return the list of legacy (OEP-68-violating) parameter names actually
present in the request, in declaration order.

Used by the view layer to emit the ADR 0033 ``Deprecation`` header.
"""
return [
legacy for legacy, _preferred in self._LEGACY_PARAM_ALIASES
if legacy in self._raw_param_names
]

def clean_course_id(self):
"""
Validate and return a course ID.
Expand Down
13 changes: 9 additions & 4 deletions openedx/core/djangoapps/enrollments/paginators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
"""


from rest_framework.pagination import CursorPagination
from edx_rest_framework_extensions.paginators import DefaultPagination # ADR 0032


class CourseEnrollmentsApiListPagination(CursorPagination):
class CourseEnrollmentsApiListPagination(DefaultPagination):
"""
Paginator for the Course enrollments list API.
ADR 0032 – standard pagination for the admin enrollments list API
(GET /api/enrollment/v1/enrollments).

Extends DefaultPagination with a larger default page size appropriate
for an admin-facing, bulk-query endpoint. The full 7-field response
envelope (count, num_pages, current_page, start, next, previous,
results) is provided by DefaultPagination.get_paginated_response.
"""
page_size = 100
page_size_query_param = 'page_size'
max_page_size = 100
page_query_param = 'page'
19 changes: 19 additions & 0 deletions openedx/core/djangoapps/enrollments/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,22 @@ class Meta:
model = CourseEnrollmentAllowed
exclude = ["id"]
lookup_field = "user"


class UserRoleSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializes a single course-level role entry for a user."""

org = serializers.CharField()
course_id = serializers.SerializerMethodField()
role = serializers.CharField()

def get_course_id(self, obj):
"""Return course_id as a string."""
return str(obj.course_id)


class UserRolesResponseSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""Serializes the full response payload for EnrollmentUserRolesView."""

roles = UserRoleSerializer(many=True)
is_staff = serializers.BooleanField()
Loading
Loading