API versioning demo#3267
Conversation
OpenAPI Changes75 changes: 0 error, 0 warning, 75 info Unexpected changes? Ensure your branch is up-to-date with |
There was a problem hiding this comment.
Pull request overview
Adds API versioning support (via mitol-django-api-versioning) and introduces a demo v2 namespace/spec for the Learning Resources APIs, including a handful of example breaking changes handled via serializer/version transforms and updated generated TypeScript clients.
Changes:
- Integrates
mitol.api_versioning(settings + serializer mixin usage across multiple apps) and enablesv2in DRF allowed versions. - Adds
v2API routing + DRF Spectacular schema endpoints/spec + OpenAPI client generation outputs forv2. - Introduces
learning_resourcesv2 demo transformations (departmentchannel_url→url, videocover_image_url→thumbnail_url, etc.) and updates tests accordingly.
Reviewed changes
Copilot reviewed 22 out of 32 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
articles/serializers.py |
Adds VersionedSerializerMixin to serializers and improves UserSerializer docstring for OpenAPI. |
channels/serializers.py |
Adds VersionedSerializerMixin to channel-related serializers. |
drf_lint_baseline.json |
Updates baseline line references due to serializer edits. |
frontends/api/src/generated/v1/api.ts |
Regenerated v1 client output (docstring text changes). |
frontends/api/src/generated/v2/.openapi-generator/FILES |
Adds OpenAPI generator metadata for v2 client output. |
frontends/api/src/generated/v2/.openapi-generator/VERSION |
Records OpenAPI generator version used for v2 output. |
frontends/api/src/generated/v2/api.ts |
Adds generated v2 TypeScript Axios client (new output). |
frontends/api/src/generated/v2/base.ts |
Adds generated v2 base client utilities. |
frontends/api/src/generated/v2/common.ts |
Adds generated v2 common request helpers. |
frontends/api/src/generated/v2/configuration.ts |
Adds generated v2 client configuration. |
frontends/api/src/generated/v2/index.ts |
Adds generated v2 entrypoint exports. |
learning_resources/serializers.py |
Applies versioned serializer mixin broadly and introduces v2 demo field changes (department url, run enrollment_url, video thumbnail_url, caption word_count). |
learning_resources/serializers_test.py |
Updates serializer tests to validate both v1/v2 behavior via request version context. |
learning_resources/transforms/__init__.py |
Exposes v2 transforms module for transform registration/discovery. |
learning_resources/transforms/v2.py |
Implements demo v2 transforms for response/request/schema compatibility. |
learning_resources/urls.py |
Adds api/v2/ URL namespace (currently reusing v1 route set). |
learning_resources/views_test.py |
Updates API tests to exercise both v1 and v2 endpoints and versioned serializer output. |
learning_resources_search/serializers.py |
Applies versioning to PercolateQuerySerializer and transforms search hit sources backward per request version. |
learning_resources_search/urls.py |
Adds api/v2/ URL namespace (currently reusing v1 route set). |
main/settings.py |
Installs mitol.api_versioning app and enables v2 in ALLOWED_VERSIONS. |
news_events/serializers.py |
Adds VersionedSerializerMixin to serializers. |
openapi/settings_spectacular.py |
Adds versioned schema post-processing hook. |
openapi/specs/v1.yaml |
Regenerated OpenAPI v1 spec (adds missing schema descriptions). |
openapi/urls.py |
Adds /api/v2/schema/* endpoints for v2 schema + docs. |
profiles/serializers.py |
Adds VersionedSerializerMixin to serializers. |
pyproject.toml |
Adds mitol-django-api_versioning dependency and uv git source reference. |
scripts/generate_openapi.sh |
Generates v2 TypeScript client alongside v0/v1. |
scripts/openapi-configs/typescript-axios-v2.yaml |
Adds OpenAPI generator config for v2 TypeScript client output. |
testimonials/serializers.py |
Adds VersionedSerializerMixin to serializer. |
uv.lock |
Locks new dependency and its source. |
video_shorts/serializers.py |
Adds VersionedSerializerMixin to serializers. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 32 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
learning_resources/serializers.py:1384
ContentFileSerializernow participates in API versioning, but several serializer method fields instantiate other serializers without passing throughself.context(e.g., departments/topics/platform/offered_by). With versioned transforms (likeLearningResourceDepartmentSerializerrenamingurl/channel_url), this can cause v1 responses to emit the v2 shape because the nested serializers don’t seerequest.version. Passcontext=self.contextwhen constructing nested serializers (and similarly in any callers that manually instantiateContentFileSerializer).
class ContentFileSerializer(VersionedSerializerMixin, serializers.ModelSerializer):
"""
Serializer class for course run ContentFiles
"""
| enrollment_url = serializers.SerializerMethodField() | ||
|
|
||
| def get_enrollment_url(self, instance) -> str | None: | ||
| """Return a URL for enrollment, derived from the run URL.""" | ||
| return f"{instance.url.rstrip('/')}/enroll/" if instance.url else None |
There was a problem hiding this comment.
The new enrollment_url field behavior (including the exact formatting and versioning behavior—present in v2, removed/absent in v0/v1 via transforms) isn’t covered by tests. Add serializer/view tests that assert enrollment_url is correctly derived from run.url in v2 and is not present for v1.
Co-authored-by: Copilot <copilot@github.com>
862ba9e to
828f2d2
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 34 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
learning_resources/serializers.py:1389
- Now that
ContentFileSerializer(and several nested serializers it calls) are version-aware viaVersionedSerializerMixin, the manual serializer instantiations insideContentFileSerializershould pass throughself.context(or at least therequest). For example,get_departmentscurrently constructsLearningResourceDepartmentSerializer(...).datawithout context, which will likely emit the v2 field name (url) even on v1 endpoints because the nested serializer can’t seerequest.version.
class ContentFileSerializer(VersionedSerializerMixin, serializers.ModelSerializer):
"""
Serializer class for course run ContentFiles
"""
| hits = instance.get("hits", {}).get("hits", []) | ||
| return (hit.get("_source") for hit in hits) | ||
| return ( | ||
| transform_dict_backwards(hit.get("_source"), ContentFileSerializer, request) |
There was a problem hiding this comment.
ContentFileSearchResponseSerializer.get_results calls transform_dict_backwards(...) without recursive=True, while the learning-resources variant explicitly enables recursion. If transform_dict_backwards is non-recursive by default, nested structures in ContentFileSerializer output (e.g., departments items where v2 uses url and v1 expects channel_url) won’t be transformed for older API versions. Consider enabling recursive transforms here as well so nested renamed fields are handled consistently.
| transform_dict_backwards(hit.get("_source"), ContentFileSerializer, request) | |
| transform_dict_backwards( | |
| hit.get("_source"), | |
| ContentFileSerializer, | |
| request, | |
| recursive=True, | |
| ) |
| def get_word_count(self, obj) -> int: | ||
| url = obj.get("url", "") if isinstance(obj, dict) else "" | ||
| return random.Random(url).randint(100, 5000) # noqa: S311 | ||
|
|
||
|
|
There was a problem hiding this comment.
CaptionUrlSerializer.get_word_count generates a pseudo-random value (and suppresses S311) instead of deriving a real word count. This means the API can return arbitrary values that don’t reflect caption content, and it will still return a number even if the caption entry has no url (seed becomes an empty string). Consider either computing the word count from actual caption text/metadata, or (if this is only a demo field) returning None/omitting the field when inputs are missing and avoiding random + the security lint suppression.
| def get_word_count(self, obj) -> int: | |
| url = obj.get("url", "") if isinstance(obj, dict) else "" | |
| return random.Random(url).randint(100, 5000) # noqa: S311 | |
| def get_word_count(self, obj) -> int | None: | |
| """Return the number of words in caption text when it is available.""" | |
| if not isinstance(obj, dict): | |
| return None | |
| caption_text = None | |
| for key in ("text", "content", "caption", "transcript"): | |
| value = obj.get(key) | |
| if isinstance(value, str) and value.strip(): | |
| caption_text = value | |
| break | |
| if caption_text is None: | |
| return None | |
| return len(caption_text.split()) |
| if "thumbnail_url" in data: | ||
| data["cover_image_url"] = data["thumbnail_url"] | ||
| elif instance is not None: | ||
| data["cover_image_url"] = getattr(instance, "cover_image_url", "") | ||
| else: | ||
| data["cover_image_url"] = "" |
There was a problem hiding this comment.
VideoRemoveCoverImageUrl.to_representation falls back to setting cover_image_url to an empty string when there’s no thumbnail_url and no instance. This produces a value that doesn’t match the schema (nullable URI) and can leak into cases where transforms are applied to dict data without a model instance (e.g., search results). Prefer None as the fallback and avoid relying on transform ordering to ensure thumbnail_url is still present when deriving cover_image_url.
| if "thumbnail_url" in data: | |
| data["cover_image_url"] = data["thumbnail_url"] | |
| elif instance is not None: | |
| data["cover_image_url"] = getattr(instance, "cover_image_url", "") | |
| else: | |
| data["cover_image_url"] = "" | |
| thumbnail_url = data.get("thumbnail_url") | |
| if thumbnail_url is None and instance is not None: | |
| thumbnail_url = getattr(instance, "thumbnail_url", None) | |
| if thumbnail_url is not None: | |
| data["cover_image_url"] = thumbnail_url | |
| elif instance is not None: | |
| data["cover_image_url"] = getattr(instance, "cover_image_url", None) | |
| else: | |
| data["cover_image_url"] = None |
828f2d2 to
64cc7c7
Compare
What are the relevant tickets?
Related to https://github.com/mitodl/hq/issues/5895
Description (What does it do?)
Incorporates the ol-django
api_versioningapp and adds a demo v2 version of the learning_resources API endpoint with a few sample transformations.Primarily for testing purposes.
How can this be tested?
mb/api_versioning_v2branch of mit-learndocker compose up --build./manage.py backpopulate_ovs_datato fetch a few videoscover_image_urlfield and each caption incaption_urlsshould includeword_countthumbnail_urlfield and noword_countforcaption_urlsAdditional Context
The first commit adds support for versioning with transforms.
The second commit implements a demo v2 version of the learning_resources API's with some sample transforms