Merge pull request #1124 from ryanpetrello/swagger

add support for building swagger/OpenAPI JSON
This commit is contained in:
Ryan Petrello
2018-02-06 11:12:36 -05:00
committed by GitHub
46 changed files with 561 additions and 43 deletions

View File

@@ -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

View File

@@ -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

103
awx/api/swagger.py Normal file
View File

@@ -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)}
)

View File

@@ -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.

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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" %}
{% include "api/_new_in_awx.md" %}

View File

@@ -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" %}
{% include "api/_new_in_awx.md" %}

View File

@@ -0,0 +1 @@
# List Red Hat Insights for a Host

View File

@@ -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 %}

View File

@@ -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 }}.

View File

@@ -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 %}

View File

@@ -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.
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.

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 }}:

View File

@@ -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 }}.

View File

@@ -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:

View File

@@ -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" %}

View File

@@ -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" %}

View File

@@ -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" %}

View File

@@ -0,0 +1 @@
# Test Logging Configuration

View File

@@ -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 %}

View File

@@ -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 }}.

View File

@@ -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 %}

View File

@@ -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:

View File

@@ -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 %}

View File

@@ -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

View File

@@ -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.

View File

@@ -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<version>(v2))/', include(v2_urls)),
url(r'^(?P<version>(v1|v2))/', include(v1_urls))
url(r'^(?P<version>(v1|v2))/', include(v1_urls)),
]
if settings.SETTINGS_MODULE == 'awx.settings.development':
urlpatterns += [
url(r'^swagger/$', SwaggerSchemaView.as_view(), name='swagger_view'),
]

View File

@@ -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

View File

View File

@@ -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 ''

View File

View File

@@ -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])

View File

@@ -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)
))

View File

@@ -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

View File

@@ -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'),

View File

@@ -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 = (

View File

@@ -1,4 +1,5 @@
django-debug-toolbar==1.5
django-rest-swagger
pprofile
ipython==5.2.1
unittest2

View File

@@ -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: