Skip to content

Feat: replace drf yasg with drf spectacular - #39108

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

Faraz32123 wants to merge 7 commits into
masterfrom
feat/replace_drf_yasg_with_drf_spectacular

Conversation

@Faraz32123

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.ParameterOpenApiParameter
  • 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

Two 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.

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.

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.
  • drf-spectacular serves the Swagger UI assets from a CDN rather than bundling
    them. Rendering is fine on a normal deployment; air-gapped installs would need
    drf-spectacular-sidecar.
  • 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 Faraz32123 self-assigned this Sep 16, 2026
@Faraz32123 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
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
Faraz32123 force-pushed the feat/replace_drf_yasg_with_drf_spectacular branch from c232307 to b9f6467 Compare September 16, 2026 08:45
@Faraz32123
Faraz32123 marked this pull request as ready for review September 16, 2026 10:32
@Faraz32123
Faraz32123 requested review from a team as code owners September 16, 2026 10:32
Comment thread lms/urls.py
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.

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.

Comment thread lms/urls.py
name='apidocs-schema',
),
path(
'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.

Heads up: this quietly drops the only test coverage of schema generation.

lms/tests.py has:

def test_api_docs(self):
    """Tests that requests to the `/api-docs/` endpoint do not raise an exception."""
    response = self.client.get('/api-docs/')
    assert response.status_code == 200

Under drf-yasg that genuinely exercised generation end-to-end: SchemaView.get() calls generator.get_schema(...) unconditionally, including when the accepted renderer is the Swagger UI one (drf_yasg/views.py:122-132).

SpectacularSwaggerView only renders a template shell and never touches the generator, so the test will keep passing while covering nothing — and it's precisely the coverage that would have caught the BaseSerializer AttributeError that commit 6 exists to fix.

Could you point that test at /api-docs/schema/ instead, and add the CMS equivalent (which has no such test today)? That's cheap and it guards the whole migration.

Comment thread lms/urls.py
),
path(
'api-docs/',
SpectacularSwaggerView.as_view(url_name='apidocs-schema'),

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.

Could we use drf-spectacular-sidecar here rather than the CDN defaults?

drf-spectacular 0.30.0 ships these defaults (drf_spectacular/settings.py:83-85):

'SWAGGER_UI_DIST': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest',
'SWAGGER_UI_FAVICON_HREF': 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@latest/favicon-32x32.png',
'REDOC_DIST': 'https://cdn.jsdelivr.net/npm/redoc@latest',

drf-yasg served these assets from its own bundled staticfiles, so this swaps self-hosted JS for unpinned third-party JS executing on the LMS and CMS origin. Every Open edX deployment would silently pick up whatever swagger-ui and redoc publish so it would introduce a potential supply chain vulnerability.

drf-spectacular-sidecar is the upstream-supported answer (self-hosted, version-pinned, and what the drf-spectacular README recommends): add the dependency, put drf_spectacular_sidecar in INSTALLED_APPS, and set SWAGGER_UI_DIST/SWAGGER_UI_FAVICON_HREF/REDOC_DIST to 'SIDECAR'. That also resolves the air-gapped caveat in your description.

Comment thread openedx/core/apidocs.py
'VERSION': 'v1',
# Document every endpoint, without the per-service filtering and prefix
# trimming that SPECTACULAR_SETTINGS applies.
'PREPROCESSING_HOOKS': [],

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.

Note only — no change needed in this PR.

The comment above frames /api-docs as "the full, untrimmed API surface" in contrast to the narrower per-service schemas, but the implementation being replaced wasn't unfiltered either. make_docs_urls(api_info) with no api_url_patterns (how both lms/urls.py and cms/urls.py called it) resolves to ApiSchemaGenerator, which overrides get_endpoints to keep only paths starting with /api/ and pins determine_path_prefix to /api/ (edx_api_doc_tools/conf_utils.py:157-177).

Your own numbers show the delta: 633 LMS paths now, versus 293 in the committed docs/lms-openapi.yaml, which the old /api/-only generator produced (all /api-relative under basePath: /api).

So two things change: the public docs now describe every DRF endpoint in the service rather than just the versioned /api/* surface, and make swagger — which feeds docs/references/lms_apis.rst — will turn that committed file from a 293-path Swagger 2.0 document into a ~633-path OpenAPI 3 one with untrimmed paths.

We're replacing docs/lms-openapi.yaml, with the new schema files. Flagging it mainly so the widening is a recorded decision rather than a side effect. Might be worth a sentence in the PR description.

Comment thread openedx/core/apidocs.py
"""
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.

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.

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.

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
@@ -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.

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.

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