-
Notifications
You must be signed in to change notification settings - Fork 2
API versioning #430
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
Open
mbertrand
wants to merge
6
commits into
main
Choose a base branch
from
mb/api_versioning
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
API versioning #430
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Changelog | ||
|
|
||
| ## 2026.3.24 | ||
|
|
||
| - Initial release with Transform base class, VersionedSerializerMixin, and drf-spectacular schema hook. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,324 @@ | ||
| # mitol-django-api_versioning | ||
|
|
||
| Transform-based API versioning for Django REST Framework. | ||
|
|
||
| When an API needs a breaking change, this library lets you keep one canonical | ||
| serializer (the latest version's shape) and describe the breaking change as a | ||
| small `Transform` class. Older clients keep working because the library | ||
| applies the transforms backwards on response and forwards on request; new | ||
| clients get the latest shape unmodified. The same approach also rewrites the | ||
| generated OpenAPI schema per version so generated TypeScript clients have | ||
| correct types. | ||
|
|
||
| The design is modelled on [Stripe's API versioning approach](https://stripe.com/blog/api-versioning). | ||
| The advantage over per-version view/serializer modules is that bug fixes, | ||
| permissions, query plans, and serializer logic live in one place; the | ||
| disadvantage is that very behavioural divergence between versions (different | ||
| auth flows, different endpoints) is out of scope and should still use | ||
| per-version views. | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Django ≥ 3.0 | ||
| - Django REST Framework ≥ 3.14 | ||
| - drf-spectacular (only required if you want per-version OpenAPI schemas) | ||
|
|
||
| DRF must be configured to use namespace-based versioning so each request | ||
| arrives with `request.version` set. | ||
|
|
||
| ## Installation | ||
|
|
||
| ```bash | ||
| pip install mitol-django-api_versioning | ||
| ``` | ||
|
|
||
| Add the app to `INSTALLED_APPS`: | ||
|
|
||
| ```python | ||
| INSTALLED_APPS = [ | ||
| ... | ||
| "mitol.api_versioning.apps.ApiVersioningApp", | ||
| ] | ||
| ``` | ||
|
|
||
| The app's `ready()` hook auto-discovers a `transforms` submodule in every | ||
| installed app, so transforms registered in `<your_app>/transforms.py` (or | ||
| `<your_app>/transforms/__init__.py`) load at startup. | ||
|
|
||
| ## Configuration | ||
|
|
||
| ### DRF settings | ||
|
|
||
| ```python | ||
| REST_FRAMEWORK = { | ||
| "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.NamespaceVersioning", | ||
| "ALLOWED_VERSIONS": ["v0", "v1", "v2"], | ||
| ... | ||
| } | ||
| ``` | ||
|
|
||
| `ALLOWED_VERSIONS` is the source of truth. The last entry is the **latest | ||
| version**: serializers always produce that shape, and transforms run only when | ||
| the request version differs. | ||
|
|
||
| ### URL conf | ||
|
|
||
| The same views serve every version; namespace-based versioning sets | ||
| `request.version` from the URL prefix: | ||
|
|
||
| ```python | ||
| v2_urls = v1_urls # same view callables, same routes | ||
|
|
||
| urlpatterns = [ | ||
| re_path(r"^api/v2/", include((v2_urls, "v2"))), | ||
| re_path(r"^api/v1/", include((v1_urls, "v1"))), | ||
| ] | ||
| ``` | ||
|
|
||
| ### drf-spectacular settings (optional) | ||
|
|
||
| If you want per-version OpenAPI schemas, register the postprocess hook: | ||
|
|
||
| ```python | ||
| SPECTACULAR_SETTINGS = { | ||
| ... | ||
| "POSTPROCESSING_HOOKS": [ | ||
| "mitol.api_versioning.schema_hooks.postprocess_versioned_schema", | ||
| ], | ||
| } | ||
| ``` | ||
|
|
||
| See [Per-version OpenAPI schemas](#per-version-openapi-schemas) below for the | ||
| URL conf that activates it. | ||
|
|
||
| ## Usage | ||
|
|
||
| ### Applying the mixin | ||
|
|
||
| Put `VersionedSerializerMixin` first in the bases of any serializer that has | ||
| versioned transforms. The serializer always produces the latest version's | ||
| shape; the mixin intercepts `to_representation` / `to_internal_value` and | ||
| applies transforms when `request.version` is older. | ||
|
|
||
| ```python | ||
| from mitol.api_versioning.mixins import VersionedSerializerMixin | ||
| from rest_framework import serializers | ||
|
|
||
| class CourseSerializer(VersionedSerializerMixin, serializers.ModelSerializer): | ||
| class Meta: | ||
| model = Course | ||
| fields = ["id", "title", "enrollment_url"] | ||
| ``` | ||
|
|
||
| The mixin's `__init_subclass__` raises `TypeError` at class-definition time if | ||
| it appears after the Serializer base class in the MRO, so misordered bases | ||
| fail loudly at import time. | ||
|
|
||
| ### Defining a transform | ||
|
|
||
| Each breaking change is one `Transform` subclass. Implement only the methods | ||
| relevant to the change — `to_representation` for response data, | ||
| `to_internal_value` for request data, `transform_schema` for OpenAPI. | ||
|
|
||
| ```python | ||
| # learning_resources/transforms.py | ||
|
|
||
| from mitol.api_versioning.transforms import Transform | ||
|
|
||
|
|
||
| class RunAddEnrollmentUrl(Transform): | ||
| """v2 added enrollment_url to LearningResourceRunSerializer.""" | ||
|
|
||
| version = "v2" | ||
| description = "Add enrollment_url field to Run" | ||
| serializer = "learning_resources.serializers.LearningResourceRunSerializer" | ||
|
|
||
| def to_representation(self, data, request, instance): | ||
| # v1 clients didn't have enrollment_url | ||
| data.pop("enrollment_url", None) | ||
| return data | ||
|
|
||
| def transform_schema(self, schema): | ||
| schema.get("properties", {}).pop("enrollment_url", None) | ||
| required = schema.get("required", []) | ||
| if "enrollment_url" in required: | ||
| required.remove("enrollment_url") | ||
| return schema | ||
| ``` | ||
|
|
||
| `version` is the version that **introduced** the breaking change. The | ||
| metaclass auto-registers the class with the registry, so simply defining it | ||
| is enough — no manual registration call. | ||
|
|
||
| `to_representation` and `to_internal_value` may either mutate `data` in place | ||
| and return it, or return a new dict — both patterns work, including for | ||
| nested transforms applied via `transform_dict_backwards(..., recursive=True)`. | ||
| The only invalid return is `None`, which raises `TypeError`. | ||
|
|
||
| ### `component_name` override | ||
|
|
||
| `transform_schema` finds the OpenAPI component to mutate by stripping | ||
| `Serializer` from the class name (`MySerializer` → `My`). When a serializer | ||
| uses `@extend_schema(component_name=...)` or shares a component name with | ||
| another serializer, set `component_name` explicitly: | ||
|
|
||
| ```python | ||
| class CaptionUrlAddWordCount(Transform): | ||
| version = "v2" | ||
| description = "Add word_count to caption URL entries" | ||
| serializer = "learning_resources.serializers.VideoSerializer" | ||
| component_name = "CaptionUrl" # not "Video" | ||
|
|
||
| def transform_schema(self, schema): | ||
| schema.get("properties", {}).pop("word_count", None) | ||
| ... | ||
| ``` | ||
|
|
||
| ### Inherited serializers | ||
|
|
||
| Registry lookup is **exact-class match** — a transform attached to a base | ||
| serializer does not automatically apply to subclasses. If both | ||
| `LearningResourceBaseDepartmentSerializer` and a derived | ||
| `LearningResourceDepartmentSerializer` rename the same field, declare two | ||
| transforms (or share the body via a no-`version` abstract base class so the | ||
| logic lives once): | ||
|
|
||
| ```python | ||
| class _DepartmentRenameBase(Transform): | ||
| # No `version` → not registered. Just shared logic. | ||
| description = "Rename channel_url to url" | ||
|
|
||
| def to_representation(self, data, request, instance): | ||
| if "url" in data: | ||
| data["channel_url"] = data.pop("url") | ||
| return data | ||
|
|
||
|
|
||
| class DepartmentRenameChannelUrlToUrl(_DepartmentRenameBase): | ||
| version = "v2" | ||
| serializer = "learning_resources.serializers.LearningResourceBaseDepartmentSerializer" | ||
|
|
||
|
|
||
| class FullDepartmentRenameChannelUrlToUrl(_DepartmentRenameBase): | ||
| version = "v2" | ||
| serializer = "learning_resources.serializers.LearningResourceDepartmentSerializer" | ||
| ``` | ||
|
|
||
| ### Non-DRF data (OpenSearch, raw dicts) | ||
|
|
||
| For data that bypasses DRF serialization but should match a serializer's | ||
| output shape (e.g. OpenSearch hits), use `transform_dict_backwards`: | ||
|
|
||
| ```python | ||
| from mitol.api_versioning.mixins import transform_dict_backwards | ||
|
|
||
| results = [ | ||
| transform_dict_backwards( | ||
| hit["_source"], | ||
| LearningResourceSerializer, | ||
| request, | ||
| recursive=True, # also walk nested serializer fields | ||
| ) | ||
| for hit in hits | ||
| ] | ||
| ``` | ||
|
|
||
| ## Per-version OpenAPI schemas | ||
|
|
||
| drf-spectacular's `SpectacularAPIView` accepts an `api_version` kwarg that it | ||
| threads onto the generator. The postprocessing hook reads that and rewrites | ||
| the schema per version: | ||
|
|
||
| ```python | ||
| # openapi/urls.py | ||
| from django.urls import path | ||
| from drf_spectacular.views import ( | ||
| SpectacularAPIView, | ||
| SpectacularSwaggerView, | ||
| SpectacularRedocView, | ||
| ) | ||
|
|
||
| urlpatterns = [ | ||
| path("api/v1/schema/", | ||
| SpectacularAPIView.as_view(api_version="v1"), | ||
| name="v1_schema"), | ||
| path("api/v1/schema/swagger-ui/", | ||
| SpectacularSwaggerView.as_view(url_name="v1_schema"), | ||
| name="v1_swagger_ui"), | ||
| path("api/v2/schema/", | ||
| SpectacularAPIView.as_view(api_version="v2"), | ||
| name="v2_schema"), | ||
| ... | ||
| ] | ||
| ``` | ||
|
|
||
| Each version's schema URL serves a fully transformed OpenAPI document; point | ||
| your client generator at it. | ||
|
|
||
| ## Debugging | ||
|
|
||
| When a transform doesn't fire, three things will tell you why: | ||
|
|
||
| 1. **`./manage.py check_api_transforms`** runs the bundled system checks for | ||
| every registered transform: | ||
| - `api_versioning.E001` — `serializer` dotted path doesn't resolve | ||
| - `api_versioning.W001` — path resolves but the resolved class's canonical | ||
| `__module__.__qualname__` differs (re-export drift; runtime lookup may | ||
| not match) | ||
| - `api_versioning.E002` — `version` is not in `ALLOWED_VERSIONS` | ||
|
|
||
| Defaults to `--fail-level WARNING` so drift fails the command; pass | ||
| `--fail-level ERROR` to allow warnings. The same checks also run inside | ||
| `./manage.py check` (and on `runserver`/`migrate`/etc), filterable via | ||
| `--tag api_versioning`. Add `./manage.py check_api_transforms` to CI to | ||
| catch misconfigured transforms before they ship. | ||
| 2. **`list_transforms_for_serializer(SerializerClass)`** returns every | ||
| registered transform for that serializer, ordered oldest-first. Useful in | ||
| a Python shell to confirm registration. | ||
| 3. **`log.debug` output** from the mixin: enable `DEBUG` logging on | ||
| `mitol.api_versioning.mixins` to see the chain of transforms applied to | ||
| each request, with version transitions. | ||
|
|
||
| ## Limitations | ||
|
|
||
| This library is scoped to **field-level shape evolution** — renames, additions, | ||
| removals, type coercions. Out of scope: | ||
|
|
||
| - Different business logic per version (different auth, different ORM access) | ||
| - Endpoints that exist in some versions but not others | ||
| - Pre-serialization queryset planning (`select_related` / `prefetch_related`) | ||
| — see below. | ||
|
|
||
| ### Pre-serialization queryset planning | ||
|
|
||
| Transforms run *after* DRF has already serialized the instance, so they can't | ||
| add ORM hints to the queryset. If an older version exposes nested data the | ||
| latest version no longer includes (or no longer prefetches), the older | ||
| version's response can degrade into N+1 queries. | ||
|
|
||
| The fix lives in the view, not the transform: branch on `request.version` in | ||
| `get_queryset()` and only add the extra prefetches for versions that still | ||
| need them. The latest version pays nothing. | ||
|
|
||
| ```python | ||
| class CourseViewSet(viewsets.ReadOnlyModelViewSet): | ||
| serializer_class = CourseSerializer | ||
|
|
||
| def get_queryset(self): | ||
| queryset = Course.objects.all() | ||
| if getattr(self.request, "version", None) == "v1": | ||
| # v1 still embeds nested runs; v2 returns runs_url instead | ||
| queryset = queryset.prefetch_related("runs__instructors") | ||
| return queryset | ||
| ``` | ||
|
|
||
| When an older version needs dramatically different loading behaviour, that's | ||
| a sign the change has crossed from shape-evolution into behavioural divergence | ||
| and a separate versioned view is probably cleaner. | ||
|
|
||
| ### Transform-chain growth | ||
|
|
||
| The transform chain grows over time. Each new version adds transforms, and a | ||
| v1 request against a v5-latest serializer applies four sets of transforms in | ||
| sequence. Test every supported version against every endpoint with transforms | ||
| to catch regressions where transforms interact badly. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| [scriv] | ||
| format = md | ||
| md_header_level = 2 | ||
| entry_title_template = file: ../../scripts/scriv/entry_title.${config:format}.j2 | ||
| version = literal: __init__.py: __version__ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| """mitol.api_versioning""" | ||
|
|
||
| default_app_config = "mitol.api_versioning.apps.ApiVersioningApp" | ||
|
|
||
| __version__ = "2026.3.24" | ||
| __distributionname__ = "mitol-django-api-versioning" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.