diff --git a/Makefile b/Makefile index c00b5dcd02..f75ac78d5a 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/conf/tests/functional awx/main/tests/functional/api awx/main/tests/docs --release=$(VERSION_TARGET) | 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..396655c660 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): @@ -214,7 +217,7 @@ class APIView(views.APIView): context['deprecated'] = True description = render_to_string(template_list, context) - if context.get('deprecated'): + if context.get('deprecated') and context.get('swagger_method') is None: # render deprecation messages at the very top description = '\n'.join([render_to_string('api/_deprecated.md', context), description]) return description diff --git a/awx/api/swagger.py b/awx/api/swagger.py new file mode 100644 index 0000000000..b67f2d4a26 --- /dev/null +++ b/awx/api/swagger.py @@ -0,0 +1,103 @@ +import json +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)) + + link.__dict__['deprecated'] = getattr(self.view, 'deprecated', False) + + # 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) + # python core-api doesn't support the deprecation yet, so track it + # ourselves and return it in a response header + _deprecated = [] + + # 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 + + if isinstance(action, Object): + for link in action.links.values(): + if link.deprecated: + _deprecated.append(link.url) + 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, + headers={'X-Deprecated-Paths': json.dumps(_deprecated)} + ) diff --git a/awx/api/templates/api/ad_hoc_command_relaunch.md b/awx/api/templates/api/ad_hoc_command_relaunch.md new file mode 100644 index 0000000000..fdddd4b6ba --- /dev/null +++ b/awx/api/templates/api/ad_hoc_command_relaunch.md @@ -0,0 +1,3 @@ +Relaunch an Ad Hoc Command: + +Make a POST request to this resource to launch a job. If any passwords or variables are required then they should be passed in via POST data. In order to determine what values are required in order to launch a job based on this job template you may make a GET request to this endpoint. diff --git a/awx/api/templates/api/api_v1_config_view.md b/awx/api/templates/api/api_v1_config_view.md index d99b97d553..d037ff4408 100644 --- a/awx/api/templates/api/api_v1_config_view.md +++ b/awx/api/templates/api/api_v1_config_view.md @@ -1,4 +1,5 @@ -Site configuration settings and general information. +{% ifmeth GET %} +# Site configuration settings and general information Make a GET request to this resource to retrieve the configuration containing the following fields (some fields may not be visible to all users): @@ -11,6 +12,10 @@ the following fields (some fields may not be visible to all users): * `license_info`: Information about the current license. * `version`: Version of Ansible Tower package installed. * `eula`: The current End-User License Agreement +{% endifmeth %} + +{% ifmeth POST %} +# Install or update an existing license (_New in Ansible Tower 2.0.0_) Make a POST request to this resource as a super user to install or update the existing license. The license data itself can @@ -18,3 +23,11 @@ be POSTed as a normal json data structure. (_New in Ansible Tower 2.1.1_) The POST must include a `eula_accepted` boolean element indicating acceptance of the End-User License Agreement. +{% endifmeth %} + +{% ifmeth DELETE %} +# Delete an existing license + +(_New in Ansible Tower 2.0.0_) Make a DELETE request to this resource as a super +user to delete the existing license +{% endifmeth %} diff --git a/awx/api/templates/api/auth_token_view.md b/awx/api/templates/api/auth_token_view.md index 69078842d4..5df4892370 100644 --- a/awx/api/templates/api/auth_token_view.md +++ b/awx/api/templates/api/auth_token_view.md @@ -1,3 +1,5 @@ +{% ifmeth POST %} +# Generate an Auth Token Make a POST request to this resource with `username` and `password` fields to obtain an authentication token to use for subsequent requests. @@ -32,6 +34,10 @@ agent that originally obtained it. Each request that uses the token for authentication will refresh its expiration timestamp and keep it from expiring. A token only expires when it is not used for the configured timeout interval (default 1800 seconds). +{% endifmeth %} -A DELETE request with the token set will cause the token to be invalidated and -no further requests can be made with it. +{% ifmeth DELETE %} +# Delete an Auth Token +A DELETE request with the token header set will cause the token to be +invalidated and no further requests can be made with it. +{% endifmeth %} 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/dashboard_jobs_graph_view.md b/awx/api/templates/api/dashboard_jobs_graph_view.md index cab9a25a2c..fa3c42497f 100644 --- a/awx/api/templates/api/dashboard_jobs_graph_view.md +++ b/awx/api/templates/api/dashboard_jobs_graph_view.md @@ -1,3 +1,5 @@ +# View Statistics for Job Runs + Make a GET request to this resource to retrieve aggregate statistics about job runs suitable for graphing. ## Parmeters and Filtering 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..41df816c10 100644 --- a/awx/api/templates/api/inventory_root_groups_list.md +++ b/awx/api/templates/api/inventory_root_groups_list.md @@ -1,7 +1,9 @@ -# List Root {{ model_verbose_name_plural|title }} for this {{ parent_model_verbose_name|title }}: +{% ifmeth GET %} +# 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 {{ parent_model_verbose_name }}. {% include "api/_list_common.md" %} +{% endifmeth %} 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/job_cancel.md b/awx/api/templates/api/job_cancel.md index f0acece331..9afb6d5031 100644 --- a/awx/api/templates/api/job_cancel.md +++ b/awx/api/templates/api/job_cancel.md @@ -1,10 +1,15 @@ -# Cancel Job +{% ifmeth GET %} +# Determine if a Job can be cancelled Make a GET request to this resource to determine if the job can be cancelled. The response will include the following field: * `can_cancel`: Indicates whether this job can be canceled (boolean, read-only) +{% endifmeth %} +{% ifmeth POST %} +# Cancel a Job Make a POST request to this resource to cancel a pending or running job. The response status code will be 202 if successful, or 405 if the job cannot be canceled. +{% endifmeth %} diff --git a/awx/api/templates/api/job_relaunch.md b/awx/api/templates/api/job_relaunch.md index 7e9ea316ce..e0946435ff 100644 --- a/awx/api/templates/api/job_relaunch.md +++ b/awx/api/templates/api/job_relaunch.md @@ -1,3 +1,3 @@ -Relaunch a job: +Relaunch a Job: -Make a POST request to this resource to launch a job. If any passwords or variables are required then they should be passed in via POST data. In order to determine what values are required in order to launch a job based on this job template you may make a GET request to this endpoint. \ No newline at end of file +Make a POST request to this resource to launch a job. If any passwords or variables are required then they should be passed in via POST data. In order to determine what values are required in order to launch a job based on this job template you may make a GET request to this endpoint. diff --git a/awx/api/templates/api/job_start.md b/awx/api/templates/api/job_start.md index 4b15ebc76b..43104dd2bc 100644 --- a/awx/api/templates/api/job_start.md +++ b/awx/api/templates/api/job_start.md @@ -1,4 +1,5 @@ -# Start Job +{% ifmeth GET %} +# Determine if a Job can be started Make a GET request to this resource to determine if the job can be started and whether any passwords are required to start the job. The response will include @@ -7,10 +8,14 @@ the following fields: * `can_start`: Flag indicating if this job can be started (boolean, read-only) * `passwords_needed_to_start`: Password names required to start the job (array, read-only) +{% endifmeth %} +{% ifmeth POST %} +# Start a Job Make a POST request to this resource to start the job. If any passwords are required, they must be passed via POST data. If successful, the response status code will be 202. If any required passwords are not provided, a 400 status code will be returned. If the job cannot be started, a 405 status code will be returned. +{% endifmeth %} diff --git a/awx/api/templates/api/list_api_view.md b/awx/api/templates/api/list_api_view.md index c45b46c40f..69f4d8fa59 100644 --- a/awx/api/templates/api/list_api_view.md +++ b/awx/api/templates/api/list_api_view.md @@ -1,3 +1,4 @@ +{% ifmeth GET %} # List {{ model_verbose_name_plural|title }}: Make a GET request to this resource to retrieve the list of @@ -6,3 +7,4 @@ Make a GET request to this resource to retrieve the list of {% include "api/_list_common.md" %} {% include "api/_new_in_awx.md" %} +{% endifmeth %} diff --git a/awx/api/templates/api/list_create_api_view.md b/awx/api/templates/api/list_create_api_view.md index 400eeade18..89a9c19f61 100644 --- a/awx/api/templates/api/list_create_api_view.md +++ b/awx/api/templates/api/list_create_api_view.md @@ -1,6 +1,6 @@ {% include "api/list_api_view.md" %} -# Create {{ model_verbose_name_plural|title }}: +# Create {{ 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 }}: 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..ae2d644d3d 100644 --- a/awx/api/templates/api/retrieve_destroy_api_view.md +++ b/awx/api/templates/api/retrieve_destroy_api_view.md @@ -2,15 +2,19 @@ ### 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 %} -# 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/retrieve_update_api_view.md b/awx/api/templates/api/retrieve_update_api_view.md index 21e4255bf1..096552b932 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: @@ -17,9 +20,14 @@ Make a PUT or PATCH request to this resource to update this {% with write_only=1 %} {% include "api/_result_fields_common.md" with serializer_fields=serializer_update_fields %} {% endwith %} +{% endifmeth %} +{% ifmeth PUT %} For a PUT request, include **all** fields in the request. +{% endifmeth %} +{% ifmeth PATCH %} 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..3f3d1f1d61 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: @@ -17,13 +20,20 @@ Make a PUT or PATCH request to this resource to update this {% with write_only=1 %} {% include "api/_result_fields_common.md" with serializer_fields=serializer_update_fields %} {% endwith %} +{% endifmeth %} +{% ifmeth PUT %} For a PUT request, include **all** fields in the request. +{% endifmeth %} +{% ifmeth PATCH %} 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/setting_logging_test.md b/awx/api/templates/api/setting_logging_test.md new file mode 100644 index 0000000000..149fac28ae --- /dev/null +++ b/awx/api/templates/api/setting_logging_test.md @@ -0,0 +1 @@ +# Test Logging Configuration diff --git a/awx/api/templates/api/sub_list_api_view.md b/awx/api/templates/api/sub_list_api_view.md index 9993819bc3..f367674c66 100644 --- a/awx/api/templates/api/sub_list_api_view.md +++ b/awx/api/templates/api/sub_list_api_view.md @@ -1,9 +1,10 @@ -# 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" %} - {% include "api/_new_in_awx.md" %} +{% endifmeth %} 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..a458404e81 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|title|anora }} 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_me_list.md b/awx/api/templates/api/user_me_list.md index 50bea61bd8..3935d23e09 100644 --- a/awx/api/templates/api/user_me_list.md +++ b/awx/api/templates/api/user_me_list.md @@ -1,3 +1,5 @@ +# Retrieve Information about the current User + Make a GET request to retrieve user information about the current user. One result should be returned containing the following fields: 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..6f888f9472 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -189,9 +189,10 @@ 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 ''' + ''' List supported API versions ''' v1 = reverse('api:api_v1_root_view', kwargs={'version': 'v1'}) v2 = reverse('api:api_v2_root_view', kwargs={'version': 'v2'}) @@ -210,9 +211,10 @@ class ApiVersionRootView(APIView): authentication_classes = [] permission_classes = (AllowAny,) + swagger_topic = 'Versioning' def get(self, request, format=None): - ''' list top level resources ''' + ''' List top level resources ''' data = OrderedDict() data['authtoken'] = reverse('api:auth_token_view', request=request) data['ping'] = reverse('api:api_v1_ping_view', request=request) @@ -275,9 +277,10 @@ 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. + """Return some basic information about this instance Everything returned here should be considered public / insecure, as this requires no auth and is intended for use by the installer process. @@ -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) @@ -312,7 +316,7 @@ class ApiV1ConfigView(APIView): self.permission_denied(request) # Raises PermissionDenied exception. def get(self, request, format=None): - '''Return various sitewide configuration settings.''' + '''Return various sitewide configuration settings''' if request.user.is_superuser or request.user.is_system_auditor: license_data = get_license(show_key=True) @@ -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())) @@ -746,10 +754,12 @@ class ScheduleUnifiedJobsList(SubListAPIView): class AuthView(APIView): + ''' List enabled single-sign-on endpoints ''' authentication_classes = [] permission_classes = (AllowAny,) new_in_240 = True + swagger_topic = 'System Configuration' def get(self, request): from rest_framework.reverse import reverse @@ -793,6 +803,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 +993,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 +2691,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 +4005,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 @@ -4770,6 +4781,7 @@ class NotificationTemplateDetail(RetrieveUpdateDestroyAPIView): class NotificationTemplateTest(GenericAPIView): + '''Test a Notification Template''' view_name = _('Notification Template Test') model = NotificationTemplate 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..f4a04d02bd --- /dev/null +++ b/awx/main/tests/docs/test_swagger_generation.py @@ -0,0 +1,185 @@ +import json +import yaml +import os +import re + +from django.conf import settings +from django.core.serializers.json import DjangoJSONEncoder +from django.utils.functional import Promise +from django.utils.encoding import force_text + +from coreapi.compat import force_bytes +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']) + + +class i18nEncoder(DjangoJSONEncoder): + def default(self, obj): + if isinstance(obj, Promise): + return force_text(obj) + return super(i18nEncoder, self).default(obj) + + +@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) + if response.has_header('X-Deprecated-Paths'): + data['deprecated_paths'] = json.loads(response['X-Deprecated-Paths']) + data.update(response.accepted_renderer.get_customizations() or {}) + + data['host'] = None + data['schemes'] = ['https'] + data['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: + data['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'] + data.setdefault('tags', []).append(tag) + + revised_paths = {} + deprecated_paths = data.pop('deprecated_paths', []) + for path, node in data['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 path in deprecated_paths: + node[method]['deprecated'] = True + 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) + + # remove the required `version` parameter + for param in node[method].get('parameters'): + if param['in'] == 'path' and param['name'] == 'version': + node[method]['parameters'].remove(param) + data['paths'] = revised_paths + self.__class__.JSON = data + + def test_sanity(self, release): + JSON = self.__class__.JSON + JSON['info']['version'] = release + + # Make some basic assertions about the rendered JSON so we can + # be sure it doesn't break across DRF upgrades and view/serializer + # changes. + assert len(JSON['tags']) + assert len(JSON['paths']) + + # The number of API endpoints changes over time, but let's just check + # for a reasonable number here; if this test starts failing, raise/lower the bounds + paths = JSON['paths'] + assert 250 < len(paths) < 300 + assert paths['/api/'].keys() == ['get'] + assert paths['/api/v2/'].keys() == ['get'] + assert sorted( + paths['/api/v2/credentials/'].keys() + ) == ['get', 'post'] + assert sorted( + paths['/api/v2/credentials/{id}/'].keys() + ) == ['delete', 'get', 'patch', 'put'] + assert paths['/api/v2/settings/'].keys() == ['get'] + assert paths['/api/v2/settings/{category_slug}/'].keys() == [ + 'get', 'put', 'patch', 'delete' + ] + + # Test deprecated paths + assert paths['/api/v2/jobs/{id}/extra_credentials/']['get']['deprecated'] is True + + @pytest.mark.parametrize('path', [ + '/api/', + '/api/v2/', + '/api/v2/ping/', + '/api/v2/config/', + ]) + def test_basic_paths(self, path, get, admin): + # hit a couple important endpoints so we always have example data + get(path, user=admin, expect=200) + + def test_autogen_response_examples(self, swagger_autogen): + for pattern, node in TestSwaggerGeneration.JSON['paths'].items(): + pattern = pattern.replace('{id}', '[0-9]+') + pattern = pattern.replace('{category_slug}', '[a-zA-Z0-9\-]+') + for path, result in swagger_autogen.items(): + if re.match('^{}$'.format(pattern), path): + for key, value in result.items(): + method, status_code = key + content_type, resp, request_data = value + if method in node: + status_code = str(status_code) + node[method].setdefault('produces', []).append(content_type) + if request_data and status_code.startswith('2'): + # DRF builds a schema based on the serializer + # fields. This is _pretty good_, but if we + # have _actual_ JSON examples, those are even + # better and we should use them instead + for param in node[method].get('parameters'): + if param['in'] == 'body': + node[method]['parameters'].remove(param) + node[method].setdefault('parameters', []).append({ + 'name': 'data', + 'in': 'body', + 'schema': {'example': request_data}, + }) + + # Build response examples + if resp: + if content_type.startswith('text/html'): + continue + if content_type == 'application/json': + resp = json.loads(resp) + node[method]['responses'].setdefault(status_code, {}).setdefault( + 'examples', {} + )[content_type] = resp + + @classmethod + def teardown_class(cls): + with open('swagger.json', 'w') as f: + f.write(force_bytes( + json.dumps(cls.JSON, cls=i18nEncoder, indent=5) + )) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index d245ff3c7d..a0c61d23f4 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -1,4 +1,3 @@ - # Python import pytest import mock @@ -47,6 +46,13 @@ from awx.main.models.notifications import ( from awx.main.models.workflow import WorkflowJobTemplate from awx.main.models.ad_hoc_commands import AdHocCommand +__SWAGGER_REQUESTS__ = {} + + +@pytest.fixture(scope="session") +def swagger_autogen(requests=__SWAGGER_REQUESTS__): + return requests + @pytest.fixture(autouse=True) def clear_cache(): @@ -547,6 +553,9 @@ def _request(verb): assert response.status_code == expect if hasattr(response, 'render'): response.render() + __SWAGGER_REQUESTS__.setdefault(request.path, {})[ + (request.method.lower(), response.status_code) + ] = (response.get('Content-Type', None), response.content, kwargs.get('data')) return response return rf 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: