diff --git a/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py index d7679fa3104d..5a53ae650413 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/authoring_urls.py @@ -13,7 +13,7 @@ urlpatterns = [ path( "courses//youtube_transcript_checks/", - YoutubeTranscriptChecksViewSet.as_view({"get": "list"}), + YoutubeTranscriptChecksViewSet.as_view({"get": "retrieve"}), name="youtube_transcript_check_list", ), path( diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py index bef2c4944729..902b1a75c753 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_youtube_transcripts.py @@ -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 @@ -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" + } diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/youtube_transcripts.py b/cms/djangoapps/contentstore/rest_api/v1/views/youtube_transcripts.py index b315e351e940..44047a0407bd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/youtube_transcripts.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/youtube_transcripts.py @@ -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) diff --git a/cms/envs/common.py b/cms/envs/common.py index 87ee13f3e0a6..03b1add9fb18 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -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 diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e3fe5f22a3d6..f5932312f66f 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -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 diff --git a/cms/envs/production.py b/cms/envs/production.py index 596d70c9f9cc..ea5cda6310ea 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -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 diff --git a/lms/envs/common.py b/lms/envs/common.py index 1fc662d26e15..fb60afe0070a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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. diff --git a/openedx/core/apidocs.py b/openedx/core/apidocs.py index 4c96c8cce109..838c61b6bb56 100644 --- a/openedx/core/apidocs.py +++ b/openedx/core/apidocs.py @@ -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. diff --git a/openedx/core/tests/test_apidocs.py b/openedx/core/tests/test_apidocs.py new file mode 100644 index 000000000000..417d7f9f48c5 --- /dev/null +++ b/openedx/core/tests/test_apidocs.py @@ -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