diff --git a/Makefile b/Makefile index 37a5e226a5..4c0fe0dd36 100644 --- a/Makefile +++ b/Makefile @@ -367,7 +367,7 @@ 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) + (set -o pipefail && py.test awx/conf/tests/functional awx/main/tests/functional/api awx/main/tests/docs --release=$(SWAGGER_RELEASE_VERSION:-$RELEASE_VERSION) | tee reports/$@.report) check: flake8 pep8 # pyflakes pylint 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/main/tests/docs/test_swagger_generation.py b/awx/main/tests/docs/test_swagger_generation.py index 0c63d64721..f4a04d02bd 100644 --- a/awx/main/tests/docs/test_swagger_generation.py +++ b/awx/main/tests/docs/test_swagger_generation.py @@ -1,9 +1,14 @@ 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 django.conf import settings from openapi_codec.encode import generate_swagger_object import pytest @@ -19,6 +24,13 @@ 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(): """ @@ -45,58 +57,58 @@ class TestSwaggerGeneration(): 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_transform_metadata(self, release): - """ - This test takes the JSON output from the swagger endpoint and applies - various transformations to it. - """ + def test_sanity(self, release): JSON = self.__class__.JSON JSON['info']['version'] = release - JSON['host'] = None - JSON['schemes'] = ['https'] - JSON['produces'] = ['application/json'] - 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: - 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'] - JSON.setdefault('tags', []).append(tag) - - revised_paths = {} - deprecated_paths = JSON.pop('deprecated_paths', []) - for path, node in 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 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) - JSON['paths'] = revised_paths # 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 JSON['info']['version'] == release assert len(JSON['paths']) # The number of API endpoints changes over time, but let's just check @@ -119,7 +131,55 @@ class TestSwaggerGeneration(): # 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))) + 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 f0aae75fd1..a0c61d23f4 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -46,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(): @@ -546,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