diff --git a/Makefile b/Makefile index c00b5dcd02..37a5e226a5 100644 --- a/Makefile +++ b/Makefile @@ -363,6 +363,12 @@ pyflakes: reports pylint: reports @(set -o pipefail && $@ | reports/$@.report) +swagger: reports + @if [ "$(VENV_BASE)" ]; then \ + . $(VENV_BASE)/awx/bin/activate; \ + fi; \ + (set -o pipefail && py.test awx/main/tests/docs --release=$(RELEASE_VERSION) | tee reports/$@.report) + check: flake8 pep8 # pyflakes pylint TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests diff --git a/awx/api/generics.py b/awx/api/generics.py index 73c8ddd1db..19a1fac19e 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -28,6 +28,7 @@ from rest_framework import status from rest_framework import views # AWX +from awx.api.swagger import AutoSchema from awx.api.filters import FieldLookupBackend from awx.main.models import * # noqa from awx.main.access import access_registry @@ -93,6 +94,7 @@ def get_view_description(cls, request, html=False): class APIView(views.APIView): + schema = AutoSchema() versioning_class = URLPathVersioning def initialize_request(self, request, *args, **kwargs): @@ -176,7 +178,7 @@ class APIView(views.APIView): and in the browsable API. """ func = self.settings.VIEW_DESCRIPTION_FUNCTION - return func(self.__class__, self._request, html) + return func(self.__class__, getattr(self, '_request', None), html) def get_description_context(self): return { @@ -197,6 +199,7 @@ class APIView(views.APIView): 'new_in_330': getattr(self, 'new_in_330', False), 'new_in_api_v2': getattr(self, 'new_in_api_v2', False), 'deprecated': getattr(self, 'deprecated', False), + 'swagger_method': getattr(self.request, 'swagger_method', None), } def get_description(self, request, html=False): diff --git a/awx/api/swagger.py b/awx/api/swagger.py new file mode 100644 index 0000000000..9e504d4297 --- /dev/null +++ b/awx/api/swagger.py @@ -0,0 +1,89 @@ +import warnings + +from coreapi.document import Object, Link + +from rest_framework import exceptions +from rest_framework.permissions import AllowAny +from rest_framework.renderers import CoreJSONRenderer +from rest_framework.response import Response +from rest_framework.schemas import SchemaGenerator, AutoSchema as DRFAuthSchema +from rest_framework.views import APIView + +from rest_framework_swagger import renderers + + +class AutoSchema(DRFAuthSchema): + + def get_link(self, path, method, base_url): + link = super(AutoSchema, self).get_link(path, method, base_url) + try: + serializer = self.view.get_serializer() + except Exception: + serializer = None + warnings.warn('{}.get_serializer() raised an exception during ' + 'schema generation. Serializer fields will not be ' + 'generated for {} {}.' + .format(self.view.__class__.__name__, method, path)) + + # auto-generate a topic/tag for the serializer based on its model + if hasattr(self.view, 'swagger_topic'): + link.__dict__['topic'] = str(self.view.swagger_topic).title() + elif serializer and hasattr(serializer, 'Meta'): + link.__dict__['topic'] = str( + serializer.Meta.model._meta.verbose_name_plural + ).title() + elif hasattr(self.view, 'model'): + link.__dict__['topic'] = str(self.view.model._meta.verbose_name_plural).title() + else: + warnings.warn('Could not determine a Swagger tag for path {}'.format(path)) + return link + + def get_description(self, path, method): + self.view._request = self.view.request + setattr(self.view.request, 'swagger_method', method) + description = super(AutoSchema, self).get_description(path, method) + return description + + +class SwaggerSchemaView(APIView): + _ignore_model_permissions = True + exclude_from_schema = True + permission_classes = [AllowAny] + renderer_classes = [ + CoreJSONRenderer, + renderers.OpenAPIRenderer, + renderers.SwaggerUIRenderer + ] + + def get(self, request): + generator = SchemaGenerator( + title='Ansible Tower API', + patterns=None, + urlconf=None + ) + schema = generator.get_schema(request=request) + + # By default, DRF OpenAPI serialization places all endpoints in + # a single node based on their root path (/api). Instead, we want to + # group them by topic/tag so that they're categorized in the rendered + # output + document = schema._data.pop('api') + for path, node in document.items(): + if isinstance(node, Object): + for action in node.values(): + topic = getattr(action, 'topic', None) + if topic: + schema._data.setdefault(topic, Object()) + schema._data[topic]._data[path] = node + elif isinstance(node, Link): + topic = getattr(node, 'topic', None) + if topic: + schema._data.setdefault(topic, Object()) + schema._data[topic]._data[path] = node + + if not schema: + raise exceptions.ValidationError( + 'The schema generator did not return a schema Document' + ) + + return Response(schema) diff --git a/awx/api/templates/api/base_variable_data.md b/awx/api/templates/api/base_variable_data.md index 19994530e0..7fcb717c3d 100644 --- a/awx/api/templates/api/base_variable_data.md +++ b/awx/api/templates/api/base_variable_data.md @@ -1,9 +1,13 @@ +{% ifmeth GET %} # Retrieve {{ model_verbose_name|title }} Variable Data: -Make a GET request to this resource to retrieve all variables defined for this +Make a GET request to this resource to retrieve all variables defined for a {{ model_verbose_name }}. +{% endifmeth %} +{% ifmeth PUT PATCH %} # Update {{ model_verbose_name|title }} Variable Data: -Make a PUT request to this resource to update variables defined for this +Make a PUT or PATCH request to this resource to update variables defined for a {{ model_verbose_name }}. +{% endifmeth %} diff --git a/awx/api/templates/api/group_all_hosts_list.md b/awx/api/templates/api/group_all_hosts_list.md index 4c021634c7..1d8e594c7d 100644 --- a/awx/api/templates/api/group_all_hosts_list.md +++ b/awx/api/templates/api/group_all_hosts_list.md @@ -1,4 +1,4 @@ -# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +# List All {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a list of all {{ model_verbose_name_plural }} directly or indirectly belonging to this diff --git a/awx/api/templates/api/group_potential_children_list.md b/awx/api/templates/api/group_potential_children_list.md index a22c10f3d9..207eb361af 100644 --- a/awx/api/templates/api/group_potential_children_list.md +++ b/awx/api/templates/api/group_potential_children_list.md @@ -1,4 +1,4 @@ -# List Potential Child Groups for this {{ parent_model_verbose_name|title }}: +# List Potential Child Groups for {{ parent_model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a list of {{ model_verbose_name_plural }} available to be added as children of the diff --git a/awx/api/templates/api/host_all_groups_list.md b/awx/api/templates/api/host_all_groups_list.md index c4275e0158..b53ddba15b 100644 --- a/awx/api/templates/api/host_all_groups_list.md +++ b/awx/api/templates/api/host_all_groups_list.md @@ -1,4 +1,4 @@ -# List All {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +# List All {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a list of all {{ model_verbose_name_plural }} of which the selected diff --git a/awx/api/templates/api/host_fact_compare_view.md b/awx/api/templates/api/host_fact_compare_view.md index a9b21079e9..aed95a1999 100644 --- a/awx/api/templates/api/host_fact_compare_view.md +++ b/awx/api/templates/api/host_fact_compare_view.md @@ -1,3 +1,5 @@ +# List Fact Scans for a Host Specific Host Scan + Make a GET request to this resource to retrieve system tracking data for a particular scan You may filter by datetime: @@ -8,4 +10,4 @@ and module `?datetime=2015-06-01&module=ansible` -{% include "api/_new_in_awx.md" %} \ No newline at end of file +{% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/host_fact_versions_list.md b/awx/api/templates/api/host_fact_versions_list.md index dd6e7a1afb..33eafb91f4 100644 --- a/awx/api/templates/api/host_fact_versions_list.md +++ b/awx/api/templates/api/host_fact_versions_list.md @@ -1,3 +1,5 @@ +# List Fact Scans for a Host by Module and Date + Make a GET request to this resource to retrieve system tracking scans by module and date/time You may filter scan runs using the `from` and `to` properties: @@ -8,4 +10,4 @@ You may also filter by module `?module=packages` -{% include "api/_new_in_awx.md" %} \ No newline at end of file +{% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/host_insights.md b/awx/api/templates/api/host_insights.md new file mode 100644 index 0000000000..a474be953a --- /dev/null +++ b/awx/api/templates/api/host_insights.md @@ -0,0 +1 @@ +# List Red Hat Insights for a Host diff --git a/awx/api/templates/api/inventory_root_groups_list.md b/awx/api/templates/api/inventory_root_groups_list.md index 17c95e03ba..b051ec94f6 100644 --- a/awx/api/templates/api/inventory_root_groups_list.md +++ b/awx/api/templates/api/inventory_root_groups_list.md @@ -1,4 +1,4 @@ -# List Root {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +# List Root {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a list of root (top-level) {{ model_verbose_name_plural }} associated with this diff --git a/awx/api/templates/api/inventory_tree_view.md b/awx/api/templates/api/inventory_tree_view.md index 9818b56880..269b821076 100644 --- a/awx/api/templates/api/inventory_tree_view.md +++ b/awx/api/templates/api/inventory_tree_view.md @@ -1,4 +1,4 @@ -# Group Tree for this {{ model_verbose_name|title }}: +# Group Tree for {{ model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a hierarchical view of groups associated with the selected {{ model_verbose_name }}. diff --git a/awx/api/templates/api/project_playbooks.md b/awx/api/templates/api/project_playbooks.md index 7b319258d0..2969381466 100644 --- a/awx/api/templates/api/project_playbooks.md +++ b/awx/api/templates/api/project_playbooks.md @@ -1,4 +1,4 @@ # Retrieve {{ model_verbose_name|title }} Playbooks: Make GET request to this resource to retrieve a list of playbooks available -for this {{ model_verbose_name }}. +for {{ model_verbose_name|anora }}. diff --git a/awx/api/templates/api/retrieve_api_view.md b/awx/api/templates/api/retrieve_api_view.md index 64b7fec852..227b5973d8 100644 --- a/awx/api/templates/api/retrieve_api_view.md +++ b/awx/api/templates/api/retrieve_api_view.md @@ -2,7 +2,7 @@ ### Note: starting from api v2, this resource object can be accessed via its named URL. {% endif %} -# Retrieve {{ model_verbose_name|title }}: +# Retrieve {{ model_verbose_name|title|anora }}: Make GET request to this resource to retrieve a single {{ model_verbose_name }} record containing the following fields: diff --git a/awx/api/templates/api/retrieve_destroy_api_view.md b/awx/api/templates/api/retrieve_destroy_api_view.md index 6872c59d4b..3b1da52551 100644 --- a/awx/api/templates/api/retrieve_destroy_api_view.md +++ b/awx/api/templates/api/retrieve_destroy_api_view.md @@ -2,14 +2,14 @@ ### Note: starting from api v2, this resource object can be accessed via its named URL. {% endif %} -# Retrieve {{ model_verbose_name|title }}: +# Retrieve {{ model_verbose_name|title|anora }}: Make GET request to this resource to retrieve a single {{ model_verbose_name }} record containing the following fields: {% include "api/_result_fields_common.md" %} -# Delete {{ model_verbose_name|title }}: +# Delete {{ model_verbose_name|title|anora }}: Make a DELETE request to this resource to delete this {{ model_verbose_name }}. diff --git a/awx/api/templates/api/retrieve_update_api_view.md b/awx/api/templates/api/retrieve_update_api_view.md index 21e4255bf1..4a0b69ef6a 100644 --- a/awx/api/templates/api/retrieve_update_api_view.md +++ b/awx/api/templates/api/retrieve_update_api_view.md @@ -2,14 +2,17 @@ ### Note: starting from api v2, this resource object can be accessed via its named URL. {% endif %} -# Retrieve {{ model_verbose_name|title }}: +{% ifmeth GET %} +# Retrieve {{ model_verbose_name|title|anora }}: Make GET request to this resource to retrieve a single {{ model_verbose_name }} record containing the following fields: {% include "api/_result_fields_common.md" %} +{% endifmeth %} -# Update {{ model_verbose_name|title }}: +{% ifmeth PUT PATCH %} +# Update {{ model_verbose_name|title|anora }}: Make a PUT or PATCH request to this resource to update this {{ model_verbose_name }}. The following fields may be modified: @@ -21,5 +24,6 @@ Make a PUT or PATCH request to this resource to update this For a PUT request, include **all** fields in the request. For a PATCH request, include only the fields that are being modified. +{% endifmeth %} {% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/retrieve_update_destroy_api_view.md b/awx/api/templates/api/retrieve_update_destroy_api_view.md index bfc99bb293..e9c8d1fbca 100644 --- a/awx/api/templates/api/retrieve_update_destroy_api_view.md +++ b/awx/api/templates/api/retrieve_update_destroy_api_view.md @@ -2,14 +2,17 @@ ### Note: starting from api v2, this resource object can be accessed via its named URL. {% endif %} -# Retrieve {{ model_verbose_name|title }}: +{% ifmeth GET %} +# Retrieve {{ model_verbose_name|title|anora }}: Make GET request to this resource to retrieve a single {{ model_verbose_name }} record containing the following fields: {% include "api/_result_fields_common.md" %} +{% endifmeth %} -# Update {{ model_verbose_name|title }}: +{% ifmeth PUT PATCH %} +# Update {{ model_verbose_name|title|anora }}: Make a PUT or PATCH request to this resource to update this {{ model_verbose_name }}. The following fields may be modified: @@ -21,9 +24,12 @@ Make a PUT or PATCH request to this resource to update this For a PUT request, include **all** fields in the request. For a PATCH request, include only the fields that are being modified. +{% endifmeth %} -# Delete {{ model_verbose_name|title }}: +{% ifmeth DELETE %} +# Delete {{ model_verbose_name|title|anora }}: Make a DELETE request to this resource to delete this {{ model_verbose_name }}. +{% endifmeth %} {% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/sub_list_api_view.md b/awx/api/templates/api/sub_list_api_view.md index 9993819bc3..f657c0b802 100644 --- a/awx/api/templates/api/sub_list_api_view.md +++ b/awx/api/templates/api/sub_list_api_view.md @@ -1,9 +1,11 @@ -# List {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +{% ifmeth GET %} +# List {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a GET request to this resource to retrieve a list of {{ model_verbose_name_plural }} associated with the selected {{ parent_model_verbose_name }}. {% include "api/_list_common.md" %} +{% endifmeth %} {% include "api/_new_in_awx.md" %} diff --git a/awx/api/templates/api/sub_list_create_api_view.md b/awx/api/templates/api/sub_list_create_api_view.md index 74b91b5084..b83233beaa 100644 --- a/awx/api/templates/api/sub_list_create_api_view.md +++ b/awx/api/templates/api/sub_list_create_api_view.md @@ -1,6 +1,6 @@ {% include "api/sub_list_api_view.md" %} -# Create {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +# Create {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a POST request to this resource with the following {{ model_verbose_name }} fields to create a new {{ model_verbose_name }} associated with this @@ -25,7 +25,7 @@ delete the associated {{ model_verbose_name }}. } {% else %} -# Add {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +# Add {{ model_verbose_name_plural|title }} for {{ parent_model_verbose_name|title|anora }}: Make a POST request to this resource with only an `id` field to associate an existing {{ model_verbose_name }} with this {{ parent_model_verbose_name }}. diff --git a/awx/api/templates/api/team_roles_list.md b/awx/api/templates/api/team_roles_list.md index bf5bc24917..8aa39a76cb 100644 --- a/awx/api/templates/api/team_roles_list.md +++ b/awx/api/templates/api/team_roles_list.md @@ -1,12 +1,16 @@ -# List Roles for this Team: +# List Roles for a Team: +{% ifmeth GET %} Make a GET request to this resource to retrieve a list of roles associated with the selected team. {% include "api/_list_common.md" %} +{% endifmeth %} +{% ifmeth POST %} # Associate Roles with this Team: Make a POST request to this resource to add or remove a role from this team. The following fields may be modified: * `id`: The Role ID to add to the team. (int, required) * `disassociate`: Provide if you want to remove the role. (any value, optional) +{% endifmeth %} diff --git a/awx/api/templates/api/user_roles_list.md b/awx/api/templates/api/user_roles_list.md index 06c06cf1b3..d8ee253418 100644 --- a/awx/api/templates/api/user_roles_list.md +++ b/awx/api/templates/api/user_roles_list.md @@ -1,12 +1,16 @@ -# List Roles for this User: +# List Roles for a User: +{% ifmeth GET %} Make a GET request to this resource to retrieve a list of roles associated with the selected user. {% include "api/_list_common.md" %} +{% endifmeth %} +{% ifmeth POST %} # Associate Roles with this User: Make a POST request to this resource to add or remove a role from this user. The following fields may be modified: * `id`: The Role ID to add to the user. (int, required) * `disassociate`: Provide if you want to remove the role. (any value, optional) +{% endifmeth %} diff --git a/awx/api/templates/swagger/config.yml b/awx/api/templates/swagger/config.yml new file mode 100644 index 0000000000..d716e7aa48 --- /dev/null +++ b/awx/api/templates/swagger/config.yml @@ -0,0 +1,44 @@ +--- +# Add categories here for generated Swagger docs; order will be respected +# in the generated document. +categories: + - name: Versioning + - name: Authentication + - name: Instances + - name: Instance Groups + - name: System Configuration + - name: Settings + - name: Dashboard + - name: Organizations + - name: Users + - name: Projects + - name: Project Updates + - name: Teams + - name: Credentials + - name: Credential Types + - name: Inventories + - name: Custom Inventory Scripts + - name: Inventory Sources + - name: Inventory Updates + - name: Groups + - name: Hosts + - name: Job Templates + - name: Jobs + - name: Job Events + - name: Job Host Summaries + - name: Ad Hoc Commands + - name: Ad Hoc Command Events + - name: System Job Templates + - name: System Jobs + - name: Schedules + - name: Roles + - name: Notification Templates + - name: Notifications + - name: Labels + - name: Unified Job Templates + - name: Unified Jobs + - name: Activity Streams + - name: Workflow Job Templates + - name: Workflow Jobs + - name: Workflow Job Template Nodes + - name: Workflow Job Nodes diff --git a/awx/api/templates/swagger/description.md b/awx/api/templates/swagger/description.md new file mode 100644 index 0000000000..f1b97c9e2e --- /dev/null +++ b/awx/api/templates/swagger/description.md @@ -0,0 +1 @@ +The Ansible Tower API Reference Manual provides in-depth documentation for Tower's REST API, including examples on how to integrate with it. diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 15af2b4dca..e7d6a4ecad 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -2,7 +2,9 @@ # All Rights Reserved. from __future__ import absolute_import, unicode_literals +from django.conf import settings from django.conf.urls import include, url +from awx.api.swagger import SwaggerSchemaView from awx.api.views import ( ApiRootView, @@ -123,5 +125,9 @@ app_name = 'api' urlpatterns = [ url(r'^$', ApiRootView.as_view(), name='api_root_view'), url(r'^(?P(v2))/', include(v2_urls)), - url(r'^(?P(v1|v2))/', include(v1_urls)) + url(r'^(?P(v1|v2))/', include(v1_urls)), ] +if settings.SETTINGS_MODULE == 'awx.settings.development': + urlpatterns += [ + url(r'^swagger/$', SwaggerSchemaView.as_view(), name='swagger_view'), + ] diff --git a/awx/api/views.py b/awx/api/views.py index ad57324823..32b4fea18d 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -189,6 +189,7 @@ class ApiRootView(APIView): permission_classes = (AllowAny,) view_name = _('REST API') versioning_class = None + swagger_topic = 'Versioning' def get(self, request, format=None): ''' list supported API versions ''' @@ -210,6 +211,7 @@ class ApiVersionRootView(APIView): authentication_classes = [] permission_classes = (AllowAny,) + swagger_topic = 'Versioning' def get(self, request, format=None): ''' list top level resources ''' @@ -275,6 +277,7 @@ class ApiV1PingView(APIView): authentication_classes = () view_name = _('Ping') new_in_210 = True + swagger_topic = 'System Configuration' def get(self, request, format=None): """Return some basic information about this instance. @@ -305,6 +308,7 @@ class ApiV1ConfigView(APIView): permission_classes = (IsAuthenticated,) view_name = _('Configuration') + swagger_topic = 'System Configuration' def check_permissions(self, request): super(ApiV1ConfigView, self).check_permissions(request) @@ -407,6 +411,7 @@ class DashboardView(APIView): view_name = _("Dashboard") new_in_14 = True + swagger_topic = 'Dashboard' def get(self, request, format=None): ''' Show Dashboard Details ''' @@ -506,6 +511,7 @@ class DashboardJobsGraphView(APIView): view_name = _("Dashboard Jobs Graphs") new_in_200 = True + swagger_topic = 'Jobs' def get(self, request, format=None): period = request.query_params.get('period', 'month') @@ -690,6 +696,8 @@ class SchedulePreview(GenericAPIView): class ScheduleZoneInfo(APIView): + swagger_topic = 'System Configuration' + def get(self, request): from dateutil.zoneinfo import get_zonefile_instance return Response(sorted(get_zonefile_instance().zones.keys())) @@ -750,6 +758,7 @@ class AuthView(APIView): authentication_classes = [] permission_classes = (AllowAny,) new_in_240 = True + swagger_topic = 'Authentication' def get(self, request): from rest_framework.reverse import reverse @@ -793,6 +802,7 @@ class AuthTokenView(APIView): permission_classes = (AllowAny,) serializer_class = AuthTokenSerializer model = AuthToken + swagger_topic = 'Authentication' def get_serializer(self, *args, **kwargs): serializer = self.serializer_class(*args, **kwargs) @@ -982,7 +992,7 @@ class OrganizationDetail(RetrieveUpdateDestroyAPIView): def get_serializer_context(self, *args, **kwargs): full_context = super(OrganizationDetail, self).get_serializer_context(*args, **kwargs) - if not hasattr(self, 'kwargs'): + if not hasattr(self, 'kwargs') or 'pk' not in self.kwargs: return full_context org_id = int(self.kwargs['pk']) @@ -2680,7 +2690,7 @@ class InventorySourceList(ListCreateAPIView): @property def allowed_methods(self): methods = super(InventorySourceList, self).allowed_methods - if get_request_version(self.request) == 1: + if get_request_version(getattr(self, 'request', None)) == 1: methods.remove('POST') return methods @@ -3994,7 +4004,7 @@ class JobList(ListCreateAPIView): @property def allowed_methods(self): methods = super(JobList, self).allowed_methods - if get_request_version(self.request) > 1: + if get_request_version(getattr(self, 'request', None)) > 1: methods.remove('POST') return methods diff --git a/awx/main/templatetags/__init__.py b/awx/main/templatetags/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/templatetags/swagger.py b/awx/main/templatetags/swagger.py new file mode 100644 index 0000000000..314d599710 --- /dev/null +++ b/awx/main/templatetags/swagger.py @@ -0,0 +1,50 @@ +import re +from django.utils.encoding import force_unicode +from django import template + +register = template.Library() + +CONSONANT_SOUND = re.compile(r'''one(![ir])''', re.IGNORECASE|re.VERBOSE) # noqa +VOWEL_SOUND = re.compile(r'''[aeio]|u([aeiou]|[^n][^aeiou]|ni[^dmnl]|nil[^l])|h(ier|onest|onou?r|ors\b|our(!i))|[fhlmnrsx]\b''', re.IGNORECASE|re.VERBOSE) # noqa + + +@register.filter +def anora(text): + # https://pypi.python.org/pypi/anora + # < 10 lines of BSD-3 code, not worth a dependency + text = force_unicode(text) + anora = 'an' if not CONSONANT_SOUND.match(text) and VOWEL_SOUND.match(text) else 'a' + return anora + ' ' + text + + +@register.tag(name='ifmeth') +def ifmeth(parser, token): + """ + Used to mark template blocks for Swagger/OpenAPI output. + If the specified method matches the *current* method in Swagger/OpenAPI + generation, show the block. Otherwise, the block is omitted. + + {% ifmeth GET %} + Make a GET request to... + {% endifmeth %} + + {% ifmeth PUT PATCH %} + Make a PUT or PATCH request to... + {% endifmeth %} + """ + allowed_methods = [m.upper() for m in token.split_contents()[1:]] + nodelist = parser.parse(('endifmeth',)) + parser.delete_first_token() + return MethodFilterNode(allowed_methods, nodelist) + + +class MethodFilterNode(template.Node): + def __init__(self, allowed_methods, nodelist): + self.allowed_methods = allowed_methods + self.nodelist = nodelist + + def render(self, context): + swagger_method = context.get('swagger_method') + if not swagger_method or swagger_method.upper() in self.allowed_methods: + return self.nodelist.render(context) + return '' diff --git a/awx/main/tests/docs/__init__.py b/awx/main/tests/docs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/awx/main/tests/docs/conftest.py b/awx/main/tests/docs/conftest.py new file mode 100644 index 0000000000..bd0cf1c99f --- /dev/null +++ b/awx/main/tests/docs/conftest.py @@ -0,0 +1,13 @@ +from awx.main.tests.functional.conftest import * # noqa + + +def pytest_addoption(parser): + parser.addoption("--release", action="store", help="a release version number, e.g., 3.3.0") + + +def pytest_generate_tests(metafunc): + # This is called for every test. Only get/set command line arguments + # if the argument is specified in the list of test "fixturenames". + option_value = metafunc.config.option.release + if 'release' in metafunc.fixturenames and option_value is not None: + metafunc.parametrize("release", [option_value]) diff --git a/awx/main/tests/docs/test_swagger_generation.py b/awx/main/tests/docs/test_swagger_generation.py new file mode 100644 index 0000000000..dd49f2f8cb --- /dev/null +++ b/awx/main/tests/docs/test_swagger_generation.py @@ -0,0 +1,95 @@ +import json +import yaml +import os + +from coreapi.compat import force_bytes +from django.conf import settings +from openapi_codec.encode import generate_swagger_object +import pytest + +import awx +from awx.api.versioning import drf_reverse + + +config_dest = os.sep.join([ + os.path.realpath(os.path.dirname(awx.__file__)), + 'api', 'templates', 'swagger' +]) +config_file = os.sep.join([config_dest, 'config.yml']) +description_file = os.sep.join([config_dest, 'description.md']) + + +@pytest.mark.django_db +class TestSwaggerGeneration(): + """ + This class is used to generate a Swagger/OpenAPI document for the awx + API. A _prepare fixture generates a JSON blob containing OpenAPI data, + individual tests have the ability modify the payload. + + Finally, the JSON content is written to a file, `swagger.json`, in the + current working directory. + + $ py.test test_swagger_generation.py --version 3.3.0 + + To customize the `info.description` in the generated OpenAPI document, + modify the text in `awx.api.templates.swagger.description.md` + """ + JSON = {} + + @pytest.fixture(autouse=True, scope='function') + def _prepare(self, get, admin): + if not self.__class__.JSON: + url = drf_reverse('api:swagger_view') + '?format=openapi' + response = get(url, user=admin) + data = generate_swagger_object(response.data) + data.update(response.accepted_renderer.get_customizations() or {}) + self.__class__.JSON = data + + def _lookup_display_name(self, method, path): + return path + + def test_transform_metadata(self, release): + """ + This test takes the JSON output from the swagger endpoint and applies + various transformations to it. + """ + self.__class__.JSON['info']['version'] = release + self.__class__.JSON['host'] = None + self.__class__.JSON['schemes'] = ['https'] + self.__class__.JSON['produces'] = ['application/json'] + self.__class__.JSON['consumes'] = ['application/json'] + + # Inject a top-level description into the OpenAPI document + if os.path.exists(description_file): + with open(description_file, 'r') as f: + self.__class__.JSON['info']['description'] = f.read() + + # Write tags in the order we want them sorted + if os.path.exists(config_file): + with open(config_file, 'r') as f: + config = yaml.load(f.read()) + for category in config.get('categories', []): + tag = {'name': category['name']} + if 'description' in category: + tag['description'] = category['description'] + self.__class__.JSON.setdefault('tags', []).append(tag) + + revised_paths = {} + for path, node in self.__class__.JSON['paths'].items(): + # change {version} in paths to the actual default API version (e.g., v2) + revised_paths[path.replace( + '{version}', + settings.REST_FRAMEWORK['DEFAULT_VERSION'] + )] = node + for method in node: + if 'description' in node[method]: + # Pop off the first line and use that as the summary + lines = node[method]['description'].splitlines() + node[method]['summary'] = lines.pop(0).strip('#:') + node[method]['description'] = '\n'.join(lines) + self.__class__.JSON['paths'] = revised_paths + + @classmethod + def teardown_class(cls): + with open('swagger.json', 'w') as f: + f.write(force_bytes(json.dumps(cls.JSON))) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index d245ff3c7d..f0aae75fd1 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,4 +1,3 @@ - # Python import pytest import mock diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index a1223c5ed1..a0618c238d 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -218,6 +218,7 @@ TEMPLATES = [ ('django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader',), )], + 'builtins': ['awx.main.templatetags.swagger'], }, 'DIRS': [ os.path.join(BASE_DIR, 'templates'), diff --git a/awx/settings/development.py b/awx/settings/development.py index 617c0b6745..36fc290d6d 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -101,6 +101,8 @@ if 'django_jenkins' in INSTALLED_APPS: PEP8_RCFILE = "setup.cfg" PYLINT_RCFILE = ".pylintrc" +INSTALLED_APPS += ('rest_framework_swagger',) + # Much faster than the default # https://docs.djangoproject.com/en/1.6/topics/auth/passwords/#how-django-stores-passwords PASSWORD_HASHERS = ( diff --git a/requirements/requirements_dev.txt b/requirements/requirements_dev.txt index 131b8ebef5..367bf85567 100644 --- a/requirements/requirements_dev.txt +++ b/requirements/requirements_dev.txt @@ -1,4 +1,5 @@ django-debug-toolbar==1.5 +django-rest-swagger pprofile ipython==5.2.1 unittest2 diff --git a/shippable.yml b/shippable.yml index 8bbadc8bad..871a497d91 100644 --- a/shippable.yml +++ b/shippable.yml @@ -7,6 +7,7 @@ env: - AWX_BUILD_TARGET=test - AWX_BUILD_TARGET=ui-test-ci - AWX_BUILD_TARGET="flake8 jshint" + - AWX_BUILD_TARGET="swagger" branches: only: