FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Feat: replace drf yasg with drf spectacular by Faraz32123 · Pull Request #39108 · openedx/openedx-platform · GitHub

Feat: replace drf yasg with drf spectacular - #39108

Open
Faraz32123 wants to merge 8 commits into
masterfrom
feat/replace_drf_yasg_with_drf_spectacular
Open

Faraz32123 wants to merge 8 commits into
masterfrom
feat/replace_drf_yasg_with_drf_spectacular

Conversation

Faraz32123 commented Sep 16, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

This PR can be merged after this schema generation PR: #39025

Replace drf-yasg with drf-spectacular

Follow-up to the FC-0118 API standardization work. Migrates all platform API
documentation off drf-yasg and edx-api-doc-tools onto drf-spectacular,
including the /api-docs site itself.

Slack Discussion thread, the goal was to drop both dependencies as part
of the drf-spectacular conversion, replacing the edx-api-doc-tools /api-docs
endpoint with the drf-spectacular equivalent.

What changed

~400 decorator call sites across 43 files, following the
drf-yasg migration guide:

  • @swagger_auto_schema / @apidocs.schema → @extend_schema
  • @apidocs.schema_for → @extend_schema_view
  • apidocs.string_parameter / query_parameter / path_parameter and
    openapi.Parameter → OpenApiParameter
  • Bare string responses (401: "Not authenticated.") → OpenApiResponse
  • openapi.Schema objects → inline_serializer where they had structure,
    OpenApiTypes where they didn't
  • /api-docs now served by SpectacularAPIView / SpectacularSwaggerView /
    SpectacularRedocView

Commits

Reviewable one at a time, each independently handled:

  1. Direct drf_yasg users (5 files)
  2. openedx/core (10 files)
  3. lms, excluding instructor (4 files)
  4. instructor (2 files, ~120 sites)
  5. cms (22 files)
  6. drf-spectacular extensions for BaseSerializer subclasses
  7. /api-docs swap and dependency removal
  8. Review feedback

Three things worth knowing

The dependencies don't actually disappear. edx-api-doc-tools is still
required by openedx-authz, and drf-yasg by django-user-tasks. Both remain
in base.txt as transitive dependencies. What this PR removes is the platform's
own dependency on them — pyproject.toml, INSTALLED_APPS, and every import.
Fully dropping them needs those upstream packages to migrate first.

Commit 6 exists because /api-docs is unfiltered. Six serializers subclass
BaseSerializer, which has no fields attribute, so drf-spectacular raised
AttributeError when generating a schema covering the whole surface. This never
surfaced before because the existing drf-spectacular schemas
(/authoring-api/, /lms-api/) are filtered down to a handful of endpoints.
Each serializer now has an OpenApiSerializerExtension declaring the type it
actually produces.

/api-docs documents more than it used to. edx-api-doc-tools'
ApiSchemaGenerator kept only paths under /api/; drf-spectacular documents
every DRF endpoint in the service — 633 LMS paths versus the 293 in the
committed docs/lms-openapi.yaml. That file also becomes an OpenAPI 3 document
rather than Swagger 2.0, with untrimmed paths. Flagging it so the widening is a
recorded decision rather than a side effect.

Verification

Tested against a running Tutor instance:

Before After
LMS /api-docs drf-yasg 633 paths, 304 components
CMS /api-docs drf-yasg 236 paths, 185 components
/authoring-api/schema/ 57 paths 57 paths (unchanged)
/lms-api/schema/ 16 paths 16 paths (unchanged)

The last two matter most: /api-docs uses custom_settings rather than the
global SPECTACULAR_SETTINGS, so the narrow SDK-facing schemas are untouched —
same paths, same prefix trimming. The SDK's generated client is unaffected.

Swagger UI and ReDoc confirmed rendering on both services. swagger.json,
swagger.yaml, api-docs/ and swagger/ keep their existing paths and URL
names; api-docs/schema/ is new, because the Swagger and ReDoc views reverse
their schema URL without arguments.

Schema caching is preserved: with OPENAPI_CACHE_TIMEOUT at its 1-hour default,
the first request to /api-docs/schema/ took 5.69s and the second 0.08s.

