Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
urlpatterns = [
path(
"courses/<course_key:course_key>/youtube_transcript_checks/",
YoutubeTranscriptChecksViewSet.as_view({"get": "list"}),
YoutubeTranscriptChecksViewSet.as_view({"get": "retrieve"}),
name="youtube_transcript_check_list",
),
path(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
YoutubeTranscriptCheckView,
YoutubeTranscriptUploadView,
)
from cms.djangoapps.contentstore.rest_api.v2.views.tests.helpers import generate_cms_schema
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
Expand Down Expand Up @@ -829,3 +830,28 @@ def test_every_declared_difference_occurs_somewhere_in_this_suite(self):
)
]
assert missing == [], f"declared but never produced: {missing}"


class TestYoutubeTranscriptSchema(CourseTestCase):
"""What the published schema says each endpoint returns."""

CHECK_PATH = "/api/authoring/v1/courses/{course_key}/youtube_transcript_checks/"
IMPORT_PATH = "/api/authoring/v1/courses/{course_key}/youtube_transcript_imports/"

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.schema = generate_cms_schema()

def _success(self, path, method):
return self.schema["paths"][path][method]["responses"]["200"]["content"]["application/json"]["schema"]

def test_the_check_returns_one_report_not_a_list(self):
assert self._success(self.CHECK_PATH, "get") == {
"$ref": "#/components/schemas/YoutubeTranscriptCheckResult"
}

def test_the_import_returns_one_record(self):
assert self._success(self.IMPORT_PATH, "post") == {
"$ref": "#/components/schemas/YoutubeTranscriptImportResult"
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class YoutubeTranscriptChecksViewSet(StandardizedErrorMixin, _CourseScopedMixin,
**_ERROR_RESPONSES,
},
)
def list(self, request, course_key):
def retrieve(self, request, course_key):
"""Return the transcript availability of the requested video."""
payload = self._payload_from_query(request, YoutubeTranscriptCheckRequestSerializer)
result = get_transcript_check_result(request, payload, course_key)
Expand Down
1 change: 1 addition & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,7 @@ def _should_send_xblock_events(settings):
'drf_spectacular.hooks.postprocess_schema_enums',
'cms.lib.spectacular.cms_mark_superseded_paths',
'cms.lib.spectacular.cms_mark_migrated_paths',
'openedx.core.apidocs.untag_deprecated_operations',
],
# Stripped from each path to derive its tags and operationId, so changing
# it renames every operationId. Paths are published in full, from the
Expand Down
1 change: 1 addition & 0 deletions cms/envs/devstack.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ def should_show_debug_toolbar(request): # pylint: disable=missing-function-docs
'drf_spectacular.hooks.postprocess_schema_enums',
'cms.lib.spectacular.cms_mark_superseded_paths',
'cms.lib.spectacular.cms_mark_migrated_paths',
'openedx.core.apidocs.untag_deprecated_operations',
],
# Stripped from each path to derive its tags and operationId, so changing
# it renames every operationId. Paths themselves are emitted in full so
Expand Down
1 change: 1 addition & 0 deletions cms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def get_env_setting(setting):
'drf_spectacular.hooks.postprocess_schema_enums',
'cms.lib.spectacular.cms_mark_superseded_paths',
'cms.lib.spectacular.cms_mark_migrated_paths',
'openedx.core.apidocs.untag_deprecated_operations',
],
# Stripped from each path to derive its tags and operationId, so changing
# it renames every operationId. Paths themselves are emitted in full so
Expand Down
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2169,6 +2169,7 @@
'POSTPROCESSING_HOOKS': [
'drf_spectacular.hooks.postprocess_schema_enums',
'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',
'openedx.core.apidocs.untag_deprecated_operations',
],
# Stripped from each path to derive its tags and operationId, so changing
# it renames every operationId. Paths themselves are emitted in full.
Expand Down
25 changes: 25 additions & 0 deletions openedx/core/apidocs.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,31 @@ def get_api_docs_settings():
return api_docs_settings


# Tag that marks an operation for the generated platform SDK.
SDK_TAG = "openedx-platform-sdk"


def untag_deprecated_operations(result, generator, request, public): # pylint: disable=unused-argument
"""
Post-processing hook: drop the SDK tag from deprecated operations.

A legacy address kept for its deprecation window is served by the same
view as its replacement, so it inherits the view's SDK tag. Leaving it
tagged would generate a client for an address callers should move off.
Registered after the hooks that mark operations deprecated.
"""
for path_item in result.get("paths", {}).values():
for operation in path_item.values():
if not isinstance(operation, dict) or not operation.get("deprecated"):
continue
tags = [tag for tag in operation.get("tags", []) if tag != SDK_TAG]
if tags:
operation["tags"] = tags
else:
operation.pop("tags", None)
return result


def cached_schema_view():
"""
Build the ``/api-docs`` schema view, caching the rendered document compressed.
Expand Down
57 changes: 57 additions & 0 deletions openedx/core/tests/test_apidocs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for the shared OpenAPI schema helpers."""

from pathlib import Path

from django.test import SimpleTestCase

from openedx.core.apidocs import SDK_TAG, untag_deprecated_operations

HOOK = "'openedx.core.apidocs.untag_deprecated_operations',"
REPO_ROOT = Path(__file__).resolve().parents[3]


def _schema(**operations):
return {"paths": {"/api/x/": operations}}


class UntagDeprecatedOperationsTest(SimpleTestCase):
"""A deprecated operation is never offered to the SDK."""

def test_the_sdk_tag_is_dropped_from_a_deprecated_operation(self):
result = untag_deprecated_operations(
_schema(get={"deprecated": True, "tags": [SDK_TAG, "v3"]}), None, None, True,
)
assert result["paths"]["/api/x/"]["get"]["tags"] == ["v3"]

def test_an_operation_left_without_tags_has_no_tags_key(self):
result = untag_deprecated_operations(
_schema(get={"deprecated": True, "tags": [SDK_TAG]}), None, None, True,
)
assert "tags" not in result["paths"]["/api/x/"]["get"]

def test_a_current_operation_keeps_its_sdk_tag(self):
result = untag_deprecated_operations(_schema(get={"tags": [SDK_TAG]}), None, None, True)
assert result["paths"]["/api/x/"]["get"]["tags"] == [SDK_TAG]

def test_non_operation_entries_are_left_alone(self):
result = untag_deprecated_operations(
_schema(parameters=[{"name": "id"}], get={"deprecated": True}), None, None, True,
)
assert result["paths"]["/api/x/"]["parameters"] == [{"name": "id"}]


class UntagDeprecatedOperationsRegistrationTest(SimpleTestCase):
"""Every schema configuration runs the hook after the hooks that mark operations deprecated."""

MARKING_HOOKS = {
"cms/envs/common.py": "'cms.lib.spectacular.cms_mark_migrated_paths',",
"cms/envs/devstack.py": "'cms.lib.spectacular.cms_mark_migrated_paths',",
"cms/envs/production.py": "'cms.lib.spectacular.cms_mark_migrated_paths',",
"lms/envs/common.py": "'lms.lib.spectacular.lms_mark_legacy_paths_deprecated',",
}

def test_the_hook_runs_last(self):
for module, marking_hook in self.MARKING_HOOKS.items():
source = (REPO_ROOT / module).read_text()
assert HOOK in source, module
assert source.index(marking_hook) < source.index(HOOK), module
Loading