diff --git a/src/boost_weblate/endpoint/apps.py b/src/boost_weblate/endpoint/apps.py index b5c5677..c36bb20 100644 --- a/src/boost_weblate/endpoint/apps.py +++ b/src/boost_weblate/endpoint/apps.py @@ -17,6 +17,14 @@ def register_plugin_urls() -> None: """Append this app's routes to Weblate's pattern list. + This is the supported integration path: at process + startup, append a single ``path("boost-endpoint/", ...)`` entry to + ``weblate.urls.real_patterns`` so routes stay under Weblate's ``URL_PREFIX`` + handling. + + Exposed HTTP paths (relative to ``/boost-endpoint/``): ``info/``, + ``add-or-update/``, and ``plugin-ping/`` (see ``boost_weblate.endpoint.urls``). + Weblate builds ``urlpatterns`` from module-level ``real_patterns`` (see ``weblate.urls``). Optional integrations append to ``real_patterns`` before the ``URL_PREFIX`` wrapper is applied, so mutating that list keeps routes @@ -42,13 +50,22 @@ def register_plugin_urls() -> None: return wl_urls.real_patterns.append( - path("boost-endpoint/", include("boost_weblate.endpoint.urls")), + path( + "boost-endpoint/", + include(("boost_weblate.endpoint.urls", "boost_endpoint")), + ), ) setattr(wl_urls, _PLUGIN_URLS_ATTR, True) class BoostEndpointConfig(AppConfig): - """Registers ``/boost-endpoint/`` on the Weblate URLconf when the app loads.""" + """Django app config for the Boost documentation translation HTTP API. + + On load, :meth:`ready` calls :func:`register_plugin_urls` once (idempotent) to + mount ``/boost-endpoint/`` with ``info/``, ``add-or-update/``, and + ``plugin-ping/`` routes (application namespace ``boost_endpoint`` for URL + reversing). + """ default_auto_field = "django.db.models.BigAutoField" name = "boost_weblate.endpoint" @@ -56,4 +73,8 @@ class BoostEndpointConfig(AppConfig): verbose_name = "Boost documentation translation API" def ready(self) -> None: + """Register plugin URL patterns with Weblate. + + Delegates to :func:`register_plugin_urls`. + """ register_plugin_urls() diff --git a/src/boost_weblate/endpoint/serializers.py b/src/boost_weblate/endpoint/serializers.py index b0c0de3..dea74d0 100644 --- a/src/boost_weblate/endpoint/serializers.py +++ b/src/boost_weblate/endpoint/serializers.py @@ -3,3 +3,68 @@ # SPDX-License-Identifier: BSL-1.0 """DRF serializers for the Boost documentation translation API.""" + +from __future__ import annotations + +from typing import Any + +from rest_framework import serializers + + +class AddOrUpdateRequestSerializer(serializers.Serializer): + """Serializer for add_or_update endpoint request.""" + + organization = serializers.CharField( + required=True, + help_text="GitHub organization name (e.g., 'CppDigest')", + ) + add_or_update = serializers.DictField( + child=serializers.ListField(child=serializers.CharField()), + required=True, + allow_empty=False, + help_text=( + "Map language code -> list of submodule names. " + 'E.g. {"zh_Hans": ["json", "unordered"], "ja": ["json"]}. ' + "Service runs for each lang_code with its submodule array." + ), + ) + version = serializers.CharField( + required=True, + help_text="Boost version (e.g., 'boost-1.90.0')", + ) + extensions = serializers.ListField( + child=serializers.CharField(), + required=False, + allow_null=True, + default=None, + help_text=( + "Optional list of file extensions to include (e.g. ['.adoc', '.md']). " + "Only Weblate-supported extensions in this list are scanned. " + "If None or empty, all Weblate-supported extensions are used." + ), + ) + + def validate_add_or_update(self, value: dict[str, Any]) -> dict[str, Any]: + """Require non-empty string language keys and non-empty submodule lists.""" + errors: dict[str, str] = {} + for lang_code, submodules in value.items(): + if not isinstance(lang_code, str) or lang_code.strip() == "": + errors[str(lang_code)] = ( + "add_or_update: each key must be a non-empty language code; " + f"got {repr(lang_code)}" + ) + continue + if not isinstance(submodules, list): + errors[str(lang_code)] = ( + "add_or_update: each value must be a non-empty list of submodule " + f"names; key {lang_code!r} is not a list " + f"(got {type(submodules).__name__})." + ) + elif len(submodules) == 0: + errors[str(lang_code)] = ( + "add_or_update: each value must be a non-empty list of submodule " + f"names; key {lang_code!r} has an empty list." + ) + if errors: + raise serializers.ValidationError(errors) + return value diff --git a/src/boost_weblate/endpoint/services.py b/src/boost_weblate/endpoint/services.py index 650f4fa..67d1016 100644 --- a/src/boost_weblate/endpoint/services.py +++ b/src/boost_weblate/endpoint/services.py @@ -3,3 +3,41 @@ # SPDX-License-Identifier: BSL-1.0 """Service layer for the Boost documentation translation API.""" + +from __future__ import annotations + +from typing import Any + + +class BoostComponentService: + """Service for managing Boost documentation components (internal Django usage). + + Full ORM-backed implementation is planned; callers receive + :class:`NotImplementedError` from :meth:`process_all` until that work lands. + """ + + def __init__( + self, + *, + organization: str, + lang_code: str, + version: str, + extensions: list[str] | None = None, + ) -> None: + self.organization = organization + self.lang_code = lang_code + self.version = version + self.extensions = extensions + + def process_all( + self, + submodules: list[str], + *, + user: Any, + request: Any = None, + ) -> dict[str, Any]: + """Clone, scan, and create/update Weblate projects and components.""" + raise NotImplementedError( + "BoostComponentService.process_all is not implemented in this plugin " + "release; it will be added in a follow-up change." + ) diff --git a/src/boost_weblate/endpoint/urls.py b/src/boost_weblate/endpoint/urls.py index de3f9fb..926f774 100644 --- a/src/boost_weblate/endpoint/urls.py +++ b/src/boost_weblate/endpoint/urls.py @@ -13,5 +13,11 @@ app_name = "boost_endpoint" urlpatterns = [ + path("info/", views.BoostEndpointInfo.as_view(), name="info"), + path( + "add-or-update/", + views.AddOrUpdateView.as_view(), + name="add-or-update", + ), path("plugin-ping/", views.plugin_ping, name="plugin-ping"), ] diff --git a/src/boost_weblate/endpoint/views.py b/src/boost_weblate/endpoint/views.py index cd6d059..de09439 100644 --- a/src/boost_weblate/endpoint/views.py +++ b/src/boost_weblate/endpoint/views.py @@ -4,11 +4,133 @@ from __future__ import annotations +import logging + from django.http import HttpResponse from django.views.decorators.http import require_GET +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from boost_weblate.endpoint.serializers import AddOrUpdateRequestSerializer +from boost_weblate.endpoint.services import BoostComponentService + +logger = logging.getLogger(__name__) @require_GET def plugin_ping(_request): """Minimal health-style endpoint for URL registration smoke tests.""" return HttpResponse("ok", content_type="text/plain") + + +class BoostEndpointInfo(APIView): + """Boost documentation translation API info.""" + + permission_classes = (IsAuthenticated,) + + def get(self, request, format=None): # noqa: A002 + """Return Boost endpoint module info.""" + return Response( + { + "module": "cppa-weblate-plugin", + "description": "Boost documentation translation API", + } + ) + + +class AddOrUpdateView(APIView): + """Add or update Boost documentation components.""" + + permission_classes = (IsAuthenticated,) + + def post(self, request, format=None): # noqa: A002 + """ + Create or update Boost documentation components. + + add_or_update is a map: lang_code -> [submodule names]. For each lang_code + the service runs with that language and its submodule list (clone, scan, + create/update project and components, add language). + """ + serializer = AddOrUpdateRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + data = serializer.validated_data + organization = data["organization"] + add_or_update = data["add_or_update"] + version = data["version"] + extensions = data.get("extensions") + + languages: dict[str, dict[str, object]] = {} + for lang_code, submodules in add_or_update.items(): + try: + service = BoostComponentService( + organization=organization, + lang_code=lang_code, + version=version, + extensions=extensions, + ) + languages[lang_code] = { + "status": "success", + "result": service.process_all( + submodules, user=request.user, request=request + ), + } + except NotImplementedError as exc: + logger.warning( + "boost_weblate.endpoint.AddOrUpdateView: add-or-update not " + "implemented (organization=%s, lang_code=%s): %s", + organization, + lang_code, + exc, + ) + languages[lang_code] = { + "status": "error", + "error": str(exc), + "code": "not_implemented", + } + except Exception: + logger.exception( + "boost_weblate.endpoint.AddOrUpdateView: add-or-update failed " + "(organization=%s, lang_code=%s)", + organization, + lang_code, + ) + languages[lang_code] = { + "status": "error", + "error": "Internal server error", + "code": "internal_error", + } + + body: dict[str, object] = { + "organization": organization, + "languages": languages, + } + has_success = any(v.get("status") == "success" for v in languages.values()) + has_error = any(v.get("status") == "error" for v in languages.values()) + + if not has_error: + return Response(body, status=status.HTTP_200_OK) + if has_success and has_error: + return Response(body, status=status.HTTP_207_MULTI_STATUS) + + if all(v.get("code") == "not_implemented" for v in languages.values()): + first_error = next( + str(v["error"]) + for v in languages.values() + if v.get("status") == "error" + ) + return Response( + {"detail": first_error, **body}, + status=status.HTTP_501_NOT_IMPLEMENTED, + ) + + return Response( + {"error": "Internal server error", **body}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/tests/endpoint/__init__.py b/tests/endpoint/__init__.py new file mode 100644 index 0000000..62d857b --- /dev/null +++ b/tests/endpoint/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 diff --git a/tests/endpoint/test_apps.py b/tests/endpoint/test_apps.py new file mode 100644 index 0000000..d62a576 --- /dev/null +++ b/tests/endpoint/test_apps.py @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +from __future__ import annotations + +import builtins +import sys +import types + +import pytest + +from boost_weblate.endpoint.apps import register_plugin_urls + + +def test_register_plugin_urls_skips_when_weblate_urls_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_import = builtins.__import__ + + def fake_import(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if name == "weblate.urls": + raise ModuleNotFoundError("weblate.urls") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + # Should not raise; no fake module to inspect. + register_plugin_urls() + + +def test_register_plugin_urls_skips_without_real_patterns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake = types.ModuleType("weblate.urls") + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + register_plugin_urls() + assert not hasattr(fake, "real_patterns") + + +def test_register_plugin_urls_appends_once(monkeypatch: pytest.MonkeyPatch) -> None: + fake = types.ModuleType("weblate.urls") + fake.real_patterns = [] + monkeypatch.setitem(sys.modules, "weblate.urls", fake) + + register_plugin_urls() + register_plugin_urls() + + assert len(fake.real_patterns) == 1 diff --git a/tests/endpoint/test_serializers.py b/tests/endpoint/test_serializers.py new file mode 100644 index 0000000..ddb3732 --- /dev/null +++ b/tests/endpoint/test_serializers.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +from __future__ import annotations + +from boost_weblate.endpoint.serializers import AddOrUpdateRequestSerializer + + +def test_add_or_update_serializer_valid_minimal() -> None: + ser = AddOrUpdateRequestSerializer( + data={ + "organization": "CppDigest", + "version": "boost-1.90.0", + "add_or_update": {"zh_Hans": ["json"]}, + } + ) + assert ser.is_valid(), ser.errors + assert ser.validated_data["organization"] == "CppDigest" + assert ser.validated_data["version"] == "boost-1.90.0" + assert ser.validated_data["add_or_update"] == {"zh_Hans": ["json"]} + assert ser.validated_data.get("extensions") is None + + +def test_add_or_update_serializer_accepts_extensions() -> None: + ser = AddOrUpdateRequestSerializer( + data={ + "organization": "o", + "version": "v", + "add_or_update": {"ja": ["unordered"]}, + "extensions": [".adoc", ".md"], + } + ) + assert ser.is_valid(), ser.errors + assert ser.validated_data["extensions"] == [".adoc", ".md"] + + +def test_add_or_update_serializer_rejects_empty_map() -> None: + ser = AddOrUpdateRequestSerializer( + data={ + "organization": "o", + "version": "v", + "add_or_update": {}, + } + ) + assert not ser.is_valid() + assert "add_or_update" in ser.errors + + +def test_add_or_update_serializer_rejects_empty_submodule_list() -> None: + ser = AddOrUpdateRequestSerializer( + data={ + "organization": "o", + "version": "v", + "add_or_update": {"zh_Hans": []}, + } + ) + assert not ser.is_valid() + assert "zh_Hans" in ser.errors["add_or_update"] + + +def test_add_or_update_serializer_rejects_non_list_submodules() -> None: + ser = AddOrUpdateRequestSerializer( + data={ + "organization": "o", + "version": "v", + "add_or_update": {"ja": "json"}, + } + ) + assert not ser.is_valid() + assert "ja" in ser.errors["add_or_update"] + + +def test_add_or_update_serializer_missing_required_fields() -> None: + ser = AddOrUpdateRequestSerializer(data={}) + assert not ser.is_valid() + for key in ("organization", "version", "add_or_update"): + assert key in ser.errors diff --git a/tests/endpoint/test_services.py b/tests/endpoint/test_services.py new file mode 100644 index 0000000..8380f2a --- /dev/null +++ b/tests/endpoint/test_services.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +from __future__ import annotations + +import pytest + +from boost_weblate.endpoint.services import BoostComponentService + + +def test_boost_component_service_process_all_not_implemented() -> None: + svc = BoostComponentService( + organization="o", + lang_code="en", + version="v", + extensions=None, + ) + with pytest.raises(NotImplementedError) as excinfo: + svc.process_all(["json"], user=None) + assert "not implemented" in str(excinfo.value).lower() diff --git a/tests/endpoint/test_views.py b/tests/endpoint/test_views.py new file mode 100644 index 0000000..a3380ed --- /dev/null +++ b/tests/endpoint/test_views.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 Andrew Zhang +# +# SPDX-License-Identifier: BSL-1.0 + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory +from rest_framework import status +from rest_framework.test import APIRequestFactory, force_authenticate + +from boost_weblate.endpoint.views import ( + AddOrUpdateView, + BoostEndpointInfo, + plugin_ping, +) + +User = get_user_model() + + +@pytest.fixture +def weblate_anonymous_user_no_db(monkeypatch: pytest.MonkeyPatch) -> None: + """Weblate's default anonymous user loads from DB; tests do not run migrations.""" + monkeypatch.setattr( + "weblate.auth.models.get_anonymous", + lambda: AnonymousUser(), + ) + + +def test_plugin_ping_returns_plain_ok() -> None: + request = RequestFactory().get("/plugin-ping/") + response = plugin_ping(request) + assert response.status_code == 200 + assert response.content == b"ok" + assert response["Content-Type"].startswith("text/plain") + + +def test_boost_endpoint_info_requires_authentication( + weblate_anonymous_user_no_db: None, +) -> None: + factory = APIRequestFactory() + request = factory.get("/info/") + response = BoostEndpointInfo.as_view()(request) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_boost_endpoint_info_returns_payload_when_authenticated() -> None: + factory = APIRequestFactory() + request = factory.get("/info/") + user = User(username="t_user") + force_authenticate(request, user=user) + response = BoostEndpointInfo.as_view()(request) + assert response.status_code == status.HTTP_200_OK + assert response.data["module"] == "cppa-weblate-plugin" + assert "Boost documentation translation API" in response.data["description"] + + +def test_add_or_update_requires_authentication( + weblate_anonymous_user_no_db: None, +) -> None: + factory = APIRequestFactory() + request = factory.post( + "/add-or-update/", + { + "organization": "o", + "version": "v", + "add_or_update": {"ja": ["json"]}, + }, + format="json", + ) + response = AddOrUpdateView.as_view()(request) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_add_or_update_validation_error() -> None: + factory = APIRequestFactory() + request = factory.post("/add-or-update/", {}, format="json") + user = User(username="t_user2") + force_authenticate(request, user=user) + response = AddOrUpdateView.as_view()(request) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "errors" in response.data + + +def test_add_or_update_returns_not_implemented_until_service_exists() -> None: + factory = APIRequestFactory() + request = factory.post( + "/add-or-update/", + { + "organization": "o", + "version": "v", + "add_or_update": {"ja": ["json"]}, + }, + format="json", + ) + user = User(username="t_user3") + force_authenticate(request, user=user) + response = AddOrUpdateView.as_view()(request) + assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED + assert "detail" in response.data + assert response.data["organization"] == "o" + assert response.data["languages"]["ja"]["status"] == "error" + + +def test_add_or_update_internal_error_is_masked( + monkeypatch: pytest.MonkeyPatch, +) -> None: + factory = APIRequestFactory() + request = factory.post( + "/add-or-update/", + { + "organization": "o", + "version": "v", + "add_or_update": {"ja": ["json"]}, + }, + format="json", + ) + user = User(username="t_user4") + force_authenticate(request, user=user) + + def boom(*_a, **_kw): + raise RuntimeError("unexpected") + + monkeypatch.setattr( + "boost_weblate.endpoint.views.BoostComponentService", + MagicMock(side_effect=boom), + ) + response = AddOrUpdateView.as_view()(request) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + assert response.data["error"] == "Internal server error" + assert response.data["organization"] == "o" + assert response.data["languages"]["ja"]["status"] == "error" diff --git a/tests/test_boost_plugin_urls.py b/tests/test_boost_plugin_urls.py deleted file mode 100644 index 5a3366b..0000000 --- a/tests/test_boost_plugin_urls.py +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Andrew Zhang -# -# SPDX-License-Identifier: BSL-1.0 - -from __future__ import annotations - -import sys -import types - -import pytest -from django.test import RequestFactory - -from boost_weblate.endpoint.views import plugin_ping - - -def test_register_plugin_urls_appends_once(monkeypatch: pytest.MonkeyPatch) -> None: - fake = types.ModuleType("weblate.urls") - fake.real_patterns = [] - fake._cppa_boost_weblate_urls_registered = False - monkeypatch.setitem(sys.modules, "weblate.urls", fake) - - from boost_weblate.endpoint.apps import register_plugin_urls - - register_plugin_urls() - register_plugin_urls() - - assert len(fake.real_patterns) == 1 - - -def test_plugin_ping_returns_200() -> None: - request = RequestFactory().get("/plugin-ping/") - response = plugin_ping(request) - assert response.status_code == 200 - assert response.content == b"ok"