Notes for reviewers

  • Commit 4 was partly machine-generated. instructor/views/api_v2.py had 91
    string responses and 46 parameters, so the repetitive conversions were
    scripted. Spot-checking won't catch a systematic miss — I verified by
    extracting every response and parameter from both versions and diffing the
    sets. Same method used on commits 1–3 and 5 to confirm nothing was dropped.
  • docs/docs_settings.py security definitions moved from Swagger 2.0
    (type: basic) to OpenAPI 3 (type: http, scheme: basic). This is the one
    change I couldn't exercise locally
    — it only takes effect in the Sphinx docs
    build.
  • Swagger UI and ReDoc assets are served by drf-spectacular-sidecar, so they
    stay self-hosted and version-pinned as the drf-yasg bundles were, rather than
    drf-spectacular's default unpinned @latest CDN URLs. This is a new
    dependency — a uv sync or container rebuild is needed on this branch.
  • Some pre-existing schema warnings remain (views without serializer_class,
    unresolvable authenticators, two CourseEnrollment components with clashing
    names). They don't block generation and are out of scope here.

Faraz32123 self-assigned this Sep 16, 2026
Faraz32123 changed the title Feat/replace drf yasg with drf spectacular Feat: replace drf yasg with drf spectacular Sep 16, 2026
Converts @swagger_auto_schema to @extend_schema in the five modules that
import drf_yasg directly, following the drf-spectacular migration guide:
https://drf-spectacular.readthedocs.io/en/latest/drf_yasg.html

Structured openapi.Schema objects become inline_serializer so they get
named components in the generated schema; untyped ones become
OpenApiTypes.OBJECT. openapi.Parameter becomes OpenApiParameter.

The other four drf_yasg users go through edx-api-doc-tools and will be
migrated with it.
Converts @apidocs.schema to @extend_schema across openedx/core, replacing
the parameter helpers with OpenApiParameter and string response
descriptions with OpenApiResponse.

bookmarks/serializers.py inlines is_schema_request, which has no
drf-spectacular equivalent, and extends it to recognise drf-spectacular's
swagger_fake_view alongside drf-yasg's format=openapi.
Converts @apidocs.schema and @Schema to @extend_schema across the lms
app, excluding instructor. Parameter helpers become OpenApiParameter and
string response descriptions become OpenApiResponse.

discussion/rest_api/views.py also drops its remaining direct drf_yasg
import, which was interleaved with the apidocs decorators.
Converts the 26 @apidocs.schema decorators in the instructor v1 and v2
APIs to @extend_schema.

The course_id, problem, and exam_id path parameters were repeated
verbatim across 29 decorators; those are now module-level constants.
Converts the remaining @apidocs.schema decorators across contentstore
and modulestore_migrator to @extend_schema. The three class-level
@apidocs.schema_for decorators become @extend_schema_view, splitting
each docstring into summary and description as schema_for did.

Files that already imported drf-spectacular for the FC-0118 work have
their import lines merged rather than duplicated.
Six serializers subclass BaseSerializer, which has no `fields` attribute,
so drf-spectacular raises AttributeError when generating a schema that
covers them. Each extension declares the type its serializer produces.

Registered from CommonInitializationConfig.ready() so they load in both
services regardless of which schema is being generated.
Faraz32123 force-pushed the feat/replace_drf_yasg_with_drf_spectacular branch 2 times, most recently from 6e12f7f to c232307 Compare September 16, 2026 08:43
Replaces make_docs_urls with SpectacularAPIView, SpectacularSwaggerView
and SpectacularRedocView, preserving the swagger.json, swagger.yaml,
api-docs/ and swagger/ routes and their URL names. The UI views reverse
their schema URL without arguments, so api-docs/schema/ is registered
alongside the format-suffixed routes.

/api-docs serves the full API surface via custom_settings, leaving
SPECTACULAR_SETTINGS to the narrower Authoring and Enrollment schemas
the SDK consumes.

Also removes drf_yasg from INSTALLED_APPS, drops SWAGGER_SETTINGS, and
converts the docs security definitions to OpenAPI 3 form. `make swagger`
now runs `manage.py lms spectacular`, since generate_swagger came from
drf_yasg; docs_settings applies the same unfiltered configuration as
/api-docs so the generated file still covers the whole surface.

