diff --git a/api/config/settings/common.py b/api/config/settings/common.py index ad24d43db..0150ee09b 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -286,6 +286,7 @@ MIDDLEWARE = tuple(ADDITIONAL_MIDDLEWARES_BEFORE) + ( "django.contrib.messages.middleware.MessageMiddleware", "funkwhale_api.users.middleware.RecordActivityMiddleware", "funkwhale_api.common.middleware.ThrottleStatusMiddleware", + "funkwhale_api.common.plugins.PluginViewMiddleware", ) # DEBUG @@ -555,6 +556,7 @@ Delay in seconds before uploaded but unattached attachements are pruned from the # ------------------------------------------------------------------------------ ROOT_URLCONF = "config.urls" SPA_URLCONF = "config.spa_urls" +PLUGINS_URLCONF = "funkwhale_api.common.plugins" ASGI_APPLICATION = "config.routing.application" # This ensures that Django will be able to detect a secure connection diff --git a/api/contrib/prometheus/__init__.py b/api/contrib/prometheus/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/contrib/prometheus/main.py b/api/contrib/prometheus/main.py new file mode 100644 index 000000000..d0d211616 --- /dev/null +++ b/api/contrib/prometheus/main.py @@ -0,0 +1,15 @@ +import json + +from django import http + +from .plugin import PLUGIN + + +@PLUGIN.register_api_view(path="prometheus") +def prometheus(request): + stats = get_stats() + return http.HttpResponse(json.dumps(stats)) + + +def get_stats(): + return {"foo": "bar"} diff --git a/api/contrib/prometheus/plugin.py b/api/contrib/prometheus/plugin.py new file mode 100644 index 000000000..171d91941 --- /dev/null +++ b/api/contrib/prometheus/plugin.py @@ -0,0 +1,5 @@ +from funkwhale_api.common import plugins + + +class Plugin(plugins.Plugin): + name = "prometheus" diff --git a/api/funkwhale_api/common/plugins.py b/api/funkwhale_api/common/plugins.py new file mode 100644 index 000000000..c38bbc2bc --- /dev/null +++ b/api/funkwhale_api/common/plugins.py @@ -0,0 +1,45 @@ +from django.apps import AppConfig +from django import urls +from django.conf import settings + + +urlpatterns = [] + + +class PluginViewMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if response.status_code == 404 and request.path.startswith("/plugins/"): + match = urls.resolve(request.path, urlconf=settings.PLUGINS_URLCONF) + response = match.func(request, *match.args, **match.kwargs) + return response + + +class Plugin(AppConfig): + def ready(self): + from . import main # noqa + + return super().ready() + + def register_api_view(self, path, name=None): + def register(view): + urlpatterns.append( + urls.path( + "plugins/{}/{}".format(self.name.replace("_", "-"), path), + view, + name="plugins-{}-{}".format(self.name, name), + ) + ), + + return register + + +def reverse(name, **kwargs): + return urls.reverse(name, settings.PLUGINS_URLCONF, **kwargs) + + +def resolve(name, **kwargs): + return urls.resolve(name, settings.PLUGINS_URLCONF, **kwargs) diff --git a/api/tests/common/test_plugins.py b/api/tests/common/test_plugins.py new file mode 100644 index 000000000..3b6a85244 --- /dev/null +++ b/api/tests/common/test_plugins.py @@ -0,0 +1,57 @@ +import os + +import pytest + +from django.urls import resolvers + +from funkwhale_api.common import plugins + + +class P(plugins.Plugin): + name = "test_plugin" + path = os.path.abspath(__file__) + + +@pytest.fixture +def plugin(settings): + yield P(app_name="test_plugin", app_module="tests.common.test_plugins.main.P") + + +@pytest.fixture(autouse=True) +def clear_patterns(): + plugins.urlpatterns.clear() + resolvers._get_cached_resolver.cache_clear() + yield + resolvers._get_cached_resolver.cache_clear() + + +def test_can_register_view(plugin, mocker, settings): + view = mocker.Mock() + plugin.register_api_view("hello", name="hello")(view) + expected = "/plugins/test-plugin/hello" + assert plugins.reverse("plugins-test_plugin-hello") == expected + assert plugins.resolve(expected).func == view + + +def test_plugin_view_middleware_not_matching(api_client, plugin, mocker, settings): + view = mocker.Mock() + get_response = mocker.Mock() + middleware = plugins.PluginViewMiddleware(get_response) + plugin.register_api_view("hello", name="hello")(view) + request = mocker.Mock(path=plugins.reverse("plugins-test_plugin-hello")) + response = middleware(request) + assert response == get_response.return_value + view.assert_not_called() + + +def test_plugin_view_middleware_matching(api_client, plugin, mocker, settings): + view = mocker.Mock() + get_response = mocker.Mock(return_value=mocker.Mock(status_code=404)) + middleware = plugins.PluginViewMiddleware(get_response) + plugin.register_api_view("hello/", name="hello")(view) + request = mocker.Mock( + path=plugins.reverse("plugins-test_plugin-hello", kwargs={"slug": "world"}) + ) + response = middleware(request) + assert response == view.return_value + view.assert_called_once_with(request, slug="world")