From 3ab0252f3aeec86b634e7c4c9ff4e109ad029431 Mon Sep 17 00:00:00 2001 From: Kevin Meinhardt Date: Mon, 11 Dec 2023 18:26:58 +0100 Subject: [PATCH] chore(): add swagger UI to document our apis (#21521) * chore(): generate api versions from method * chore(): add swagger UI to document our apis --- docker/nginx/addons.conf | 4 ++ requirements/prod.txt | 9 ++++ settings.py | 5 ++ src/olympia/abuse/models.py | 69 ++++++++++++++++++++---- src/olympia/abuse/serializers.py | 11 +++- src/olympia/addons/views.py | 14 +++++ src/olympia/api/middleware.py | 2 +- src/olympia/api/tests/test_middleware.py | 15 ++++++ src/olympia/api/urls.py | 51 ++++++++++++++++-- src/olympia/lib/settings_base.py | 6 +++ 10 files changed, 171 insertions(+), 15 deletions(-) diff --git a/docker/nginx/addons.conf b/docker/nginx/addons.conf index 4e36bcbeac..b11dc51c17 100644 --- a/docker/nginx/addons.conf +++ b/docker/nginx/addons.conf @@ -46,6 +46,10 @@ server { alias /srv/site-static/admin/; } + location /static/drf-yasg/ { + alias /srv/site-static/drf-yasg/; + } + location ~ ^/api/ { try_files $uri @olympia; } diff --git a/requirements/prod.txt b/requirements/prod.txt index e8ca8e985e..5977c2e323 100644 --- a/requirements/prod.txt +++ b/requirements/prod.txt @@ -861,3 +861,12 @@ grpcio==1.59.3 \ --hash=sha256:f2eb8f0c7c0c62f7a547ad7a91ba627a5aa32a5ae8d930783f7ee61680d7eb8d \ --hash=sha256:fb111aa99d3180c361a35b5ae1e2c63750220c584a1344229abc139d5c891881 \ --hash=sha256:fcfa56f8d031ffda902c258c84c4b88707f3a4be4827b4e3ab8ec7c24676320d +drf-yasg==1.21.7 \ + --hash=sha256:4c3b93068b3dfca6969ab111155e4dd6f7b2d680b98778de8fd460b7837bdb0d \ + --hash=sha256:f85642072c35e684356475781b7ecf5d218fff2c6185c040664dd49f0a4be181 +uritemplate==4.1.1 \ + --hash=sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0 \ + --hash=sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e +inflection==0.5.1 \ + --hash=sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417 \ + --hash=sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2 diff --git a/settings.py b/settings.py index e5120b4678..bab7f45350 100644 --- a/settings.py +++ b/settings.py @@ -150,3 +150,8 @@ SITEMAP_DEBUG_AVAILABLE = True # Will show the widget but no captcha, verification will always pass. RECAPTCHA_PUBLIC_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' RECAPTCHA_PRIVATE_KEY = '6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe' + +SWAGGER_SETTINGS = { + 'USE_SESSION_AUTH': False, + 'DEEP_LINKING': True, +} diff --git a/src/olympia/abuse/models.py b/src/olympia/abuse/models.py index ad0858ac54..a10c4aac63 100644 --- a/src/olympia/abuse/models.py +++ b/src/olympia/abuse/models.py @@ -360,10 +360,21 @@ class AbuseReport(ModelBase): blank=True, related_name='abuse_reported', on_delete=models.SET_NULL, + help_text='The user who submitted the report, if authenticated.', ) # name and/or email can be provided instead for unauthenticated reporters - reporter_email = models.CharField(max_length=255, default=None, null=True) - reporter_name = models.CharField(max_length=255, default=None, null=True) + reporter_email = models.CharField( + max_length=255, + default=None, + null=True, + help_text='The provided email of the reporter, if not authenticated.', + ) + reporter_name = models.CharField( + max_length=255, + default=None, + null=True, + help_text='The provided name of the reporter, if not authenticated.', + ) country_code = models.CharField(max_length=2, default=None, null=True) # An abuse report can be for an addon, a user or a rating. # - If user is set then guid and rating should be null. @@ -379,7 +390,9 @@ class AbuseReport(ModelBase): collection = models.ForeignKey( Collection, null=True, related_name='abuse_reports', on_delete=models.SET_NULL ) - message = models.TextField(blank=True) + message = models.TextField( + blank=True, help_text='The body/content of the abuse report.' + ) state = models.PositiveSmallIntegerField( default=STATES.UNTRIAGED, choices=STATES.choices @@ -388,10 +401,26 @@ class AbuseReport(ModelBase): # Extra optional fields for more information, giving some context that is # meant to be extracted automatically by the client (i.e. Firefox) and # submitted via the API. - client_id = models.CharField(default=None, max_length=64, blank=True, null=True) - addon_name = models.CharField(default=None, max_length=255, blank=True, null=True) + client_id = models.CharField( + default=None, + max_length=64, + blank=True, + null=True, + help_text="The client's hashed telemetry ID.", + ) + addon_name = models.CharField( + default=None, + max_length=255, + blank=True, + null=True, + help_text='The add-on name in the locale used by the client.', + ) addon_summary = models.CharField( - default=None, max_length=255, blank=True, null=True + default=None, + max_length=255, + blank=True, + null=True, + help_text='The add-on summary in the locale used by the client.', ) addon_version = models.CharField( default=None, max_length=255, blank=True, null=True @@ -429,10 +458,26 @@ class AbuseReport(ModelBase): null=True, ) addon_install_method = models.PositiveSmallIntegerField( - default=None, choices=ADDON_INSTALL_METHODS.choices, blank=True, null=True + default=None, + choices=ADDON_INSTALL_METHODS.choices, + blank=True, + null=True, + help_text=( + 'For addon_install_method and addon_install_source specifically,' + 'if an unsupported value is sent, it will be silently changed to other' + 'instead of raising a 400 error.' + ), ) addon_install_source = models.PositiveSmallIntegerField( - default=None, choices=ADDON_INSTALL_SOURCES.choices, blank=True, null=True + default=None, + choices=ADDON_INSTALL_SOURCES.choices, + blank=True, + null=True, + help_text=( + 'For addon_install_method and addon_install_source specifically,' + 'if an unsupported value is sent, it will be silently changed to other' + 'instead of raising a 400 error.' + ), ) addon_install_source_url = models.CharField( # See addon_install_origin above as for why it's not an URLField. @@ -445,7 +490,13 @@ class AbuseReport(ModelBase): default=None, choices=REPORT_ENTRY_POINTS.choices, blank=True, null=True ) location = models.PositiveSmallIntegerField( - default=None, choices=LOCATION.choices, blank=True, null=True + default=None, + choices=LOCATION.choices, + blank=True, + null=True, + help_text=( + 'Where the content being reported is located - on AMO or inside the add-on.' + ), ) cinder_job = models.ForeignKey(CinderJob, null=True, on_delete=models.SET_NULL) diff --git a/src/olympia/abuse/serializers.py b/src/olympia/abuse/serializers.py index 37a400f358..7f88e4f36f 100644 --- a/src/olympia/abuse/serializers.py +++ b/src/olympia/abuse/serializers.py @@ -44,7 +44,12 @@ class BaseAbuseReportSerializer(AMOModelSerializer): error_messages=error_messages, ) lang = serializers.CharField( - required=False, source='application_locale', max_length=255 + required=False, + source='application_locale', + max_length=255, + help_text=( + 'The language code of the locale used by the client for the application.' + ), ) class Meta: @@ -98,7 +103,9 @@ class BaseAbuseReportSerializer(AMOModelSerializer): class AddonAbuseReportSerializer(BaseAbuseReportSerializer): - addon = serializers.SerializerMethodField() + addon = serializers.SerializerMethodField( + help_text='The add-on reported for abuse.' + ) app = ReverseChoiceField( choices=list((v.id, k) for k, v in amo.APPS.items()), required=False, diff --git a/src/olympia/addons/views.py b/src/olympia/addons/views.py index 9d5902f566..458529a39b 100644 --- a/src/olympia/addons/views.py +++ b/src/olympia/addons/views.py @@ -7,6 +7,7 @@ from django.shortcuts import redirect from django.utils.cache import patch_cache_control from django.utils.translation import gettext +from drf_yasg.utils import swagger_auto_schema from elasticsearch_dsl import Q, Search, query from rest_framework import exceptions, serializers, status from rest_framework.decorators import action @@ -392,6 +393,19 @@ class AddonViewSet( self.action = 'create' return self.create(request, *args, **kwargs) + @swagger_auto_schema( + operation_description=""" + This endpoint allows a submission of an upload to create a new add-on + and setting other AMO metadata. + + To create an add-on with a listed version from an upload + (an upload that has channel == listed) certain metadata must be defined + - version license + - add-on name + - add-on summary + - add-on categories for each app the version is compatible with. + """ + ) def create(self, request, *args, **kwargs): response = super().create(request, *args, **kwargs) webext_version_stats(request, 'addons.submission') diff --git a/src/olympia/api/middleware.py b/src/olympia/api/middleware.py index 823e8d969d..105eb57838 100644 --- a/src/olympia/api/middleware.py +++ b/src/olympia/api/middleware.py @@ -7,7 +7,7 @@ from django.utils.deprecation import MiddlewareMixin class APIRequestMiddleware(MiddlewareMixin): def identify_request(self, request): - request.is_api = re.match(settings.DRF_API_REGEX, request.path_info) + request.is_api = re.match(settings.DRF_API_NOT_SWAGGER_REGEX, request.path_info) def process_request(self, request): self.identify_request(request) diff --git a/src/olympia/api/tests/test_middleware.py b/src/olympia/api/tests/test_middleware.py index 5ef24c4e66..bba87af1fb 100644 --- a/src/olympia/api/tests/test_middleware.py +++ b/src/olympia/api/tests/test_middleware.py @@ -36,6 +36,21 @@ class TestAPIRequestMiddleware(TestCase): APIRequestMiddleware(lambda: None).process_response(request, response) assert response['Vary'] == 'Foo, Bar' + def test_disabled_on_swagger(self): + """Test that we don't tag the request as API on swagger pages.""" + + urls = [ + '/api/v3/swagger', + '/api/v3/swagger.json', + '/api/v4/swagger/', + '/api/v4/swagger.yaml', + ] + + for url in urls: + request = self.request_factory.get(url) + APIRequestMiddleware(lambda: None).process_request(request) + assert not request.is_api + def test_disabled_for_the_rest(self): """Test that we don't tag the request as API on "regular" pages.""" request = self.request_factory.get('/overtherainbow') diff --git a/src/olympia/api/urls.py b/src/olympia/api/urls.py index 92f38e175d..9bde639a05 100644 --- a/src/olympia/api/urls.py +++ b/src/olympia/api/urls.py @@ -1,11 +1,56 @@ +from django.conf import settings from django.urls import include, re_path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + from olympia.accounts.urls import accounts_v3, accounts_v4, auth_urls from olympia.addons.api_urls import addons_v3, addons_v4, addons_v5 from olympia.amo.urls import api_patterns as amo_api_patterns from olympia.ratings.api_urls import ratings_v3, ratings_v4 +def get_versioned_api_routes(version, url_patterns): + route_pattern = r'^{}/'.format(version) + + schema_view = get_schema_view( + openapi.Info( + title='AMO API', + default_version=version, + description='The official API for addons.mozilla.org.', + ), + public=True, + permission_classes=(permissions.AllowAny,), + ) + + routes = url_patterns + + # For now, this feature is only enabled in dev mode + if settings.DEBUG: + routes.extend( + [ + re_path( + r'^swagger(?P\.json|\.yaml)$', + schema_view.without_ui(cache_timeout=0), + name='schema-json', + ), + re_path( + r'^swagger/$', + schema_view.with_ui('swagger', cache_timeout=0), + name='schema-swagger-ui', + ), + re_path( + r'^redoc/$', + schema_view.with_ui('redoc', cache_timeout=0), + name='schema-redoc', + ), + ] + ) + + return (re_path(route_pattern, include((routes, version))),) + + v3_api_urls = [ re_path(r'^abuse/', include('olympia.abuse.api_urls')), re_path(r'^accounts/', include(accounts_v3)), @@ -51,7 +96,7 @@ v5_api_urls = [ urlpatterns = [ re_path(r'^auth/', include((auth_urls, 'auth'))), - re_path(r'^v3/', include((v3_api_urls, 'v3'))), - re_path(r'^v4/', include((v4_api_urls, 'v4'))), - re_path(r'^v5/', include((v5_api_urls, 'v5'))), + *get_versioned_api_routes('v3', v3_api_urls), + *get_versioned_api_routes('v4', v4_api_urls), + *get_versioned_api_routes('v5', v5_api_urls), ] diff --git a/src/olympia/lib/settings_base.py b/src/olympia/lib/settings_base.py index 1671dceb25..8a067baa05 100644 --- a/src/olympia/lib/settings_base.py +++ b/src/olympia/lib/settings_base.py @@ -104,6 +104,8 @@ DELETION_EMAIL = 'amo-notifications+deletion@mozilla.com' DRF_API_VERSIONS = ['auth', 'v3', 'v4', 'v5'] DRF_API_REGEX = r'^/?api/(?:auth|v3|v4|v5)/' +DRF_API_NOT_SWAGGER_REGEX = rf'{DRF_API_REGEX}(?!swagger|redoc).*$' + # Add Access-Control-Allow-Origin: * header for the new API with # django-cors-headers. CORS_ALLOW_ALL_ORIGINS = True @@ -329,6 +331,9 @@ JINJA_EXCLUDE_TEMPLATE_PATHS = ( r'^registration\/', # Django's sitemap_index.xml template uses some syntax that jinja doesn't support r'sitemap_index.xml', + # Swagger URLs are for the API docs, use some syntax that jinja doesn't support + r'drf-yasg/swagger-ui.html', + r'drf-yasg/redoc.html', ) TEMPLATES = [ @@ -528,6 +533,7 @@ INSTALLED_APPS = ( 'django_jinja', 'rangefilter', 'django_recaptcha', + 'drf_yasg', # Django contrib apps 'django.contrib.admin', 'django.contrib.auth',