edx-api-doc-tools and drf-yasg remain installed as transitive
dependencies of openedx-authz and django-user-tasks respectively.
Faraz32123 force-pushed the feat/replace_drf_yasg_with_drf_spectacular branch from c232307 to b9f6467 Compare September 16, 2026 08:45
Faraz32123 marked this pull request as ready for review September 16, 2026 10:32
Faraz32123 requested review from a team as code owners September 16, 2026 10:32
Comment thread lms/urls.py Outdated
urlpatterns += [
re_path(
r'^swagger\.(?P<format>json|yaml)$',
SpectacularAPIView.as_view(custom_settings=get_api_docs_settings()),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

These endpoints lose their server-side cache.

openedx/envs/common.py has, with the comment "How long to cache OpenAPI schemas and UI, in seconds":

OPENAPI_CACHE_TIMEOUT = 60 * 60

Nothing in lms/envs/production.py or cms/envs/production.py overrides it — only devstack and test set it to 0 — so production runs with a 1-hour cache today. edx-api-doc-tools fed that value into SchemaView.as_cached_view, which wraps the view in vary_on_headers("Cookie", "Authorization") + cache_page(timeout) (drf_yasg/views.py:134-164).

SpectacularAPIView has no caching of any kind, so after this change every request to /swagger.json, /swagger.yaml, and the new /api-docs/schema/ regenerates the entire schema. These are public and unauthenticated (SERVE_PERMISSIONS defaults to AllowAny, which matches the old permission_classes=(AllowAny,)), and the schema being generated is also roughly twice as large as before (see my note on openedx/core/apidocs.py).

It also leaves OPENAPI_CACHE_TIMEOUT as a dead setting that still claims to do something.

Could we keep using the existing setting — wrapping the two SpectacularAPIView routes in cache_page(settings.OPENAPI_CACHE_TIMEOUT)? Same applies to cms/urls.py.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Good catch, I'll use the existing setting.

Comment thread lms/urls.py
Comment thread lms/urls.py
Comment thread openedx/core/apidocs.py
Comment thread openedx/core/apidocs.py Outdated
"""
Build the ``/api-docs`` schema settings, adding contact details if available.

``API_ACCESS_MANAGER_EMAIL`` is an LMS-only setting, so it is included only

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Small correction: API_ACCESS_MANAGER_EMAIL isn't LMS-only. It's defined in openedx/envs/common.py (~line 2721), which both lms/envs/common.py and cms/envs/common.py star-import — the old code here read it unconditionally at import time from a module cms/urls.py imported, which wouldn't have worked otherwise.

The getattr(..., None) guard is harmless, but the docstring should probably just say the contact is included when the setting is present, without the LMS-only claim.

``AttributeError`` when it tries to walk them. Each extension below declares
the type its serializer actually produces.

The extensions self-register on import; ``lms.lib.spectacular`` and

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

This paragraph doesn't match the implementation — neither lms/lib/spectacular.py nor cms/lib/spectacular.py imports this module. The only importer is openedx/core/djangoapps/common_initialization/apps.py in ready(), which is the right place.

Looks like a leftover from an earlier approach; worth repointing the docstring at the AppConfig so the next reader can find the registration site.


Schema generators set a swagger_fake_view attribute on the view; that is
the drf-spectacular-compatible signal. ``format=openapi`` is drf-yasg's
convention, kept while it still serves ``/api-docs``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

The swagger_fake_view check is correct — drf-spectacular sets it on the view (generators.py:141) before build_mock_request builds the DRF Request, so parser_context['view'] resolves to that view.

The docstring is stale though: this PR is what stops drf-yasg serving /api-docs, so "kept while it still serves /api-docs" no longer holds and the format=openapi branch is dead as far as the platform is concerned. Either drop the fallback or reword to say it's kept for out-of-tree callers.

Comment thread lms/envs/common.py Outdated
@@ -2149,10 +2148,6 @@

######################### Django Rest Framework ########################

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Nit: removing SWAGGER_SETTINGS leaves this banner with nothing under it. Worth deleting the header too, since the drf-spectacular block below has its own.

Faraz32123 marked this pull request as draft September 17, 2026 14:33
Faraz32123 force-pushed the feat/replace_drf_yasg_with_drf_spectacular branch from 66825db to dedc96a Compare September 17, 2026 14:40
- restore server-side caching on the OpenAPI schema endpoints, which
  edx-api-doc-tools provided via SchemaView.as_cached_view
- point the api-docs test at /api-docs/schema/ so it exercises schema
  generation again, and add the CMS equivalent
- serve Swagger UI and ReDoc assets from drf-spectacular-sidecar instead
  of drf-spectacular's unpinned jsdelivr CDN defaults
- correct the /api-docs comment: edx-api-doc-tools was /api/-only, so
  this widens the documented surface rather than being "the opposite"
- drop the LMS-only claim about API_ACCESS_MANAGER_EMAIL, which lives in
  openedx/envs/common.py and is shared by both services
- point the schema_extensions docstring at CommonInitializationConfig,
  the actual registration site
- remove the dead format=openapi branch from is_schema_request, since
  nothing in the platform serves drf-yasg any more
- delete the now-empty Django Rest Framework banner in lms/envs/common.py
Faraz32123 force-pushed the feat/replace_drf_yasg_with_drf_spectacular branch from dedc96a to 4bf390f Compare September 17, 2026 14:54
Faraz32123 marked this pull request as ready for review September 17, 2026 15:27
Faraz32123 requested a review from feanil September 17, 2026 15:27
Comment thread lms/urls.py
# Schema generation is expensive and these endpoints are public, so both schema
# routes are cached for OPENAPI_CACHE_TIMEOUT, as edx-api-doc-tools did via
# SchemaView.as_cached_view.
_apidocs_schema_view = cache_page(settings.OPENAPI_CACHE_TIMEOUT)(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Sorry, I didn't point this out before, but it turns out that the document here might be too big for the cache as is. I measured the rendered response at head 4bf390f6 under docs.docs_settings: /api-docs/schema/ and /swagger.yaml pickle to 1,253,795 bytes and /swagger.json to 1,694,003 bytes, both over memcached's 1MiB default item limit. CACHES['default'] is PyMemcacheCache with ignore_exc: True (openedx/envs/common.py:362-373), so the failed set is swallowed and Django deletes the key.

Cache the compressed body instead of letting cache_page pickle the whole Response, the way openedx/core/lib/edx_api_utils.py:78-121 already does:

# openedx/core/apidocs.py
import logging

from django.core.cache import cache
from django.http import HttpResponse
from django.utils.cache import add_never_cache_headers
from edx_django_utils.cache import get_cache_key

from openedx.core.lib.cache_utils import zpickle, zunpickle

log = logging.getLogger(__name__)


def cached_schema_view():
    """
    Build the ``/api-docs`` schema view, caching the rendered document compressed.

    ``cache_page`` stores the pickled ``Response``, which for this schema is ~1.7MB and so
    over memcached's default 1MB item limit: the ``set`` fails and nothing is ever cached.
    Storing the zlib-compressed body instead brings it to ~110KB.  The document is identical
    for every requester, so the key covers only the path and the negotiated representation.

    ``drf_spectacular.views`` is imported inside the function, not at module scope: this
    module is imported from the settings, and importing DRF views that early freezes
    ``api_settings`` before ``DEFAULT_SCHEMA_CLASS`` is set, which makes schema generation
    fail with ``Incompatible AutoSchema used on View``.
    """
    from drf_spectacular.views import SpectacularAPIView

    view = SpectacularAPIView.as_view(custom_settings=get_api_docs_settings())

    def _response(content_type, body):
        response = HttpResponse(body, content_type=content_type)
        add_never_cache_headers(response)
        return response

    def schema_view(request, *args, **kwargs):
        if not settings.OPENAPI_CACHE_TIMEOUT:
            return view(request, *args, **kwargs)
        cache_key = get_cache_key(
            resource='apidocs-schema',
            path=request.get_full_path(),
            accept=request.META.get('HTTP_ACCEPT', ''),
        ) + '.zpickled'
        cached = cache.get(cache_key)
        if cached:
            try:
                content_type, body = zunpickle(cached)
            except Exception:  # pylint: disable=broad-except
                log.warning("Data for cache is corrupt for cache key %s", cache_key)
                cache.delete(cache_key)
            else:
                return _response(content_type, body)

        response = view(request, *args, **kwargs).render()
        if response.status_code != 200:
            return response
        content_type = response['Content-Type']
        cache.set(cache_key, zpickle((content_type, response.content)),
                  settings.OPENAPI_CACHE_TIMEOUT)
        return _response(content_type, response.content)

    return schema_view

Then here, dropping the cache_page and vary_on_headers imports:

_apidocs_schema_view = cached_schema_view()

I ran the above on 4bf390f6 against a local memory cache: largest stored entry 109,504 bytes, 10.4% of the limit, hits return in 0.003s against 0.33 to 0.76s to generate, and the bodies are byte identical to the uncached ones. add_never_cache_headers restores the no-store drf-yasg got from deferred_never_cache (drf_yasg/views.py:134-140), which is why these routes currently answer Cache-Control: max-age=3600. Dropping vary_on_headers is safe because nothing in the document varies by user: SERVE_PUBLIC defaults to True, SERVE_* cannot be set through custom_settings, and API_DOCS_SETTINGS sets 'SERVERS': [] so no servers block is emitted (drf_spectacular/plumbing.py:526-527).

Same block in cms/urls.py.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants


Back | FazBrowse Home | New Git URL