From ff2db43c496fb70f4f8542c4dc5d221bf07577ec Mon Sep 17 00:00:00 2001 From: Ildar Iskhakov Date: Wed, 16 Aug 2023 14:13:56 +0800 Subject: [PATCH] Add openapi schema generation for internal api (#2771) # What this PR does ## Which issue(s) this PR fixes ## Checklist - [ ] Unit, integration, and e2e (if applicable) tests updated - [ ] Documentation added (or `pr:no public docs` PR label added if not required) - [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not required) --- docker-compose-developer.yml | 1 + engine/apps/api/serializers/alert_group.py | 47 ++++++++++++++++++---- engine/apps/api/serializers/user.py | 22 ++++++++++ engine/apps/api/views/alert_group.py | 15 ++++++- engine/apps/api/views/features.py | 15 ++++++- engine/engine/included_path.py | 9 +++++ engine/engine/urls.py | 8 ++++ engine/requirements.txt | 1 + engine/settings/base.py | 23 +++++++++++ 9 files changed, 131 insertions(+), 10 deletions(-) create mode 100644 engine/engine/included_path.py diff --git a/docker-compose-developer.yml b/docker-compose-developer.yml index 0fc4df5b..cdb26987 100644 --- a/docker-compose-developer.yml +++ b/docker-compose-developer.yml @@ -336,6 +336,7 @@ services: required: false profiles: - grafana + volumes: redisdata_dev: labels: *oncall-labels diff --git a/engine/apps/api/serializers/alert_group.py b/engine/apps/api/serializers/alert_group.py index 77ccc536..cb851eed 100644 --- a/engine/apps/api/serializers/alert_group.py +++ b/engine/apps/api/serializers/alert_group.py @@ -1,7 +1,9 @@ +import datetime import logging from django.core.cache import cache from django.utils import timezone +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers from apps.alerts.incident_appearance.renderers.classic_markdown_renderer import AlertGroupClassicMarkdownRenderer @@ -13,7 +15,7 @@ from common.api_helpers.mixins import EagerLoadingMixin from .alert import AlertSerializer from .alert_receive_channel import FastAlertReceiveChannelSerializer from .alerts_field_cache_buster_mixin import AlertsFieldCacheBusterMixin -from .user import FastUserSerializer +from .user import FastUserSerializer, UserShortSerializer logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) @@ -62,7 +64,19 @@ class ShortAlertGroupSerializer(AlertGroupFieldsCacheSerializerMixin, serializer class Meta: model = AlertGroup fields = ["pk", "render_for_web", "alert_receive_channel", "inside_organization_number"] + read_only_fields = ["pk", "render_for_web", "alert_receive_channel", "inside_organization_number"] + @extend_schema_field( + inline_serializer( + name="render_for_web", + fields={ + "title": serializers.CharField(), + "message": serializers.CharField(), + "image_url": serializers.CharField(), + "source_link": serializers.CharField(), + }, + ) + ) def get_render_for_web(self, obj): last_alert = obj.alerts.last() if last_alert is None: @@ -138,6 +152,17 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize "is_restricted", ] + @extend_schema_field( + inline_serializer( + name="render_for_web", + fields={ + "title": serializers.CharField(), + "message": serializers.CharField(), + "image_url": serializers.CharField(), + "source_link": serializers.CharField(), + }, + ) + ) def get_render_for_web(self, obj): if not obj.last_alert: return {} @@ -149,6 +174,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize ) def get_render_for_classic_markdown(self, obj): + """Deprecated. TODO: remove""" if not obj.last_alert: return {} return AlertGroupFieldsCacheSerializerMixin.get_or_set_web_template_field( @@ -158,6 +184,7 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize AlertGroupClassicMarkdownRenderer, ) + @extend_schema_field(UserShortSerializer(many=True)) def get_related_users(self, obj): users_ids = set() users = [] @@ -166,21 +193,21 @@ class AlertGroupListSerializer(EagerLoadingMixin, AlertGroupFieldsCacheSerialize # when def acknowledge/resolve are called in view. if obj.resolved_by_user: users_ids.add(obj.resolved_by_user.public_primary_key) - users.append(obj.resolved_by_user.short()) + users.append(obj.resolved_by_user) if obj.acknowledged_by_user and obj.acknowledged_by_user.public_primary_key not in users_ids: users_ids.add(obj.acknowledged_by_user.public_primary_key) - users.append(obj.acknowledged_by_user.short()) + users.append(obj.acknowledged_by_user) if obj.silenced_by_user and obj.silenced_by_user.public_primary_key not in users_ids: users_ids.add(obj.silenced_by_user.public_primary_key) - users.append(obj.silenced_by_user.short()) + users.append(obj.silenced_by_user) for log_record in obj.log_records.all(): if log_record.author is not None and log_record.author.public_primary_key not in users_ids: - users.append(log_record.author.short()) + users.append(log_record.author) users_ids.add(log_record.author.public_primary_key) - return users + return UserShortSerializer(users, many=True).data class AlertGroupSerializer(AlertGroupListSerializer): @@ -198,7 +225,7 @@ class AlertGroupSerializer(AlertGroupListSerializer): "paged_users", ] - def get_last_alert_at(self, obj): + def get_last_alert_at(self, obj) -> datetime.datetime: last_alert = obj.alerts.last() if not last_alert: @@ -206,6 +233,7 @@ class AlertGroupSerializer(AlertGroupListSerializer): return last_alert.created_at + @extend_schema_field(AlertSerializer(many=True)) def get_limited_alerts(self, obj): """ Overriding default alerts because there are alert_groups with thousands of them. @@ -214,5 +242,8 @@ class AlertGroupSerializer(AlertGroupListSerializer): alerts = obj.alerts.order_by("-pk")[:100] return AlertSerializer(alerts, many=True).data + @extend_schema_field(UserShortSerializer(many=True)) def get_paged_users(self, obj): - return [u.short() for u in obj.get_paged_users()] + paged_users = obj.get_paged_users() + serializer = UserShortSerializer(paged_users, many=True) + return serializer.data diff --git a/engine/apps/api/serializers/user.py b/engine/apps/api/serializers/user.py index 4db39731..84cf8413 100644 --- a/engine/apps/api/serializers/user.py +++ b/engine/apps/api/serializers/user.py @@ -239,3 +239,25 @@ class FilterUserSerializer(EagerLoadingMixin, serializers.ModelSerializer): "pk", "username", ] + + +class UserShortSerializer(serializers.ModelSerializer): + username = serializers.CharField() + pk = serializers.CharField(source="public_primary_key") + avatar = serializers.CharField(source="avatar_url") + avatar_full = serializers.CharField(source="avatar_full_url") + + class Meta: + model = User + fields = [ + "username", + "pk", + "avatar", + "avatar_full", + ] + read_only_fields = [ + "username", + "pk", + "avatar", + "avatar_full", + ] diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index 226bf4d1..c507d55c 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -5,7 +5,8 @@ from django.db.models import Count, Max, Q from django.utils import timezone from django_filters import rest_framework as filters from django_filters.widgets import RangeWidget -from rest_framework import mixins, status, viewsets +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import mixins, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter @@ -395,8 +396,10 @@ class AlertGroupView( return alert_groups + @extend_schema(responses=inline_serializer(name="AlertGroupStats", fields={"count": serializers.IntegerField()})) @action(detail=False) def stats(self, *args, **kwargs): + """Return number of alert groups capped at 100001""" MAX_COUNT = 100001 alert_groups = self.filter_queryset(self.get_queryset())[:MAX_COUNT] count = alert_groups.count() @@ -492,6 +495,9 @@ class AlertGroupView( @action(methods=["post"], detail=True) def attach(self, request, pk=None): + """ + Attach alert group to another alert group + """ alert_group = self.get_object() if alert_group.is_maintenance_incident: raise BadRequest(detail="Can't attach maintenance alert group") @@ -537,6 +543,13 @@ class AlertGroupView( alert_group.silence_by_user(request.user, silence_delay=delay, action_source=ActionSource.WEB) return Response(AlertGroupSerializer(alert_group, context={"request": request}).data) + @extend_schema( + responses=inline_serializer( + name="silence_options", + fields={"value": serializers.CharField(), "display_name": serializers.CharField()}, + many=True, + ) + ) @action(methods=["get"], detail=False) def silence_options(self, request): data = [ diff --git a/engine/apps/api/views/features.py b/engine/apps/api/views/features.py index a3e92018..d0c4334c 100644 --- a/engine/apps/api/views/features.py +++ b/engine/apps/api/views/features.py @@ -1,4 +1,6 @@ from django.conf import settings +from drf_spectacular.utils import OpenApiExample, extend_schema +from rest_framework import serializers from rest_framework.response import Response from rest_framework.views import APIView @@ -21,8 +23,19 @@ class FeaturesAPIView(APIView): authentication_classes = (PluginAuthentication,) + @extend_schema( + request=None, + responses=serializers.ListField(child=serializers.CharField()), + examples=[ + OpenApiExample( + name="Example response", + value=["slack", "telegram", "grafana_cloud_connection", "live_settings", "grafana_cloud_notifications"], + ) + ], + ) def get(self, request): - return Response(self._get_enabled_features(request)) + data = self._get_enabled_features(request) + return Response(data) def _get_enabled_features(self, request): enabled_features = [] diff --git a/engine/engine/included_path.py b/engine/engine/included_path.py new file mode 100644 index 00000000..0c8db7d1 --- /dev/null +++ b/engine/engine/included_path.py @@ -0,0 +1,9 @@ +from django.conf import settings + + +def custom_preprocessing_hook(endpoints): + filtered = [] + for path, path_regex, method, callback in endpoints: + if any(path_prefix in path for path_prefix in settings.SPECTACULAR_INCLUDED_PATHS): + filtered.append((path, path_regex, method, callback)) + return filtered diff --git a/engine/engine/urls.py b/engine/engine/urls.py index 9d1c3cf3..06faf004 100644 --- a/engine/engine/urls.py +++ b/engine/engine/urls.py @@ -73,3 +73,11 @@ if settings.DEBUG: if settings.SILK_PROFILER_ENABLED: urlpatterns += [path(settings.SILK_PATH, include("silk.urls", namespace="silk"))] + +if settings.DRF_SPECTACULAR_ENABLED: + from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + + urlpatterns += [ + path("internal/schema/", SpectacularAPIView.as_view(api_version="internal/v1"), name="schema"), + path("internal/schema/swagger-ui/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + ] diff --git a/engine/requirements.txt b/engine/requirements.txt index c816c60f..6ac758a8 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -57,3 +57,4 @@ urllib3==1.26.15 prometheus_client==0.16.0 lxml==4.9.2 babel==2.12.1 +drf-spectacular==0.26.2 diff --git a/engine/settings/base.py b/engine/settings/base.py index a10a34e9..acb22fd2 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -237,6 +237,7 @@ INSTALLED_APPS = [ "fcm_django", "django_dbconn_retry", "apps.phone_notifications", + "drf_spectacular", ] REST_FRAMEWORK = { @@ -246,8 +247,30 @@ REST_FRAMEWORK = { "rest_framework.parsers.MultiPartParser", ), "DEFAULT_AUTHENTICATION_CLASSES": [], + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } + +DRF_SPECTACULAR_ENABLED = getenv_boolean("DRF_SPECTACULAR_ENABLED", default=False) + +SPECTACULAR_SETTINGS = { + "TITLE": "Grafana OnCall Private API", + "DESCRIPTION": "Internal API docs. This is not meant to be used by end users. API endpoints will be kept added/removed/changed without notice.", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + # OTHER SETTINGS + "PREPROCESSING_HOOKS": [ + "engine.included_path.custom_preprocessing_hook" + ], # Custom hook to include only paths from SPECTACULAR_INCLUDED_PATHS + "SERVE_URLCONF": ("apps.api.urls"), + "SWAGGER_UI_SETTINGS": {"supportedSubmitMethods": []}, # Disable "Try it out" button for all endpoints +} + +SPECTACULAR_INCLUDED_PATHS = [ + "/features", + "/alertgroups", +] + MIDDLEWARE = [ "log_request_id.middleware.RequestIDMiddleware", "engine.middlewares.RequestTimeLoggingMiddleware",