├── examples ├── __init__.py ├── django_app │ ├── db.sqlite3 │ ├── django_app │ │ ├── __init__.py │ │ ├── wsgi.py │ │ ├── urls.py │ │ ├── views.py │ │ └── settings.py │ └── manage.py ├── oas_empty_expected.yaml ├── api.py ├── api.json ├── oas_testcase.py ├── current_api.json ├── oas_testcase_openapi.yaml ├── oas_testcase_expected.yaml └── oas_testcase_api.json ├── acceptable ├── tests │ ├── __init__.py │ ├── test_util.py │ ├── _fixtures.py │ ├── test_dummy_importer.py │ ├── test_openapi.py │ ├── test_validation.py │ ├── test_djangoutils.py │ ├── test_service.py │ ├── test_main.py │ └── test_lint.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── acceptable.py ├── templates │ ├── index.md.j2 │ └── api_group.md.j2 ├── __init__.py ├── make │ └── Makefile.acceptable ├── dummy_importer.py ├── responses.py ├── util.py ├── djangoutil.py ├── openapi.py ├── lint.py ├── _validation.py ├── __main__.py └── _service.py ├── MANIFEST.in ├── setup.cfg ├── .gitignore ├── requirements-dev.txt ├── .git-blame-ignore-revs ├── .travis.yml ├── pyproject.toml ├── tox.ini ├── .github └── workflows │ ├── lint.yml │ └── tox.yml ├── Makefile ├── setup.py ├── docs-todo.md ├── CHANGELOG.rst ├── README.rst └── LICENSE /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptable/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptable/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_app/db.sqlite3: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /acceptable/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/django_app/django_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include acceptable/make/Makefile.acceptable 2 | include acceptable/templates/* 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length=88 6 | extend-exclude=env/ 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /env/ 2 | __pycache__/ 3 | *.pyc 4 | *.egg-info 5 | /dist/ 6 | /tmp/ 7 | build/ 8 | docs/ 9 | .tox 10 | .coverage 11 | venv/ 12 | .eggs/ 13 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black < 23.0.0 2 | coveralls 3 | flake8 4 | fixtures 5 | isort 6 | pytest 7 | pytest-cov 8 | testscenarios 9 | testtools 10 | responses 11 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Migrate code style to Black 2 | d0923a275476bf64bb192d792e6fead7ec49f23f 3 | # Make a clean slate, remove trailing commas 4 | 240a239b0b8d0b85c7124423ace6209b2856cce0 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | cache: pip 3 | dist: xenial 4 | sudo: False 5 | python: 6 | - 3.5 7 | - 3.6 8 | install: 9 | - pip install tox-travis 10 | script: tox 11 | after_success: 12 | - coveralls 13 | -------------------------------------------------------------------------------- /examples/oas_empty_expected.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | contact: 3 | email: example@example.example 4 | name: '' 5 | description: None 6 | title: None 7 | version: 0.0.None 8 | openapi: 3.1.0 9 | paths: {} 10 | servers: 11 | - url: http://localhost 12 | tags: [] 13 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | skip-string-normalization = true 3 | skip-magic-trailing-comma = true 4 | 5 | [tool.isort] 6 | profile = "black" 7 | skip = ["env", ".tox"] 8 | 9 | [tool.pytest.ini_options] 10 | addopts = "--cov=acceptable" 11 | 12 | [tool.coverage.report] 13 | omit = ["acceptable/tests/*"] 14 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py38, py310, py312 3 | skip_missing_interpreters = True 4 | skipsdist = True 5 | 6 | [gh-actions] 7 | python = 8 | 3.8: py38 9 | 3.10: py310 10 | 3.12: py312 11 | 12 | [testenv] 13 | usedevelop = True 14 | deps = -r{toxinidir}/requirements-dev.txt 15 | extras = 16 | flask 17 | django 18 | commands = 19 | pytest {posargs} 20 | passenv = 21 | TRAVIS 22 | TRAVIS_BRANCH 23 | TRAVIS_JOB_ID 24 | -------------------------------------------------------------------------------- /examples/django_app/django_app/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_app project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /acceptable/templates/index.md.j2: -------------------------------------------------------------------------------- 1 | --- 2 | title: {{service_name}} Documentation 3 | --- 4 | 5 | This is the documentation for version {{ version }} of {{service_name}}. 6 | 7 | {% if changelog %} 8 | ## Changelog 9 | 10 | {% for version, api_logs in changelog.items() | sort(reverse=True) %} 11 | * Version {{ version }}: 12 | {% for (group, api_name), log in api_logs.items() | sort %} 13 | * [{{ api_name }}]({{group}}.html#{{api_name}}): {{ log }} 14 | {% endfor %} 15 | {% endfor %} 16 | 17 | {% endif %} 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: psf/black@stable 11 | with: 12 | options: "--check --verbose --skip-magic-trailing-comma" 13 | version: "~= 22.0" 14 | - uses: actions/setup-python@v4 15 | - uses: py-actions/flake8@v2 16 | with: 17 | max-line-length: "88" 18 | - uses: isort/isort-action@v1 19 | with: 20 | configuration: "--profile=black --check" 21 | -------------------------------------------------------------------------------- /acceptable/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | 4 | 5 | from ._service import AcceptableService, get_metadata # NOQA 6 | from ._validation import ( # NOQA 7 | DataValidationError, 8 | validate_body, 9 | validate_output, 10 | validate_params, 11 | ) 12 | 13 | # __all__ strings must be bytes in py2 and unicode in py3 14 | __all__ = [ 15 | "AcceptableService", 16 | "DataValidationError", 17 | "get_metadata", 18 | "validate_body", 19 | "validate_output", 20 | "validate_params", 21 | ] 22 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: Run tox 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - master 8 | pull_request: 9 | branches: 10 | - main 11 | - master 12 | 13 | jobs: 14 | build: 15 | name: "Project tests via tox" 16 | runs-on: ubuntu-24.04 17 | strategy: 18 | matrix: 19 | python: [3.8, "3.10", "3.12"] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Setup Python 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python }} 27 | - name: Install dependencies 28 | run: | 29 | python -m pip install --upgrade pip 30 | pip install "tox<4" tox-gh-actions 31 | - name: Run tox 32 | run: tox 33 | -------------------------------------------------------------------------------- /acceptable/make/Makefile.acceptable: -------------------------------------------------------------------------------- 1 | ACCEPTABLE_ENV ?= $(ENV) 2 | ACCEPTABLE_METADATA ?= api.json 3 | ACCEPTABLE_DOCS ?= docs 4 | 5 | .PHONY: api-lint 6 | api-lint: 7 | $(ACCEPTABLE_ENV)/bin/acceptable lint $(ACCEPTABLE_METADATA) $(ACCEPTABLE_MODULES) --quiet $(ARGS) 8 | 9 | .PHONY: api-update-metadata 10 | api-update-metadata: 11 | $(ACCEPTABLE_ENV)/bin/acceptable lint $(ACCEPTABLE_METADATA) $(ACCEPTABLE_MODULES) --quiet --update $(ARGS) 12 | 13 | .PHONY: api-version 14 | api-version: 15 | $(ACCEPTABLE_ENV)/bin/acceptable api-version $(ACCEPTABLE_METADATA) $(ACCEPTABLE_MODULES) 16 | 17 | .PHONY: api-docs-markdown 18 | api-docs-markdown: 19 | rm -f "$(ACCEPTABLE_DOCS)"/en/*.md 20 | $(ACCEPTABLE_ENV)/bin/acceptable render $(ACCEPTABLE_METADATA) --name '$(ACCEPTABLE_SERVICE_TITLE)' --dir "$(ACCEPTABLE_DOCS)" 21 | 22 | -------------------------------------------------------------------------------- /examples/django_app/django_app/urls.py: -------------------------------------------------------------------------------- 1 | """django_app URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url 17 | from django.contrib import admin 18 | 19 | urlpatterns = [url(r"admin/", admin.site.urls)] 20 | -------------------------------------------------------------------------------- /examples/django_app/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django # noqa 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /acceptable/tests/test_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import testtools 4 | 5 | from acceptable import util 6 | 7 | 8 | class UtilsTestCase(testtools.TestCase): 9 | def test_sort_schema_simple(self): 10 | srtd = util.sort_schema({"5": "5", "1": "1", "3": "3"}) 11 | self.assertEqual(["1", "3", "5"], list(srtd)) 12 | 13 | def test_sort_schema(self): 14 | d = {"5": "5", "1": "1", "3": "3"} 15 | ll = [5, 1, 3] 16 | srtd = util.sort_schema({"foo": {"b": [d], "a": [ll]}}) 17 | 18 | # check we sort nested dicts 19 | self.assertEqual(["a", "b"], list(srtd["foo"])) 20 | # check we sort dicts nested in lists 21 | self.assertEqual(["1", "3", "5"], list(srtd["foo"]["b"][0])) 22 | # ensure we preserve the order of lists 23 | self.assertEqual([5, 1, 3], list(srtd["foo"]["a"][0])) 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: black 2 | black: env/.done 3 | env/bin/black . 4 | 5 | .PHONY: isort 6 | isort: env/.done 7 | env/bin/isort . 8 | 9 | .PHONY: fmt 10 | fmt: isort black 11 | 12 | .PHONY: clean 13 | clean: 14 | rm -rf env docs html .tox .eggs build dist 15 | 16 | env/.done: requirements-dev.txt setup.py 17 | virtualenv -p python3 env 18 | env/bin/pip install -e .[flask,django] 19 | env/bin/pip install -r requirements-dev.txt 20 | env/bin/pip uninstall -y argparse # we want to use the built-in version of argparse 21 | touch $@ 22 | 23 | env/bin/tox: env/.done 24 | env/bin/pip install "tox<4" 25 | 26 | .PHONY: lint 27 | lint: env/.done 28 | env/bin/flake8 . 29 | env/bin/black . --check 30 | env/bin/isort . --check 31 | 32 | .PHONY: test 33 | test: env/.done 34 | env/bin/pytest 35 | 36 | .PHONY: tox 37 | tox: env/bin/tox 38 | env/bin/tox 39 | 40 | .PHONY: wheel 41 | wheel: env/.done 42 | env/bin/python setup.py bdist_wheel 43 | 44 | .PHONY: sdist 45 | sdist: env/.done 46 | env/bin/python setup.py sdist 47 | -------------------------------------------------------------------------------- /examples/api.py: -------------------------------------------------------------------------------- 1 | from acceptable import AcceptableService 2 | 3 | service = AcceptableService("mysvc") 4 | 5 | foo_api = service.api("/foo", "foo", introduced_at=2) 6 | 7 | foo_api.request_schema = { 8 | "type": "object", 9 | "required": ["foo", "baz"], 10 | "properties": { 11 | "foo": {"type": "string"}, 12 | "baz": { 13 | "type": "object", 14 | "description": "Bar the door.", 15 | "introduced_at": 4, 16 | "properties": { 17 | "bar": {"type": "string", "introduced_at": 5, "description": "asdf"} 18 | }, 19 | }, 20 | }, 21 | } 22 | 23 | foo_api.response_schema = { 24 | "type": "object", 25 | "properties": { 26 | "foo_result": {"type": "string"}, 27 | "bar": {"type": "string", "description": "bar bar", "introduced_at": 5}, 28 | }, 29 | } 30 | 31 | foo_api.changelog(5, "Added baz field.") 32 | foo_api.changelog(4, "Added bar field") 33 | 34 | 35 | @foo_api 36 | def foo(): 37 | """Documentation goes here.""" 38 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # Copyright 2017 Canonical Ltd. This software is licensed under the 4 | # GNU Lesser General Public License version 3 (see the file LICENSE). 5 | 6 | from setuptools import find_packages, setup 7 | 8 | VERSION = "0.42" 9 | 10 | setup( 11 | name="acceptable", 12 | version=VERSION, 13 | description="API metadata and schema tool for generating tests and documentation", 14 | author="Canonical Online Services", 15 | author_email="online-services@lists.canonical.com", 16 | url="https://github.com/canonical/acceptable", 17 | license="LGPLv3", 18 | packages=find_packages(exclude=["examples", "*tests"]), 19 | long_description="".join(open("README.rst").readlines()[2:]), 20 | long_description_content_type="text/x-rst", 21 | install_requires=["jsonschema", "pyyaml", "Jinja2"], 22 | extras_require=dict(flask=["Flask"], django=["django>=2.1,<3"]), 23 | test_suite="acceptable.tests", 24 | include_package_data=True, 25 | entry_points={"console_scripts": ["acceptable = acceptable.__main__:main"]}, 26 | ) 27 | -------------------------------------------------------------------------------- /examples/api.json: -------------------------------------------------------------------------------- 1 | { 2 | "$version": 2, 3 | "default": { 4 | "apis": { 5 | "foo": { 6 | "api_name": "foo", 7 | "changelog": {}, 8 | "doc": "Documentation goes here.", 9 | "introduced_at": 2, 10 | "methods": [ 11 | "GET" 12 | ], 13 | "request_schema": { 14 | "properties": { 15 | "foo": { 16 | "description": "The foo thing.", 17 | "type": "string" 18 | }, 19 | "baz": { 20 | "type": "object" 21 | } 22 | }, 23 | "required": ["foo"], 24 | "type": "object" 25 | }, 26 | "response_schema": { 27 | "properties": { 28 | "foo_result": { 29 | "type": "string" 30 | } 31 | }, 32 | "type": "object" 33 | }, 34 | "title": "Foo", 35 | "url": "/foo" 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/oas_testcase.py: -------------------------------------------------------------------------------- 1 | """Test case for OpenAPI specification (OAS) output.""" 2 | 3 | from acceptable import AcceptableService 4 | 5 | service = AcceptableService("OpenApiSample") 6 | 7 | foo_api = service.api("/foo//", "foo", introduced_at=2) 8 | 9 | foo_api.params_schema = { 10 | "type": "object", 11 | "required": ["param1"], 12 | "properties": {"param1": {"type": "string"}, "param2": {"type": "integer"}}, 13 | } 14 | 15 | foo_api.request_schema = { 16 | "type": "object", 17 | "required": ["foo", "baz"], 18 | "properties": { 19 | "foo": {"description": "This is a foo.", "type": "string"}, 20 | "baz": { 21 | "type": "object", 22 | "description": "Bar the door.", 23 | "introduced_at": 4, 24 | "properties": { 25 | "bar": {"type": "string", "introduced_at": 5, "description": "asdf"} 26 | }, 27 | }, 28 | }, 29 | } 30 | 31 | foo_api.response_schema = { 32 | "type": "object", 33 | "properties": { 34 | "foo_result": {"description": "Result of a foo.", "type": "string"}, 35 | "bar": {"type": "string", "description": "bar bar", "introduced_at": 5}, 36 | }, 37 | } 38 | 39 | foo_api.changelog(5, "Added baz field.") 40 | foo_api.changelog(4, "Added bar field") 41 | 42 | 43 | @foo_api 44 | def foo(): 45 | """Documentation goes here.""" 46 | -------------------------------------------------------------------------------- /acceptable/tests/_fixtures.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import os 4 | import sys 5 | import textwrap 6 | 7 | import fixtures 8 | 9 | from acceptable import _service 10 | 11 | 12 | def clean_up_module(name, old_syspath=None): 13 | sys.modules.pop(name) 14 | _service.clear_metadata() 15 | 16 | if old_syspath is not None: 17 | sys.path = old_syspath 18 | 19 | 20 | class CleanUpModuleImport(fixtures.Fixture): 21 | def __init__(self, name): 22 | self.name = name 23 | 24 | def _setUp(self): 25 | _service.clear_metadata() 26 | self.addCleanup(clean_up_module, self.name) 27 | 28 | 29 | class TemporaryModuleFixture(fixtures.Fixture): 30 | """Setup a module that can be imported, and clean up afterwards.""" 31 | 32 | def __init__(self, name, code): 33 | self.name = name 34 | self.code = textwrap.dedent(code).strip() 35 | self.path = None 36 | 37 | def _setUp(self): 38 | tempdir = self.useFixture(fixtures.TempDir()).path 39 | self.path = os.path.join(tempdir, "{}.py".format(self.name)) 40 | with open(self.path, "w") as f: 41 | f.write(self.code) 42 | 43 | # preserve state 44 | old_sys_path = sys.path 45 | sys.path = [tempdir] + old_sys_path 46 | _service.clear_metadata() 47 | 48 | self.addCleanup(clean_up_module, self.name, old_sys_path) 49 | -------------------------------------------------------------------------------- /examples/django_app/django_app/views.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.conf.urls import include, url 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | from acceptable import AcceptableService 6 | 7 | service = AcceptableService("django_app") 8 | 9 | 10 | class TestForm(forms.Form): 11 | foo = forms.EmailField(required=True, label=_("foo"), help_text=_("foo help")) 12 | 13 | bar = forms.ChoiceField( 14 | required=False, 15 | label=_("bar"), 16 | help_text=_("bar help"), 17 | choices=[("A", "AAA"), ("B", "BBB"), ("C", "CCC")], 18 | ) 19 | 20 | baz = forms.DecimalField(required=False, label=_("baz"), help_text=_("baz help")) 21 | 22 | multi = forms.MultipleChoiceField( 23 | label=_("multi"), 24 | required=False, 25 | help_text=_("multi help"), 26 | choices=[("A", "AAA"), ("B", "BBB"), ("C", "CCC")], 27 | ) 28 | 29 | 30 | api = service.django_api("test", introduced_at=1) 31 | api.django_form = TestForm 32 | 33 | 34 | @api.handler 35 | class TestHandler(object): 36 | """Documentation. 37 | 38 | Multiline.""" 39 | 40 | allowed_methods = ("POST",) 41 | 42 | def __call__(self, *args): 43 | raise Exception("test only method") 44 | 45 | 46 | urlpatterns = [ 47 | url("^test$", TestHandler(), name="test"), 48 | url("^test2/(.*)$", TestHandler(), name="test2"), 49 | url("^login$", TestHandler(), name="login"), 50 | url("^prefix1/", include(("django_app.urls", "admin"))), 51 | url("^prefix2/", include(("django_app.urls", "admin"), namespace="other")), 52 | ] 53 | -------------------------------------------------------------------------------- /examples/current_api.json: -------------------------------------------------------------------------------- 1 | { 2 | "$version": 5, 3 | "default": { 4 | "apis": { 5 | "foo": { 6 | "service": "mysvc", 7 | "api_group": "default", 8 | "api_name": "foo", 9 | "introduced_at": 2, 10 | "methods": [ 11 | "GET" 12 | ], 13 | "request_schema": { 14 | "properties": { 15 | "baz": { 16 | "description": "Bar the door.", 17 | "introduced_at": 4, 18 | "properties": { 19 | "bar": { 20 | "description": "asdf", 21 | "introduced_at": 5, 22 | "type": "string" 23 | } 24 | }, 25 | "type": "object" 26 | }, 27 | "foo": { 28 | "type": "string" 29 | } 30 | }, 31 | "required": [ 32 | "foo", 33 | "baz" 34 | ], 35 | "type": "object" 36 | }, 37 | "response_schema": { 38 | "properties": { 39 | "bar": { 40 | "description": "bar bar", 41 | "introduced_at": 5, 42 | "type": "string" 43 | }, 44 | "foo_result": { 45 | "type": "string" 46 | } 47 | }, 48 | "type": "object" 49 | }, 50 | "doc": "Documentation goes here.", 51 | "changelog": { 52 | "5": "Added baz field.", 53 | "4": "Added bar field" 54 | }, 55 | "title": "Foo", 56 | "url": "/foo" 57 | } 58 | }, 59 | "title": "Default" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /acceptable/tests/test_dummy_importer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import importlib.abc 4 | import importlib.util 5 | import sys 6 | import types 7 | 8 | import testtools 9 | from testtools.assertions import assert_that 10 | from testtools.matchers import Contains, Is, Not 11 | 12 | from acceptable.dummy_importer import DummyImporterContext 13 | 14 | 15 | class DummyImporterContextTests(testtools.TestCase): 16 | def test_mock_fake_import(self): 17 | with DummyImporterContext(): 18 | import zzzxxxvvv # noqa 19 | 20 | def test_allowed_real_modules(self): 21 | class FakeModuleLoader(importlib.abc.MetaPathFinder, importlib.abc.Loader): 22 | def __init__(self): 23 | self.imported = False 24 | self.module = types.SimpleNamespace() 25 | 26 | def find_spec(self, fullname, path, target=None): 27 | if fullname == "zzzxxxvvv.test": 28 | return importlib.util.spec_from_loader(fullname, self) 29 | return None 30 | 31 | def create_module(self, spec): 32 | return None 33 | 34 | def exec_module(self, module): 35 | self.imported = True 36 | sys.modules[module.__name__] = self.module 37 | self.module.__name__ = module.__name__ 38 | self.module.__loader__ = self 39 | self.module.__spec__ = module.__spec__ 40 | 41 | fml = FakeModuleLoader() 42 | sys.meta_path.insert(0, fml) 43 | with DummyImporterContext("zzzxxxvvv.test"): 44 | import zzzxxxvvv.test 45 | 46 | assert_that(fml.module, Is(zzzxxxvvv.test)) 47 | assert_that(sys.modules, Not(Contains("zzzxxxvvv.test"))) 48 | assert_that(fml.imported, Is(True)) 49 | sys.meta_path.remove(fml) 50 | -------------------------------------------------------------------------------- /docs-todo.md: -------------------------------------------------------------------------------- 1 | 2 | The following text was pulled from README.md, and needs to be re-written and 3 | possibly re-inserted somewhere in the project... 4 | 5 | ## Developer Perspective: 6 | 7 | The acceptable library helps developers provide a predictable API to clients. 8 | It does this by requiring developers to annotate views with two pieces of 9 | information: 10 | 11 | 1. The version number the view was introduced at. 12 | 2. The API Flag (if any) that the view is present under. 13 | 14 | 15 | ### Version Numbers 16 | 17 | A version number follows a simple 'X.Y' pattern: 'X' is the *major* version 18 | number, and 'Y' is the *minor* version number. Both the major and minor version 19 | numbers can be any integer >= 0. Major version numbers indicate backwards 20 | -incompatible changes to the API. Obviously creating a backwards-incompatible 21 | change is something that should be avoided at all costs, so major version 22 | numbers should be rarely updated. Minor version numbers indicate backwards 23 | compatible changes, and should be updated any time some new functionality 24 | is introduced to the API. 25 | 26 | Clients specify the preferred version number they support. If a client supports 27 | API version '1.3', any '1.x' version less than, or equal to '1.3' may be 28 | used to satisfy the client's request. 29 | 30 | ### API Flags 31 | 32 | 33 | API Flags allow developers to expose experimental APIs to clients that 34 | understand that opting in to use those experimental APIs carries significant 35 | risk. As a general rule, clients in production should never use views behind 36 | API flags. 37 | 38 | Once these experimental features have reached maturity, the API flag is removed 39 | from the view(s) in question, and the views are introduced at a newer API 40 | version number. 41 | 42 | ## TODO: 43 | 44 | - can we somehow encode what view was selected in the response? Could use 45 | content-type, but that will wreak havock with requests ets, so let's not do 46 | that. 47 | -------------------------------------------------------------------------------- /examples/oas_testcase_openapi.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | contact: 3 | email: example@example.example 4 | name: '' 5 | description: OpenApiSample 6 | title: OpenApiSample 7 | version: 0.0.5 8 | openapi: 3.1.0 9 | paths: 10 | /foo/{p}/{q}: 11 | get: 12 | description: Documentation goes here. 13 | operationId: foo-get 14 | parameters: 15 | - in: path 16 | name: p 17 | required: true 18 | schema: 19 | type: int 20 | - in: path 21 | name: q 22 | required: true 23 | schema: 24 | type: str 25 | - in: query 26 | name: param1 27 | required: true 28 | schema: 29 | type: string 30 | - in: query 31 | name: param2 32 | required: false 33 | schema: 34 | type: integer 35 | requestBody: 36 | content: 37 | application/json: 38 | schema: 39 | properties: 40 | baz: 41 | description: Bar the door. 42 | introduced_at: 4 43 | properties: 44 | bar: 45 | description: asdf 46 | introduced_at: 5 47 | type: string 48 | type: object 49 | foo: 50 | description: This is a foo. 51 | type: string 52 | required: 53 | - foo 54 | - baz 55 | type: object 56 | responses: 57 | '200': 58 | content: 59 | application/json: 60 | schema: 61 | properties: 62 | bar: 63 | description: bar bar 64 | introduced_at: 5 65 | type: string 66 | foo_result: 67 | description: Result of a foo. 68 | type: string 69 | type: object 70 | description: OK 71 | tags: [] 72 | servers: 73 | - url: http://localhost 74 | tags: [] 75 | -------------------------------------------------------------------------------- /examples/oas_testcase_expected.yaml: -------------------------------------------------------------------------------- 1 | info: 2 | contact: 3 | email: example@example.example 4 | name: '' 5 | description: OpenApiSample 6 | title: OpenApiSample 7 | version: 0.0.5 8 | openapi: 3.1.0 9 | paths: 10 | /foo/{p}/{q}: 11 | get: 12 | description: Documentation goes here. 13 | operationId: foo-get 14 | parameters: 15 | - in: path 16 | name: p 17 | required: true 18 | schema: 19 | type: int 20 | - in: path 21 | name: q 22 | required: true 23 | schema: 24 | type: str 25 | - in: query 26 | name: param1 27 | required: true 28 | schema: 29 | type: string 30 | - in: query 31 | name: param2 32 | required: false 33 | schema: 34 | type: integer 35 | requestBody: 36 | content: 37 | application/json: 38 | schema: 39 | properties: 40 | baz: 41 | description: Bar the door. 42 | introduced_at: 4 43 | properties: 44 | bar: 45 | description: asdf 46 | introduced_at: 5 47 | type: string 48 | type: object 49 | foo: 50 | description: This is a foo. 51 | type: string 52 | required: 53 | - foo 54 | - baz 55 | type: object 56 | responses: 57 | '200': 58 | content: 59 | application/json: 60 | schema: 61 | properties: 62 | bar: 63 | description: bar bar 64 | introduced_at: 5 65 | type: string 66 | foo_result: 67 | description: Result of a foo. 68 | type: string 69 | type: object 70 | description: OK 71 | tags: [] 72 | servers: 73 | - url: http://localhost 74 | tags: [] 75 | -------------------------------------------------------------------------------- /examples/oas_testcase_api.json: -------------------------------------------------------------------------------- 1 | { 2 | "$version": 5, 3 | "default": { 4 | "apis": { 5 | "foo": { 6 | "service": "OpenApiSample", 7 | "api_group": "default", 8 | "api_name": "foo", 9 | "introduced_at": 2, 10 | "methods": [ 11 | "GET" 12 | ], 13 | "request_schema": { 14 | "properties": { 15 | "baz": { 16 | "description": "Bar the door.", 17 | "introduced_at": 4, 18 | "properties": { 19 | "bar": { 20 | "description": "asdf", 21 | "introduced_at": 5, 22 | "type": "string" 23 | } 24 | }, 25 | "type": "object" 26 | }, 27 | "foo": { 28 | "description": "This is a foo.", 29 | "type": "string" 30 | } 31 | }, 32 | "required": [ 33 | "foo", 34 | "baz" 35 | ], 36 | "type": "object" 37 | }, 38 | "response_schema": { 39 | "properties": { 40 | "bar": { 41 | "description": "bar bar", 42 | "introduced_at": 5, 43 | "type": "string" 44 | }, 45 | "foo_result": { 46 | "description": "Result of a foo.", 47 | "type": "string" 48 | } 49 | }, 50 | "type": "object" 51 | }, 52 | "params_schema": { 53 | "properties": { 54 | "param1": { 55 | "type": "string" 56 | }, 57 | "param2": { 58 | "type": "integer" 59 | } 60 | }, 61 | "required": [ 62 | "param1" 63 | ], 64 | "type": "object" 65 | }, 66 | "doc": "Documentation goes here.", 67 | "changelog": { 68 | "5": "Added baz field.", 69 | "4": "Added bar field" 70 | }, 71 | "title": "Foo", 72 | "url": "/foo//" 73 | } 74 | }, 75 | "title": "Default", 76 | "docs": "Test case for OpenAPI specification (OAS) output." 77 | } 78 | } -------------------------------------------------------------------------------- /acceptable/management/commands/acceptable.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | """acceptable - Programatic API Metadata for Flask apps.""" 4 | 5 | import argparse 6 | import json 7 | import sys 8 | 9 | from django.core.management.base import BaseCommand 10 | 11 | from acceptable import get_metadata, openapi 12 | from acceptable.__main__ import load_metadata 13 | from acceptable.djangoutil import get_urlmap 14 | 15 | 16 | class Command(BaseCommand): 17 | help = "Generate Acceptable API Metadata from project" 18 | 19 | def add_arguments(self, parser): 20 | # Handle our subparsers in a way that is supported in Django 2.1+ 21 | subparsers = parser.add_subparsers(dest="cmd") 22 | 23 | metadata_parser = subparsers.add_parser( 24 | "metadata", help="Import project and print extracted metadata in json" 25 | ) 26 | metadata_parser.set_defaults(func=self.metadata) 27 | 28 | openapi_parser = subparsers.add_parser( 29 | "openapi", help="Import project and print as OpenAPI 3.1 schema" 30 | ) 31 | openapi_parser.set_defaults(func=self.openapi) 32 | 33 | version_parser = subparsers.add_parser( 34 | "api-version", 35 | help="Get the current api version from json meta, and " 36 | "optionally from current code also", 37 | ) 38 | version_parser.add_argument( 39 | "metadata", 40 | nargs="?", 41 | type=argparse.FileType("r"), 42 | default=sys.stdin, 43 | help="The json metadata for the api", 44 | ) 45 | version_parser.set_defaults(func=self.version) 46 | 47 | def handle(self, *args, **options): 48 | get_urlmap() # this imports all urls and initialises the url mappings 49 | func = options["func"] 50 | func(options, get_metadata()) 51 | 52 | def metadata(self, _, metadata): 53 | _serial, _ = metadata.serialize() 54 | print(json.dumps(_serial, indent=2)) 55 | 56 | def openapi(self, _, metadata): 57 | print(openapi.dump(metadata)) 58 | 59 | def version(self, options, metadata, stream=sys.stdout): 60 | file_metadata = load_metadata(options["metadata"]) 61 | json_version = file_metadata["$version"] 62 | serialized, _ = metadata.serialize() 63 | import_version = serialized["$version"] 64 | stream.write("{}: {}\n".format(options["metadata"].name, json_version)) 65 | stream.write("Imported API: {}\n".format(import_version)) 66 | -------------------------------------------------------------------------------- /acceptable/templates/api_group.md.j2: -------------------------------------------------------------------------------- 1 | --- 2 | title: {{group_name}} 3 | --- 4 | # {{group_title}} 5 | {{ group_doc|default('') }} 6 | 7 | {% for api in group_apis %} 8 | 9 | ## {{api.api_name}}{{ ' **(DEPRECATED)**' if api.deprecated_at else '' }} 10 | 11 | {% if api.deprecated_at %} 12 | !!! Warning "Deprecated" 13 | Deprecated from version {{api.deprecated_at}} 14 | {% endif %} 15 | 16 | {% if api.deprecated_at != api.introduced_at %} 17 | *Introduced in version {{api.introduced_at}}* 18 | {% endif %} 19 | 20 | Summary: **{{api.methods|join('|')}} {{api.url|e}}** 21 | 22 | {% if api.doc %} 23 | {{api.doc}} 24 | {% else %} 25 | No documentation yet. 26 | {% endif %} 27 | 28 | {% if api.changelog %} 29 | ### {{api.api_name}} changelog 30 | 31 | {% for version, log in api.changelog.items()| sort(reverse=True) %} 32 | * Version {{ version }}: {{ log }} 33 | {% endfor %} 34 | 35 | {% endif %} 36 | 37 | {% if api.params_schema and 'properties' in api.params_schema %} 38 | ### URL Parameters 39 | 40 | ```json 41 | {{api.params_schema|tojson(4)}} 42 | ``` 43 | 44 | {% for name, desc in api.params_schema['properties'].items() %} 45 | * {{ name }} 46 | {% if 'description' in desc %} 47 | * description: {{ desc['description'] }} 48 | {% endif %} 49 | {% if name in api.params_schema.get('required', []) %} 50 | * Is required 51 | {% endif %} 52 | {% if 'type' in desc and desc['type'] != 'string' %} 53 | * Must be {{ desc['type'] }} type 54 | {% endif %} 55 | {% if 'pattern' in desc %} 56 | * Must match regex "{{ desc['pattern'] }}" 57 | {% endif %} 58 | {% if 'minLength' in desc %} 59 | * Length must be >= {{ desc['minLength'] }} 60 | {% endif %} 61 | {% if 'maxLength' in desc %} 62 | * Length must be <= {{ desc['maxLength'] }} 63 | {% endif %} 64 | {% if 'minimum' in desc %} 65 | * Must be >{{ '' if desc.get('exclusiveMinimum') else '=' }} {{ desc['minimum'] }} 66 | {% endif %} 67 | {% if 'maximum' in desc %} 68 | * Must be <{{ '' if desc.get('exclusiveMaximum') else '=' }} {{ desc['maximum'] }} 69 | {% endif %} 70 | {% if 'multipleOf' in desc %} 71 | * Must be multiple of {{ desc['multipleOf'] }} 72 | {% endif %} 73 | {% if 'enum' in desc %} 74 | * Must be one of {% for v in desc['enum'] %}{{ " or " if loop.last else ", " if not loop.first else "" }}"{{ v }}"{% endfor %} 75 | 76 | {% endif %} 77 | {% if 'const' in desc %} 78 | * Must be "{{ desc['const'] }}" 79 | {% endif %} 80 | {% endfor %} 81 | 82 | {% endif %} 83 | {% if api.request_schema %} 84 | ### Request JSON Schema 85 | 86 | ```json 87 | {{api.request_schema|tojson(4)}} 88 | ``` 89 | {% endif %} 90 | 91 | {% if api.response_schema %} 92 | ### Response JSON Schema 93 | 94 | ```json 95 | {{api.response_schema|tojson(4)}} 96 | ``` 97 | {% endif %} 98 | {% endfor %} 99 | -------------------------------------------------------------------------------- /acceptable/dummy_importer.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import importlib.abc 4 | import importlib.util 5 | import sys 6 | from unittest.mock import MagicMock 7 | 8 | 9 | class DummyFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader): 10 | """Implements PEP 451 module finder and loader which will pretend to 11 | load modules but actually just create mocks that look like them. 12 | 13 | This allows python code to be loaded when its dependencies are not 14 | installed. 15 | 16 | allowed_real_modules is a list of module names to not mock instead the 17 | module finders are tried. 18 | 19 | finders can come from sys.meta_path 20 | 21 | modules in passthrough will raise the correct error on import if 22 | they can't be loaded. 23 | 24 | This class is used by DummyImporterContext which patches sys.modules 25 | and sys.meta_path. 26 | """ 27 | 28 | def __init__(self, allowed_real_modules, finders): 29 | self.finders = finders 30 | self.allowed = set(allowed_real_modules) 31 | 32 | def find_spec(self, fullname, path, target=None): 33 | if fullname in self.allowed: 34 | for finder in self.finders: 35 | spec = finder.find_spec(fullname, path, target) 36 | if spec is not None: 37 | return spec 38 | else: 39 | return None 40 | 41 | return importlib.util.spec_from_loader(fullname, self) 42 | 43 | def create_module(self, spec): 44 | return None 45 | 46 | def exec_module(self, module): 47 | mod = MagicMock() 48 | mod.__file__ = "" 49 | mod.__loader__ = self 50 | mod.__path__ = [] 51 | mod.__package__ = module.__name__ 52 | mod.__doc__ = "DummyImporterContext dummy" 53 | mod.__spec__ = module.__spec__ 54 | 55 | sys.modules[module.__name__] = mod 56 | 57 | 58 | class DummyImporterContext(object): 59 | """Creates a context in which modules, other than those in 60 | allowed_real_modules, will not be imported but instead replaced with 61 | mocks. 62 | 63 | Manager sys.modules so that the mock modules are removed after 64 | the context ends. 65 | 66 | Allows python code to be imported and executed even when its 67 | dependencies are not installed. 68 | """ 69 | 70 | def __init__(self, *allowed_real_modules): 71 | self.allowed_real_modules = set(allowed_real_modules) 72 | 73 | def __enter__(self): 74 | self.orig_sys_meta_path = sys.meta_path 75 | self.orig_sys_modules = sys.modules 76 | self.finder = DummyFinder(self.allowed_real_modules, self.orig_sys_meta_path) 77 | sys.modules = dict(sys.modules) 78 | sys.meta_path = [self.finder] 79 | 80 | def __exit__(self, *args): 81 | sys.meta_path = self.orig_sys_meta_path 82 | sys.modules = self.orig_sys_modules 83 | -------------------------------------------------------------------------------- /acceptable/responses.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import responses 4 | 5 | 6 | class ResponsesManager(object): 7 | """The responses library is used to add mock behaviour into the requests 8 | library. 9 | 10 | It does this using the RequestsMock class, however only one of 11 | these can be active at a time. Attempting to start a new RequestsMock 12 | will remove any others hooked into requests. 13 | 14 | We use an instance of this class to manage use of the RequestsMock 15 | instance `responses.mock`. This allows us to start, stop and reset 16 | the it at the right time. 17 | """ 18 | 19 | def __init__(self): 20 | self._attached = 0 21 | 22 | def attach(self): 23 | if self._attached == 0: 24 | responses.mock.start() 25 | self._attached += 1 26 | 27 | def detach(self): 28 | self._attached -= 1 29 | assert self._attached >= 0 30 | if self._attached == 0: 31 | responses.mock.stop() 32 | responses.mock.reset() 33 | 34 | def attach_callback(self, methods, url, callback): 35 | for method in methods: 36 | responses.mock.add_callback(method, url, callback) 37 | self.attach() 38 | 39 | def detach_callback(self, methods, url, callback): 40 | for method in methods: 41 | responses.mock.remove(method, url) 42 | self.detach() 43 | 44 | 45 | responses_manager = ResponsesManager() 46 | 47 | 48 | class responses_mock_context(object): 49 | """Provides access to `responses.mock` in a way that is safe to mix with 50 | mocks from `acceptable.mocks`. 51 | 52 | Use as a context manager or a decorator: 53 | 54 | def blah(): 55 | with responese_mock_context(): 56 | ... 57 | Or: 58 | 59 | @responese_mock_context() 60 | def blah(): 61 | ,,, 62 | """ 63 | 64 | def __enter__(self): 65 | responses_manager.attach() 66 | return responses.mock 67 | 68 | def __exit__(self, *args): 69 | responses_manager.detach() 70 | 71 | def __call__(self, func): 72 | # responses.get_wrapper creates a function which has the same 73 | # signature etc. as `func`. It execs `wrapper_template` 74 | # in a seperate namespace to do this. See get_wrapper code. 75 | wrapper_template = """\ 76 | def wrapper%(signature)s: 77 | with responses_mock_context: 78 | return func%(funcargs)s 79 | """ 80 | namespace = {"responses_mock_context": self, "func": func} 81 | try: 82 | return responses.get_wrapped(func, wrapper_template, namespace) 83 | except (TypeError, AttributeError): 84 | # In responses > 0.10.2, the function definition has changed 85 | # In responses > 0.20.0, the function definition changed again 86 | return responses.get_wrapped(func, self) 87 | -------------------------------------------------------------------------------- /acceptable/util.py: -------------------------------------------------------------------------------- 1 | import difflib 2 | import inspect 3 | import pprint 4 | import re 5 | import sys 6 | import textwrap 7 | from collections import OrderedDict 8 | 9 | 10 | def get_callsite_location(depth=1): 11 | frame = sys._getframe(depth + 1) 12 | return { 13 | "filename": inspect.getsourcefile(frame), 14 | "lineno": frame.f_lineno, 15 | "module": inspect.getmodule(frame), 16 | } 17 | 18 | 19 | def get_function_location(fn): 20 | # get the innermost wrapped function (the view itself) 21 | while getattr(fn, "__wrapped__", None) is not None: 22 | fn = fn.__wrapped__ 23 | 24 | source_lines, start_line = inspect.getsourcelines(fn) 25 | 26 | # unfortunately because getsourcelines considers decorators to be part of the 27 | # function, we need to manually find the line that contains the actual `def <...>(` 28 | # part. 29 | def_pattern = re.compile(r"^\s*def\s+\w+\s*\(") 30 | for offset, line in enumerate(source_lines): 31 | if def_pattern.match(line): 32 | return { 33 | "filename": inspect.getsourcefile(fn), 34 | "lineno": start_line + offset, 35 | "module": inspect.getmodule(fn), 36 | } 37 | 38 | # if we can't find a function definition, we just return whatever line Python thinks 39 | # is the start of the function. 40 | return { 41 | "filename": inspect.getsourcefile(fn), 42 | "lineno": start_line, 43 | "module": inspect.getmodule(fn), 44 | } 45 | 46 | 47 | def clean_docstring(docstring): 48 | """Dedent docstring, special casing the first line.""" 49 | docstring = docstring.strip() 50 | if "\n" in docstring: 51 | # multiline docstring 52 | if docstring[0].isspace(): 53 | # whole docstring is indented 54 | return textwrap.dedent(docstring) 55 | else: 56 | # first line not indented, rest maybe 57 | first, _, rest = docstring.partition("\n") 58 | return first + "\n" + textwrap.dedent(rest) 59 | return docstring 60 | 61 | 62 | def _sort_schema(schema): 63 | """Recursively sorts a JSON schema by dict key.""" 64 | 65 | if isinstance(schema, dict): 66 | for k, v in sorted(schema.items()): 67 | if isinstance(v, dict): 68 | yield k, OrderedDict(_sort_schema(v)) 69 | elif isinstance(v, list): 70 | yield k, list(_sort_schema(v)) 71 | else: 72 | yield k, v 73 | elif isinstance(schema, list): 74 | for v in schema: 75 | if isinstance(v, dict): 76 | yield OrderedDict(_sort_schema(v)) 77 | elif isinstance(v, list): 78 | yield list(_sort_schema(v)) 79 | else: 80 | yield v 81 | else: 82 | yield schema 83 | 84 | 85 | def sort_schema(schema): 86 | sorted_schema = OrderedDict(_sort_schema(schema)) 87 | # ensure sorting the schema does not alter it 88 | if schema != sorted_schema: 89 | d1 = pprint.pformat(schema).splitlines() 90 | d2 = pprint.pformat(sorted_schema).splitlines() 91 | diff = "\n".join(difflib.ndiff(d1, d2)) 92 | raise RuntimeError("acceptable: sorting schema failed:\n" + diff) 93 | return sorted_schema 94 | -------------------------------------------------------------------------------- /examples/django_app/django_app/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for django_app project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | 19 | # Quick-start development settings - unsuitable for production 20 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 21 | 22 | # SECURITY WARNING: keep the secret key used in production secret! 23 | SECRET_KEY = "testing" 24 | 25 | # SECURITY WARNING: don't run with debug turned on in production! 26 | DEBUG = True 27 | 28 | ALLOWED_HOSTS = ["*"] 29 | 30 | 31 | # Application definition 32 | 33 | INSTALLED_APPS = [ 34 | "django.contrib.admin", 35 | "django.contrib.auth", 36 | "django.contrib.contenttypes", 37 | "django.contrib.sessions", 38 | "django.contrib.messages", 39 | "django.contrib.staticfiles", 40 | "acceptable", 41 | ] 42 | 43 | MIDDLEWARE = [ 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.contrib.sessions.middleware.SessionMiddleware", 46 | "django.middleware.common.CommonMiddleware", 47 | "django.middleware.csrf.CsrfViewMiddleware", 48 | "django.contrib.auth.middleware.AuthenticationMiddleware", 49 | "django.contrib.messages.middleware.MessageMiddleware", 50 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 51 | ] 52 | 53 | ROOT_URLCONF = "django_app.views" 54 | 55 | TEMPLATES = [ 56 | { 57 | "BACKEND": "django.template.backends.django.DjangoTemplates", 58 | "DIRS": [], 59 | "APP_DIRS": True, 60 | "OPTIONS": { 61 | "context_processors": [ 62 | "django.template.context_processors.debug", 63 | "django.template.context_processors.request", 64 | "django.contrib.auth.context_processors.auth", 65 | "django.contrib.messages.context_processors.messages", 66 | ] 67 | }, 68 | } 69 | ] 70 | 71 | WSGI_APPLICATION = "django_app.wsgi.application" 72 | 73 | 74 | # Database 75 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 76 | 77 | DATABASES = { 78 | "default": { 79 | "ENGINE": "django.db.backends.sqlite3", 80 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 81 | } 82 | } 83 | 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": ( 91 | "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" 92 | ) 93 | }, 94 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, 95 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, 96 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, 97 | ] 98 | 99 | 100 | # Internationalization 101 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 102 | 103 | LANGUAGE_CODE = "en-us" 104 | 105 | TIME_ZONE = "UTC" 106 | 107 | USE_I18N = True 108 | 109 | USE_L10N = True 110 | 111 | USE_TZ = True 112 | 113 | 114 | # Static files (CSS, JavaScript, Images) 115 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 116 | 117 | STATIC_URL = "/static/" 118 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Version Next 2 | 3 | 4 | 5 | Version 0.42 6 | 7 | * Improve Openapi service descriptions 8 | 9 | Version 0.41 10 | 11 | * Add support for Python 3.12 12 | 13 | Version 0.40 14 | 15 | * Port test runner to pytest. 16 | * Fix compatibility issue with Werkezeug 3 related to deprecated ``request.charset``. 17 | * Drop service doubles and mocks, and ``acceptable doubles``. Use the OpenAPI spec with tools such as Connexion instead. 18 | 19 | Version 0.39 20 | 21 | * Assorted fixes and improvements in generated OpenAPI specs 22 | * Extend generated OpenAPI specs with support for query params (fixes #149) 23 | * Code cleanup with `flake8` 24 | * Reformat code with `isort` 25 | 26 | Version 0.38 27 | 28 | * Add new Django command ``openapi``. Generates ``openapi.yaml`` 29 | * Update format when a single endpoint supports multiple methods. 30 | * 📢 Please note! operationId now includes both the endpoint name and the method. For example, ``validation-get`` instead of ``validation``. This allows multiple methods on a single endpoint, such as ``validation-post`` and ``validation-get``. 31 | 32 | Version 0.37 33 | 34 | * Sort tags to stabilise YAML order 35 | * Fix OpenAPI spec output when a single endpoint supports multiple methods 36 | 37 | Version 0.36 38 | 39 | * Codebase is blackened 40 | * ``lint --create`` will also generate a valid but incomplete ``openapi.yaml``: 41 | 42 | * includes paths, methods, path parameters, request schema, response schema 43 | * operations include operationId, description and summary (if found) 44 | * group names are included as tags 45 | * version is given as 0.0.V where V is the version number 46 | 47 | Version 0.33 48 | 49 | * Drop support for Python < 3.8, so only Ubuntu 20.04 LTS and 22.04 LTS. 50 | 51 | Version 0.27 52 | 53 | * Improvements to the includable make file. 54 | 55 | Version 0.26 56 | 57 | * Adds an includable make file fragement. See README.rst. 58 | 59 | Version 0.25 60 | 61 | * Fix for v0.24 which couldn't be uploaded to PyPI due to README.rst parsing issue 62 | 63 | Version 0.24 64 | 65 | * ``acceptable doubles`` generates mocks from metadata; 66 | * ``acceptable doubles --new-style`` mocks with more features; 67 | * ``acceptable metadata --dummy-dependencies`` lets you extract metadata in a less complex and more reliable way. 68 | 69 | Version 0.23 70 | 71 | * Actuall order all changelog versions numerically, rather than rerelease an old version 72 | 73 | Version 0.22 74 | 75 | * Order all changelog versions numerically 76 | 77 | Version 0.21 78 | 79 | * Order versions numerically 80 | 81 | Version 0.20 82 | 83 | * APIs metadata and documentation rendered in groups, rather than individual 84 | API. This may require manually updating any api metadata files you use for 85 | linting. 86 | * Include module docstring in API group page 87 | * Can specifiy human friendly titles for apis and groups. 88 | * Fix url escaping in docs 89 | * Ordering of metadata now based on import order, rather than alphabetical 90 | 91 | Version 0.19 92 | 93 | * fix version bug in rendering 94 | * support deprecated apis 95 | 96 | Verison 0.18 97 | 98 | * Fix packaging bug that excluded django packages 99 | * More robust django form handling, with more rigorous tests 100 | 101 | Version 0.17 102 | 103 | * make acceptable a django app, for better django integration (#60) 104 | * support overriding the documentation templates/extension (#61, #62) 105 | 106 | Version 0.16 107 | 108 | * py2 support (#53) 109 | * Support same-file reference resolution when building doubles with AST (#57) 110 | * Make flask an optional dependency (#58) 111 | * initial django support (#59) 112 | 113 | Version 0.15 114 | 115 | * Sort changelogs properly in docs 116 | * Add global changelog 117 | * Support undocumented apis 118 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | acceptable 2 | ========== 3 | 4 | Acceptable is python tool to annotate and capture the metadata around your 5 | python web API. This metadata can be used for validation, documentation, 6 | testing and linting of your API code. 7 | 8 | It works standalone, or can be hooked into Flask (beta support for Django) web 9 | apps for richer integration. 10 | 11 | 12 | Design Goals: 13 | ------------- 14 | 15 | - Tightly couple code, metadata, and documentation to reduce drift and increase DRY. 16 | 17 | - Validation of JSON input and output 18 | 19 | - Provide tools for developers to make safe changes to APIs 20 | 21 | - Make it easy to generate API documentation. 22 | 23 | 24 | Usage 25 | ----- 26 | 27 | And example, for flask:: 28 | 29 | from acceptable import AcceptableService 30 | 31 | service = AcceptableService('example') 32 | 33 | foo_api = service.api('foo', '/foo', introduced_at=1, methods=['POST']) 34 | foo_api.request_schema = 35 | foo_api.response_schema = 36 | foo_api.changelog(3, 'Changed other thing') 37 | foo_api.changelog(2, 'Changed something') 38 | 39 | @foo_api 40 | def view(): 41 | ... 42 | 43 | You can use this metadata to bind the URL to a flask app:: 44 | 45 | from acceptable import get_metadata() 46 | app = Flask(__name__) 47 | get_metadata().bind_all(app) 48 | 49 | You can now generate API metadata like so:: 50 | 51 | acceptable metadata your.import.path > api.json 52 | 53 | This metadata can now be used to generate documentation, and provide API linting. 54 | 55 | 56 | Django 57 | ------ 58 | 59 | Note: Django support is very limited at the minute, and is mainly for documentation. 60 | 61 | Marking up the APIs themselves is a little different:: 62 | 63 | from acceptable import AcceptableService 64 | 65 | service = AcceptableService('example') 66 | 67 | # url is looked up from name, like reverse() 68 | foo_api = service.django_api('app:foo', introduced_at=1) 69 | foo_api.django_form = SomeForm 70 | foo_api.changelog(3, 'Changed other thing) 71 | foo_api.changelog(2, 'Changed something') 72 | 73 | @foo_api.handler 74 | class MyHandler(BaseHandler): 75 | allowed_methods=['POST'] 76 | ... 77 | 78 | Acceptable will generate a JSON schema representation of the form for documentation. 79 | 80 | To generate API metadata, you should add 'acceptable' to INSTALLED_APPS. This 81 | will provide an 'acceptable' management command:: 82 | 83 | ./manage.py acceptable metadata > api.json # generate metadata 84 | 85 | And also:: 86 | 87 | ./manage.py acceptable api-version api.json # inspect the current version 88 | 89 | 90 | Documentation (beta) 91 | -------------------- 92 | 93 | One of the goals of acceptable is to use the metadata about your API to build documentation. 94 | 95 | Once you have your metadata in JSON format, as above, you can transform that into markdown documentation:: 96 | 97 | acceptable render api.json --name 'My Service' 98 | 99 | You can do this in a single step:: 100 | 101 | acceptable metadata path/to/files*.py | acceptable render --name 'My Service' 102 | 103 | This markdown is designed to rendered to html by 104 | `documentation-builder `:: 105 | 106 | documentation-builder --base-directory docs 107 | 108 | 109 | Includable Makefile 110 | ------------------- 111 | 112 | *If you are using make files to automate your build you might find this useful.* 113 | 114 | The acceptable package contains a make file fragment that can be included to 115 | give you the following targets: 116 | 117 | - ``api-lint`` - Checks backward compatibility and version numbers; 118 | - ``api-update-metadata`` - Check like ``api-lint`` then update the saved metadata; 119 | - ``api-version`` - Print the saved metadata and current API version; 120 | - ``api-docs-markdown`` - Generates markdown documentation. 121 | 122 | The make file has variables for the following which you can override if 123 | needed: 124 | 125 | - ``ACCEPTABLE_ENV`` - The virtual environment with acceptable installed, it defaults to ``$(ENV)``. 126 | - ``ACCEPTABLE_METADATA`` - The saved metadata filename, it defaults to ``api.json``; 127 | - ``ACCEPTABLE_DOCS`` - The directory ``api-docs-markdown`` will generate documentation under, it defaults to ``docs``. 128 | 129 | You will need to create a saved metadata manually the first time using 130 | ``acceptable metadata`` command and saving it to the value of ``ACCEPTABLE_METADATA``. 131 | 132 | The make file assumes the following variables: 133 | 134 | - ``ACCEPTABLE_MODULES`` is a space separated list of modules containing acceptable annotated services; 135 | - ``ACCEPTABLE_SERVICE_TITLE`` is the title of the service used by ``api-docs-markdown``. 136 | 137 | ``ACCEPTABLE_SERVICE_TITLE`` should not be quoted e.g.:: 138 | 139 | ACCEPTABLE_SERVICE_TITLE := Title of the Service 140 | 141 | To include the file you'll need to get its path, if the above variables and 142 | conditions exist you can put this in your make file:: 143 | 144 | include $(shell $(ENV)/bin/python -c 'import pkg_resources; print(pkg_resources.resource_filename("acceptable", "make/Makefile.acceptable"))' 2> /dev/null) 145 | 146 | Development 147 | ----------- 148 | 149 | ``make test`` and ``make tox`` should run without errors. 150 | 151 | To run a single test module invoke:: 152 | 153 | env/bin/pytest acceptable/tests/test_module.py 154 | 155 | or:: 156 | 157 | tox -epy38 -- --test-suite acceptable.tests.test_module 158 | 159 | ...the latter runs "test_module" against Python 3.8 only. 160 | -------------------------------------------------------------------------------- /acceptable/tests/test_openapi.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | from collections import defaultdict 4 | from dataclasses import dataclass 5 | 6 | import testtools 7 | import yaml 8 | 9 | from acceptable import openapi 10 | from acceptable._service import ( 11 | AcceptableAPI, 12 | AcceptableService, 13 | APIMetadata, 14 | clear_metadata, 15 | ) 16 | 17 | 18 | def tearDownModule(): 19 | clear_metadata() 20 | 21 | 22 | @dataclass 23 | class SampleWithImplicitDunderDict(object): 24 | value: int = 42 25 | 26 | 27 | @dataclass 28 | class SampleWithToDictMethod(object): 29 | value: int = 42 30 | 31 | def _to_dict(self): 32 | return {"sample": self.value} 33 | 34 | 35 | class ToDictTests(testtools.TestCase): 36 | @staticmethod 37 | def test_convert_sample_with_to_dict_method_calls_method(): 38 | result = openapi._to_dict(SampleWithToDictMethod()) 39 | assert {"sample": 42} == result 40 | 41 | @staticmethod 42 | def test_convert_dict_returns_new_dict(): 43 | source = {"foo": "bar"} 44 | result = openapi._to_dict(source) 45 | assert source == result 46 | assert id(source) != id(result) 47 | 48 | @staticmethod 49 | def test_convert_defaultdict_returns_new_dict(): 50 | source = defaultdict(foo="bar") 51 | result = openapi._to_dict(source) 52 | assert source == result 53 | assert id(source) != id(result) 54 | 55 | @staticmethod 56 | def test_convert_list_returns_new_list(): 57 | source = ["fizz", "buzz"] 58 | result = openapi._to_dict(source) 59 | assert source == result 60 | assert id(source) != id(result) 61 | 62 | @staticmethod 63 | def test_convert_sample_with_dunder_dict_returns_dunder_value(): 64 | result = openapi._to_dict(SampleWithImplicitDunderDict()) 65 | assert {"value": 42} == result 66 | 67 | @staticmethod 68 | def test_convert_str_returns_same_value(): 69 | result = openapi._to_dict("beeblebrox") 70 | assert "beeblebrox" == result 71 | 72 | 73 | class ParameterExtractionTests(testtools.TestCase): 74 | def test_blank(self): 75 | url, parameters = openapi.extract_path_parameters("") 76 | assert url == "/" 77 | assert parameters == {} 78 | 79 | def test_no_parameters(self): 80 | url, parameters = openapi.extract_path_parameters("https://www.example.com") 81 | assert url == "https://www.example.com" 82 | assert parameters == {} 83 | 84 | def test_simple_parameter(self): 85 | url, parameters = openapi.extract_path_parameters( 86 | "https://www.example.com/" 87 | ) 88 | assert url == "https://www.example.com/{test}" 89 | assert parameters == {"test": "str"} 90 | 91 | def test_typed_parameter(self): 92 | url, parameters = openapi.extract_path_parameters( 93 | "https://www.example.com/" 94 | ) 95 | assert url == "https://www.example.com/{test}" 96 | assert parameters == {"test": "int"} 97 | 98 | def test_multiple_typed_parameters(self): 99 | url, parameters = openapi.extract_path_parameters( 100 | "https://www.example.com/..." 101 | ) 102 | assert url == "https://www.example.com/{test}...{test2}" 103 | assert parameters == {"test": "int", "test2": "float"} 104 | 105 | def test_ignore_bad_parameter(self): 106 | url, parameters = openapi.extract_path_parameters( 107 | "https://www.example.com/" 108 | ) 109 | assert url == "https://www.example.com/{test:int:float}" 110 | assert parameters == {} 111 | 112 | 113 | class EndpointToOperationTests(testtools.TestCase): 114 | @staticmethod 115 | def test_blank(): 116 | endpoint = AcceptableAPI( 117 | introduced_at=1, 118 | name="test-name", # maps to operation.operation_id 119 | service=AcceptableService(name="test service"), 120 | url="https://test.example", 121 | ) 122 | operation = openapi.convert_endpoint_to_operation(endpoint, "get", {}) 123 | assert operation.description is None 124 | assert "test-name-get" == operation.operation_id 125 | assert operation.summary is None 126 | assert 0 == len(operation.tags) 127 | 128 | @staticmethod 129 | def test_populated(): 130 | endpoint = AcceptableAPI( 131 | introduced_at=1, 132 | name="test-name", # maps to operation.operation_id 133 | service=AcceptableService( 134 | name="test service", group="test group" # maps to operation.tags 135 | ), 136 | title="test title", # maps to operation.summary 137 | url="https://test.example", 138 | ) 139 | endpoint.docs = "test docs" # maps to operation.description 140 | operation = openapi.convert_endpoint_to_operation(endpoint, "get", {}) 141 | assert "test docs" == operation.description 142 | assert "test-name-get" == operation.operation_id 143 | assert "test title" == operation.summary 144 | assert 1 == len(operation.tags) 145 | assert "test group" == operation.tags[0] 146 | 147 | 148 | class OpenApiTests(testtools.TestCase): 149 | def test_dump_of_empty_metadata(self): 150 | metadata = APIMetadata() 151 | result = openapi.dump(metadata).splitlines(keepends=True) 152 | with open("examples/oas_empty_expected.yaml", "r") as _expected: 153 | expected = _expected.readlines() 154 | self.assertListEqual(expected, result) 155 | 156 | def test_single_endpoint_with_multiple_methods(self): 157 | metadata = APIMetadata() 158 | service = AcceptableService("service", metadata=metadata) 159 | service.api("/foo", "get_foo", methods=["GET"]) 160 | service.api("/foo", "create_foo", methods=["POST"]) 161 | 162 | result = openapi.dump(metadata) 163 | spec = yaml.safe_load(result) 164 | 165 | assert list(spec["paths"].keys()) == ["/foo"] 166 | assert list(spec["paths"]["/foo"].keys()) == ["get", "post"] 167 | -------------------------------------------------------------------------------- /acceptable/djangoutil.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import logging 4 | 5 | import django 6 | from django.forms import fields, widgets 7 | 8 | from acceptable._service import AcceptableAPI 9 | from acceptable.util import clean_docstring, sort_schema 10 | 11 | logger = logging.getLogger("acceptable") 12 | _urlmap = None 13 | 14 | if django.VERSION >= (2, 0): 15 | from django.urls import URLPattern, URLResolver, get_resolver 16 | 17 | PATTERNS = (URLPattern,) 18 | RESOLVERS = (URLResolver,) 19 | 20 | def get_pattern(p): 21 | return str(p.pattern) 22 | 23 | else: 24 | try: 25 | from django.urls import ( 26 | LocaleRegexURLResolver, 27 | RegexURLPattern, 28 | RegexURLResolver, 29 | get_resolver, 30 | ) 31 | except ImportError: 32 | from django.core.urlresolvers import ( 33 | LocaleRegexURLResolver, 34 | RegexURLPattern, 35 | RegexURLResolver, 36 | get_resolver, 37 | ) 38 | 39 | PATTERNS = (RegexURLPattern,) 40 | RESOLVERS = (RegexURLResolver, LocaleRegexURLResolver) 41 | 42 | def get_pattern(p): 43 | return p.regex.pattern 44 | 45 | 46 | def get_urlmap(): 47 | global _urlmap 48 | if _urlmap is None: 49 | resolver = get_resolver() 50 | _urlmap = dict(urlmap(resolver.url_patterns)) 51 | return _urlmap 52 | 53 | 54 | def urlmap(patterns): 55 | """Recursively build a map of (group, name) => url patterns. 56 | 57 | Group is either the resolver namespace or app name for the url config. 58 | 59 | The urls are joined with any prefixes, and cleaned up of extraneous regex 60 | specific syntax.""" 61 | for pattern in patterns: 62 | group = getattr(pattern, "namespace", None) 63 | if group is None: 64 | group = getattr(pattern, "app_name", None) 65 | path = "/" + get_pattern(pattern).lstrip("^").rstrip("$") 66 | if isinstance(pattern, PATTERNS): 67 | yield (group, pattern.name), path 68 | elif isinstance(pattern, RESOLVERS): 69 | subpatterns = pattern.url_patterns 70 | for (_, name), subpath in urlmap(subpatterns): 71 | yield (group, name), path.rstrip("/") + subpath 72 | 73 | 74 | def get_field_schema(name, field): 75 | """Returns a JSON Schema representation of a form field.""" 76 | field_schema = {"type": "string"} 77 | 78 | if field.label: 79 | field_schema["title"] = str(field.label) # force translation 80 | 81 | if field.help_text: 82 | field_schema["description"] = str(field.help_text) # force translation 83 | 84 | if isinstance(field, (fields.URLField, fields.FileField)): 85 | field_schema["format"] = "uri" 86 | elif isinstance(field, fields.EmailField): 87 | field_schema["format"] = "email" 88 | elif isinstance(field, fields.DateTimeField): 89 | field_schema["format"] = "date-time" 90 | elif isinstance(field, fields.DateField): 91 | field_schema["format"] = "date" 92 | elif isinstance(field, (fields.DecimalField, fields.FloatField)): 93 | field_schema["type"] = "number" 94 | elif isinstance(field, fields.IntegerField): 95 | field_schema["type"] = "integer" 96 | elif isinstance(field, fields.NullBooleanField): 97 | field_schema["type"] = "boolean" 98 | elif isinstance(field.widget, widgets.CheckboxInput): 99 | field_schema["type"] = "boolean" 100 | 101 | if getattr(field, "choices", []): 102 | field_schema["enum"] = sorted([choice[0] for choice in field.choices]) 103 | 104 | # check for multiple values 105 | if isinstance(field.widget, (widgets.Select, widgets.ChoiceWidget)): 106 | if field.widget.allow_multiple_selected: 107 | # promote to array of , move details into the items field 108 | field_schema["items"] = {"type": field_schema["type"]} 109 | if "enum" in field_schema: 110 | field_schema["items"]["enum"] = field_schema.pop("enum") 111 | field_schema["type"] = "array" 112 | 113 | return field_schema 114 | 115 | 116 | def get_form_schema(form): 117 | """Return a JSON Schema object for a Django Form.""" 118 | schema = {"type": "object", "properties": {}} 119 | 120 | for name, field in form.base_fields.items(): 121 | schema["properties"][name] = get_field_schema(name, field) 122 | if field.required: 123 | schema.setdefault("required", []).append(name) 124 | 125 | return schema 126 | 127 | 128 | class DjangoAPI(AcceptableAPI): 129 | """Django-flavour API metadata 130 | 131 | Supports setting a Django form to provide json schema for documentation, as 132 | well providing an API handler class to inspect for more metadata. 133 | """ 134 | 135 | def __init__( 136 | self, 137 | service, 138 | name, 139 | introduced_at, 140 | options={}, 141 | location=None, 142 | undocumented=False, 143 | deprecated_at=None, 144 | title=None, 145 | ): 146 | # leave url blank, as we can't know it until django has set itself up 147 | # properly 148 | super().__init__( 149 | service, 150 | name, 151 | None, 152 | introduced_at, 153 | options, 154 | location, 155 | undocumented, 156 | deprecated_at, 157 | title, 158 | ) 159 | 160 | self._form = None 161 | self.handler_class = None 162 | 163 | def resolve_url(self): 164 | name = self.name 165 | try_default = True 166 | 167 | if ":" in self.name: 168 | group, name = self.name.split(":", 2) 169 | try_default = False # user passed explicit group, just use that 170 | else: 171 | group = self.service.group 172 | 173 | urlmap = get_urlmap() 174 | url = urlmap.get((group, name)) 175 | if url is None and try_default: 176 | url = urlmap.get((None, name)) 177 | # TODO should we error out if there is no map with that name? 178 | return url 179 | 180 | @property 181 | def methods(self): 182 | default = ["GET"] 183 | if "methods" in self.options: 184 | return list(self.options.get("methods", default)) 185 | 186 | # allowed_methods works for piston handlers 187 | # TODO: add support for DRF? And maybe plain view functions with 188 | # decorators? 189 | return list(getattr(self.handler_class, "allowed_methods", default)) 190 | 191 | @property 192 | def django_form(self): 193 | return self._form 194 | 195 | @django_form.setter 196 | def django_form(self, form): 197 | self._form = form 198 | schema = get_form_schema(form) 199 | self.request_schema = sort_schema(schema) 200 | 201 | def handler(self, handler_class): 202 | """Link to an API handler class (e.g. piston or DRF).""" 203 | self.handler_class = handler_class 204 | # we take the docstring from the handler class, not the methods 205 | if self.docs is None and handler_class.__doc__: 206 | self.docs = clean_docstring(handler_class.__doc__) 207 | return handler_class 208 | -------------------------------------------------------------------------------- /acceptable/openapi.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers to translate acceptable metadata to OpenAPI specifications (OAS). 3 | """ 4 | import json 5 | import logging 6 | import re 7 | from collections import defaultdict 8 | from dataclasses import dataclass, field 9 | from typing import Any, Tuple 10 | 11 | import yaml 12 | 13 | from acceptable._service import AcceptableAPI, APIMetadata 14 | 15 | 16 | @dataclass 17 | class OasOperation: 18 | tags: list 19 | summary: str 20 | description: str 21 | operation_id: str 22 | request_schema: Any 23 | response_schema: Any 24 | path_parameters: dict 25 | query_parameters: dict 26 | 27 | def _parameters_to_openapi(self): 28 | # To ensure a stable output we sort the parameter dictionary. 29 | for key, value in sorted(self.path_parameters.items()): 30 | yield { 31 | "name": key, 32 | "in": "path", 33 | "required": True, 34 | "schema": {"type": value}, 35 | } 36 | for key, value in sorted(self.query_parameters.get("properties", {}).items()): 37 | yield { 38 | "name": key, 39 | "in": "query", 40 | "required": key in self.query_parameters.get("required", {}), 41 | "schema": value, 42 | } 43 | 44 | def _to_dict(self): 45 | result = { 46 | "tags": self.tags, 47 | "description": self.description or "None.", 48 | "operationId": self.operation_id, 49 | "parameters": list(self._parameters_to_openapi()), 50 | } 51 | 52 | if self.summary: 53 | result["summary"] = self.summary 54 | 55 | if self.request_schema: 56 | result["requestBody"] = { 57 | "content": {"application/json": {"schema": self.request_schema}} 58 | } 59 | 60 | result["responses"] = {"200": {"description": self.summary or "OK"}} 61 | if self.response_schema: 62 | result["responses"]["200"]["content"] = { 63 | "application/json": {"schema": self.response_schema} 64 | } 65 | 66 | return result 67 | 68 | 69 | @dataclass 70 | class OasInfo: 71 | description: str = "" 72 | version: str = "" 73 | title: str = "" 74 | contact: dict = field( 75 | default_factory=lambda: {"name": "", "email": "example@example.example"} 76 | ) 77 | 78 | 79 | @dataclass 80 | class OasRoot31: 81 | openapi: str = "3.1.0" 82 | info: OasInfo = field(default_factory=OasInfo) 83 | tags: list = field(default_factory=lambda: []) 84 | servers: list = field(default_factory=lambda: [{"url": "http://localhost"}]) 85 | paths: dict = field(default_factory=lambda: defaultdict(dict)) 86 | 87 | def _to_dict(self): 88 | return { 89 | "openapi": self.openapi, 90 | "info": _to_dict(self.info), 91 | "servers": _to_dict(self.servers), 92 | "tags": _to_dict(self.tags), 93 | "paths": _to_dict(self.paths), 94 | } 95 | 96 | 97 | def _to_dict(source: Any): 98 | if hasattr(source, "_to_dict"): 99 | return source._to_dict() # noqa 100 | elif isinstance(source, dict): 101 | return {key: _to_dict(value) for key, value in source.items()} 102 | elif type(source) is list: 103 | return [_to_dict(value) for value in source] 104 | elif hasattr(source, "__dict__"): 105 | return {key: _to_dict(value) for key, value in source.__dict__.items()} 106 | else: 107 | return source 108 | 109 | 110 | def convert_endpoint_to_operation( 111 | endpoint: AcceptableAPI, method: str, path_parameters: dict 112 | ): 113 | _request_schema = None 114 | if endpoint.request_schema: 115 | _request_schema = json.loads(json.dumps(endpoint.request_schema)) 116 | 117 | _response_schema = None 118 | if endpoint.response_schema: 119 | _response_schema = json.loads(json.dumps(endpoint.response_schema)) 120 | 121 | query_parameters = {} 122 | if endpoint.params_schema: 123 | query_parameters = json.loads(json.dumps(endpoint.params_schema)) 124 | 125 | return OasOperation( 126 | tags=[endpoint.service.group] if endpoint.service.group else [], 127 | summary=endpoint.title, 128 | description=endpoint.docs, 129 | operation_id=f"{endpoint.name}-{method}", 130 | path_parameters=path_parameters, 131 | query_parameters=query_parameters, 132 | request_schema=_request_schema, 133 | response_schema=_response_schema, 134 | ) 135 | 136 | 137 | def extract_path_parameters(url: str) -> Tuple[str, dict]: 138 | # convert URL from metadata-style to openapi-style 139 | if url is None or url == "": 140 | url = "/" 141 | url = url.replace("<", "{").replace(">", "}") 142 | 143 | # get individual instances of `{...}` 144 | raw_parameters = set(re.findall(r"\{[^}]*}", url)) 145 | # originally the simpler r"\{.*?}" but SonarLint tells me this approach is safer 146 | # it translates as open-curly, zero-or-more-not-close-curly, close-curly 147 | # https://rules.sonarsource.com/python/type/Code%20Smell/RSPEC-5857 148 | 149 | # extract types from parameters, then 150 | # re-insert parameters into openapi-style url 151 | parameters = {} 152 | for raw in raw_parameters: 153 | p = raw[1:-1] 154 | c = p.count(":") 155 | 156 | # skip duplicate parameter names 157 | if p in parameters.keys(): 158 | continue 159 | 160 | # if no type is defined, use str 161 | if c == 0: 162 | parameters[p] = "str" 163 | 164 | # if type is defined, use that 165 | elif c == 1: 166 | [_param, _type] = p.split(":") 167 | parameters[_param] = _type 168 | url = url.replace(raw, "{" + _param + "}") 169 | 170 | # otherwise, skip badly formed parameters 171 | else: 172 | continue 173 | 174 | return url, parameters 175 | 176 | 177 | def dump(metadata: APIMetadata, stream=None): 178 | oas = OasRoot31() 179 | 180 | _title = "None" 181 | try: 182 | [_title] = list(metadata.services.keys()) 183 | except (TypeError, ValueError): 184 | logging.warning( 185 | "Could not extract service title from metadata. " 186 | "Expected exactly one valid title." 187 | ) 188 | finally: 189 | oas.info.title = _title 190 | oas.info.description = _title 191 | 192 | oas.info.version = "0.0." + str(metadata.current_version) 193 | tags = set() 194 | 195 | for service_group in metadata.services.values(): 196 | for api_group in service_group.values(): 197 | for endpoint in api_group.values(): 198 | for method in endpoint.methods: 199 | method = str.lower(method) 200 | tidy_url, path_parameters = extract_path_parameters(endpoint.url) 201 | operation = convert_endpoint_to_operation( 202 | endpoint, method, path_parameters 203 | ) 204 | tags.update(set(operation.tags)) 205 | oas.paths[tidy_url][method] = operation 206 | 207 | for tag in sorted(tags): 208 | oas.tags.append({"name": tag}) 209 | 210 | return yaml.safe_dump( 211 | _to_dict(oas), stream, default_flow_style=False, encoding=None 212 | ) 213 | -------------------------------------------------------------------------------- /acceptable/tests/test_validation.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import json 4 | 5 | import flask 6 | import jsonschema 7 | from fixtures import Fixture 8 | from testtools import TestCase 9 | from testtools.matchers import StartsWith 10 | 11 | from acceptable._validation import DataValidationError, validate_body, validate_output 12 | 13 | 14 | class FlaskValidateBodyFixture(Fixture): 15 | def __init__(self, body_schema=None, output_schema=None, view_fn=None): 16 | if not (body_schema or output_schema): 17 | raise ValueError( 18 | "Must specify at least one of body_schema or output_schema" 19 | ) 20 | self.body_schema = body_schema 21 | self.output_schema = output_schema 22 | self.view = view_fn 23 | 24 | def _setUp(self): 25 | self.app = flask.Flask(__name__) 26 | self.app.testing = True 27 | 28 | def _default_view(): 29 | return "OK", 200 30 | 31 | self.view = self.view or _default_view 32 | 33 | if self.body_schema: 34 | self.view = validate_body(self.body_schema)(self.view) 35 | if self.output_schema: 36 | self.view = validate_output(self.output_schema)(self.view) 37 | 38 | self.app.route("/", methods=["POST"])(self.view) 39 | self.client = self.app.test_client() 40 | 41 | def post_json(self, json_payload): 42 | return self.client.post( 43 | "/", 44 | data=json.dumps(json_payload), 45 | headers={"Content-Type": "application/json"}, 46 | ) 47 | 48 | 49 | class ValidateBodyTests(TestCase): 50 | def test_raises_on_bad_schema(self): 51 | def fn(): 52 | pass 53 | 54 | self.assertRaises( 55 | jsonschema.SchemaError, validate_body({"required": "bar"}), fn 56 | ) 57 | 58 | def test_passes_on_good_payload(self): 59 | app = self.useFixture(FlaskValidateBodyFixture({"type": "object"})) 60 | 61 | resp = app.post_json(dict(foo="bar")) 62 | self.assertEqual(200, resp.status_code) 63 | 64 | def test_raises_on_bad_payload(self): 65 | app = self.useFixture(FlaskValidateBodyFixture({"type": "object"})) 66 | 67 | e = self.assertRaises(DataValidationError, app.post_json, []) 68 | msg = "[] is not of type 'object' at /" 69 | self.assertEqual([msg], e.error_list) 70 | 71 | def test_raises_on_invalid_json(self): 72 | app = self.useFixture(FlaskValidateBodyFixture({"type": "object"})) 73 | 74 | e = self.assertRaises( 75 | DataValidationError, 76 | app.client.post, 77 | "/", 78 | data="invalid json", 79 | headers={"Content-Type": "application/json"}, 80 | ) 81 | # Python 3.3 json decode errors have a different format from later 82 | # versions, so this check isn't as explicit as I'd like. 83 | self.assertThat( 84 | e.error_list[0], StartsWith("Error decoding JSON request body: ") 85 | ) 86 | 87 | def test_validates_even__on_wrong_mimetype(self): 88 | app = self.useFixture(FlaskValidateBodyFixture({"type": "object"})) 89 | 90 | resp = app.client.post("/", data="{}", headers={"Content-Type": "text/plain"}) 91 | self.assertEqual(200, resp.status_code) 92 | 93 | def test_validates_even_on_missing_mimetype(self): 94 | app = self.useFixture(FlaskValidateBodyFixture({"type": "object"})) 95 | 96 | resp = app.client.post("/", data="{}", headers={}) 97 | self.assertEqual(200, resp.status_code) 98 | 99 | 100 | class ValidateOutputTests(TestCase): 101 | def test_raises_on_bad_schema(self): 102 | def fn(): 103 | pass 104 | 105 | self.assertRaises( 106 | jsonschema.SchemaError, validate_output({"required": "bar"}), fn 107 | ) 108 | 109 | def test_raises_on_bad_payload(self): 110 | def view(): 111 | return [] 112 | 113 | app = self.useFixture( 114 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 115 | ) 116 | 117 | e = self.assertRaises(AssertionError, app.post_json, []) 118 | 119 | msg = "[] is not of type 'object' at /" 120 | 121 | self.assertEqual( 122 | "Response does not comply with output schema: %r.\n%s" % ([msg], []), str(e) 123 | ) 124 | 125 | def test_raises_on_unknown_response_type(self): 126 | def view(): 127 | return object() 128 | 129 | app = self.useFixture( 130 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 131 | ) 132 | e = self.assertRaises(ValueError, app.post_json, {}) 133 | self.assertIn("Unknown response type", str(e)) 134 | self.assertIn("Supported types are list and dict.", str(e)) 135 | 136 | def test_skips_validation_if_disabled(self): 137 | def view(): 138 | return [] 139 | 140 | app = self.useFixture( 141 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 142 | ) 143 | 144 | # Output validation is enabled by default. 145 | self.assertRaises(AssertionError, app.post_json, []) 146 | 147 | # But if disabled then the app is trusted to return valid data. 148 | app.app.config["ACCEPTABLE_VALIDATE_OUTPUT"] = False 149 | self.assertEqual(b"[]\n", app.post_json([]).data) 150 | 151 | # It can also be explicitly enabled. 152 | app.app.config["ACCEPTABLE_VALIDATE_OUTPUT"] = True 153 | self.assertRaises(AssertionError, app.post_json, []) 154 | 155 | def assertResponseJsonEqual(self, response, expected_json): 156 | charset = response.mimetype_params.get("charset", "utf-8") 157 | data = json.loads(response.data.decode(charset)) 158 | self.assertEqual(expected_json, data) 159 | 160 | def test_passes_on_good_payload_single_return_parameter(self): 161 | returned_payload = {"foo": "bar"} 162 | 163 | def view(): 164 | return returned_payload 165 | 166 | app = self.useFixture( 167 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 168 | ) 169 | resp = app.post_json({}) 170 | self.assertEqual(200, resp.status_code) 171 | self.assertResponseJsonEqual(resp, returned_payload) 172 | 173 | def test_passes_on_good_payload_double_return_parameter(self): 174 | returned_payload = {"foo": "bar"} 175 | 176 | def view(): 177 | return returned_payload, 201 178 | 179 | app = self.useFixture( 180 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 181 | ) 182 | resp = app.post_json({}) 183 | self.assertEqual(201, resp.status_code) 184 | self.assertResponseJsonEqual(resp, returned_payload) 185 | 186 | def test_passes_on_good_payload_triple_return_parameter(self): 187 | returned_payload = {"foo": "bar"} 188 | 189 | def view(): 190 | return returned_payload, 201, {"Custom-Header": "Foo"} 191 | 192 | app = self.useFixture( 193 | FlaskValidateBodyFixture(output_schema={"type": "object"}, view_fn=view) 194 | ) 195 | resp = app.post_json({}) 196 | self.assertEqual(201, resp.status_code) 197 | self.assertResponseJsonEqual(resp, returned_payload) 198 | self.assertEqual("Foo", resp.headers["Custom-Header"]) 199 | 200 | 201 | class DeltaValidationErrorTests(TestCase): 202 | def test_repr_and_str(self): 203 | e = DataValidationError(["error one", "error two"]) 204 | self.assertEqual("DataValidationError: error one, error two", str(e)) 205 | self.assertEqual(str(e), repr(e)) 206 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /acceptable/lint.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import os 4 | from enum import IntEnum 5 | 6 | 7 | class LintTypes(IntEnum): 8 | WARNING = 0 9 | DOCUMENTATION = 1 10 | ERROR = 2 11 | 12 | 13 | # shortcuts 14 | WARNING = LintTypes.WARNING 15 | DOCUMENTATION = LintTypes.DOCUMENTATION 16 | ERROR = LintTypes.ERROR 17 | 18 | 19 | class Message: 20 | """A linter message to the user.""" 21 | 22 | level = None 23 | 24 | def __init__(self, name, msg, *args, **kwargs): 25 | self.name = name 26 | self.location = kwargs.pop("location", None) 27 | self.api_name = kwargs.pop("api_name", None) 28 | self.msg = msg.format(*args, **kwargs) 29 | 30 | def __str__(self): 31 | output = "{}: API {} at {}: {}".format( 32 | self.level.name.title(), self.api_name, self.name, self.msg 33 | ) 34 | 35 | if self.location is None: 36 | return output 37 | else: 38 | return "{}:{}: {}".format( 39 | os.path.relpath(self.location["filename"]), 40 | self.location["lineno"], 41 | output, 42 | ) 43 | 44 | 45 | class LintError(Message): 46 | level = ERROR 47 | 48 | 49 | class LintWarning(Message): 50 | level = WARNING 51 | 52 | 53 | class LintFixit(Message): 54 | level = DOCUMENTATION 55 | 56 | 57 | class CheckChangelog(Message): 58 | def __init__(self, name, revision): 59 | super().__init__(name, "") 60 | self.revision = revision 61 | 62 | 63 | def metadata_lint(old, new, locations): 64 | """Run the linter over the new metadata, comparing to the old.""" 65 | # ensure we don't modify the metadata 66 | old = old.copy() 67 | new = new.copy() 68 | # remove version info 69 | old_introduced_at = old.pop("$version", None) 70 | new.pop("$version", None) 71 | 72 | for old_group_name in old: 73 | if old_group_name not in new: 74 | yield LintError("", "api group removed", api_name=old_group_name) 75 | 76 | for group_name, new_group in new.items(): 77 | old_group = old.get(group_name, {"apis": {}}) 78 | for name, api in new_group["apis"].items(): 79 | old_api = old_group["apis"].get(name, {}) 80 | api_locations = locations[name] 81 | for message in lint_api( 82 | name, old_api, api, api_locations, old_introduced_at 83 | ): 84 | message.api_name = name 85 | if message.location is None: 86 | message.location = api_locations["api"] 87 | yield message 88 | 89 | 90 | def lint_api(api_name, old, new, locations, old_introduced_at): 91 | """Lint an acceptable api metadata.""" 92 | is_new_api = not old 93 | api_location = locations["api"] 94 | changelog = new.get("changelog", {}) 95 | changelog_location = api_location 96 | 97 | if locations["changelog"]: 98 | changelog_location = list(locations["changelog"].values())[0] 99 | 100 | # apis must have documentation if they are new 101 | if not new.get("doc"): 102 | msg_type = LintError if is_new_api else LintWarning 103 | yield msg_type( 104 | "doc", 105 | "missing docstring documentation", 106 | api_name=api_name, 107 | location=locations.get("view", api_location), 108 | ) 109 | 110 | introduced_at = new.get("introduced_at") 111 | if introduced_at is None: 112 | yield LintError( 113 | "introduced_at", "missing introduced_at field", location=api_location 114 | ) 115 | 116 | if is_new_api: 117 | if not isinstance(introduced_at, int): 118 | yield LintError( 119 | "introduced_at", 120 | "introduced_at should be an integer", 121 | location=api_location, 122 | ) 123 | if old_introduced_at is not None and introduced_at <= old_introduced_at: 124 | yield LintError( 125 | "introduced_at", 126 | "introduced_at should be > {}".format(old_introduced_at), 127 | location=api_location, 128 | ) 129 | 130 | if not is_new_api: 131 | # cannot change introduced_at if we already have it 132 | old_introduced_at = old.get("introduced_at") 133 | if old_introduced_at is not None: 134 | if old_introduced_at != introduced_at: 135 | yield LintError( 136 | "introduced_at", 137 | "introduced_at changed from {} to {}", 138 | old_introduced_at, 139 | introduced_at, 140 | api_name=api_name, 141 | location=api_location, 142 | ) 143 | 144 | # cannot change url 145 | if new["url"] != old.get("url", new["url"]): 146 | yield LintError( 147 | "url", 148 | "url changed from {} to {}", 149 | old["url"], 150 | new["url"], 151 | api_name=api_name, 152 | location=api_location, 153 | ) 154 | 155 | # cannot add required fields 156 | for removed in set(old.get("methods", [])) - set(new["methods"]): 157 | yield LintError( 158 | "methods", 159 | "HTTP method {} removed", 160 | removed, 161 | api_name=api_name, 162 | location=api_location, 163 | ) 164 | 165 | for schema in ["request_schema", "response_schema"]: 166 | new_schema = new.get(schema) 167 | if new_schema is None: 168 | if old.get(schema) is not None: 169 | yield LintError( 170 | schema, 171 | "{} schema removed", 172 | schema.split("_")[0].title(), 173 | api_name=api_name, 174 | location=api_location, 175 | ) 176 | continue 177 | 178 | schema_location = locations[schema] 179 | old_schema = old.get(schema) or {} 180 | 181 | for message in walk_schema( 182 | schema, old_schema, new_schema, root=True, new_api=is_new_api 183 | ): 184 | if isinstance(message, CheckChangelog): 185 | if message.revision not in changelog: 186 | yield LintFixit( 187 | message.name, 188 | "No changelog entry for revision {}", 189 | message.revision, 190 | location=changelog_location, 191 | ) 192 | else: 193 | # add in here, saves passing it down the recursive call 194 | message.location = schema_location 195 | yield message 196 | 197 | 198 | def get_schema_types(schema): 199 | schema_type = schema.get("type") 200 | if schema_type is None: 201 | return [] 202 | elif isinstance(schema_type, (str, bytes)): 203 | return [schema_type] 204 | else: 205 | return schema_type 206 | 207 | 208 | def check_custom_attrs(name, old, new, new_api=False): 209 | # these are our custom schema properties, not in the jsonschema 210 | # standard, and we don't need them on the root schema object, as that 211 | # takes its doc from the function docstring and its introduced_at from 212 | # the api definition 213 | if "description" not in new: 214 | yield LintWarning(name + ".description", "missing description field") 215 | 216 | # New field on existing api without introduced_at 217 | if not new_api and not old and new.get("introduced_at") is None: 218 | yield LintFixit("{}.introduced_at".format(name), "missing introduced_at field") 219 | # Existing field but introduced_at has changed 220 | elif ( 221 | not new_api 222 | and old.get("introduced_at") is not None 223 | and old.get("introduced_at") != new.get("introduced_at") 224 | ): 225 | yield LintError( 226 | "{}.introduced_at".format(name), 227 | "introduced_at changed from {} to {}", 228 | old.get("introduced_at"), 229 | new.get("introduced_at"), 230 | ) 231 | # Check introduced_at has a change log entry 232 | elif new.get("introduced_at") is not None: 233 | yield CheckChangelog(name, new.get("introduced_at")) 234 | 235 | 236 | def walk_schema(name, old, new, root=False, new_api=False): 237 | if not root: 238 | for i in check_custom_attrs(name, old, new, new_api): 239 | yield i 240 | 241 | types = get_schema_types(new) 242 | old_types = get_schema_types(old) 243 | for removed in set(old_types) - set(types): 244 | yield LintError(name + ".type", "cannot remove type {} from field", removed) 245 | 246 | # you cannot add new required fields to an existing API. 247 | if not new_api: 248 | old_required = old.get("required", []) 249 | for removed in set(new.get("required", [])) - set(old_required): 250 | yield LintError(name + ".required", "Cannot require new field {}", removed) 251 | 252 | if "object" in types: 253 | properties = new.get("properties", {}) 254 | old_properties = old.get("properties", {}) 255 | 256 | for deleted in set(old_properties) - set(properties): 257 | yield LintError(name + "." + deleted, "cannot delete field {}", deleted) 258 | 259 | for prop, value in sorted(properties.items()): 260 | for i in walk_schema( 261 | name + "." + prop, old_properties.get(prop, {}), value, new_api=new_api 262 | ): 263 | yield i 264 | 265 | if "array" in types and "items" in new: 266 | for i in walk_schema( 267 | name + ".items", old.get("items", {}), new["items"], new_api=new_api 268 | ): 269 | yield i 270 | -------------------------------------------------------------------------------- /acceptable/_validation.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2020 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import functools 4 | import json 5 | 6 | import jsonschema 7 | 8 | from acceptable.util import get_callsite_location, sort_schema 9 | 10 | 11 | class DataValidationError(Exception): 12 | """Raises when a request body fails validation.""" 13 | 14 | def __init__(self, error_list): 15 | self.error_list = error_list 16 | 17 | def __repr__(self): 18 | return "DataValidationError: %s" % ", ".join(self.error_list) 19 | 20 | def __str__(self): 21 | return repr(self) 22 | 23 | 24 | def validate_params(schema): 25 | """Validate the request parameters. 26 | 27 | The request parameters (request.args) are validated against the schema. 28 | 29 | The root of the schema should be an object and each of its properties 30 | is a parameter. 31 | 32 | An example usage might look like this:: 33 | 34 | from snapstore_schemas import validate_params 35 | 36 | 37 | @validate_params({ 38 | "type": "object", 39 | "properties": { 40 | "id": { 41 | "type": "string", 42 | "description": "A test property.", 43 | "pattern": "[0-9A-F]{8}", 44 | } 45 | }, 46 | required: ["id"] 47 | }) 48 | def my_flask_view(): 49 | ... 50 | 51 | """ 52 | location = get_callsite_location() 53 | 54 | def decorator(fn): 55 | validate_schema(schema) 56 | wrapper = wrap_request_params(fn, schema) 57 | record_schemas(fn, wrapper, location, params_schema=sort_schema(schema)) 58 | return wrapper 59 | 60 | return decorator 61 | 62 | 63 | def wrap_request_params(fn, schema): 64 | from flask import request 65 | 66 | @functools.wraps(fn) 67 | def wrapper(*args, **kwargs): 68 | error_list = validate(request.args, schema) 69 | if error_list: 70 | raise DataValidationError(error_list) 71 | return fn(*args, **kwargs) 72 | 73 | return wrapper 74 | 75 | 76 | def validate_body(schema): 77 | """Validate the body of incoming requests for a flask view. 78 | 79 | An example usage might look like this:: 80 | 81 | from snapstore_schemas import validate_body 82 | 83 | 84 | @validate_body({ 85 | 'type': 'array', 86 | 'items': { 87 | 'type': 'object', 88 | 'properties': { 89 | 'snap_id': {'type': 'string'}, 90 | 'series': {'type': 'string'}, 91 | 'name': {'type': 'string'}, 92 | 'title': {'type': 'string'}, 93 | 'keywords': { 94 | 'type': 'array', 95 | 'items': {'type': 'string'} 96 | }, 97 | 'summary': {'type': 'string'}, 98 | 'description': {'type': 'string'}, 99 | 'created_at': {'type': 'string'}, 100 | }, 101 | 'required': ['snap_id', 'series'], 102 | 'additionalProperties': False 103 | } 104 | }) 105 | def my_flask_view(): 106 | # view code here 107 | return "Hello World", 200 108 | 109 | All incoming request that have been routed to this view will be matched 110 | against the specified schema. If the request body does not match the schema 111 | an instance of `DataValidationError` will be raised. 112 | 113 | By default this will cause the flask application to return a 500 response, 114 | but this can be customised by telling flask how to handle these exceptions. 115 | The exception instance has an 'error_list' attribute that contains a list 116 | of all the errors encountered while processing the request body. 117 | """ 118 | location = get_callsite_location() 119 | 120 | def decorator(fn): 121 | validate_schema(schema) 122 | wrapper = wrap_request(fn, schema) 123 | record_schemas(fn, wrapper, location, request_schema=sort_schema(schema)) 124 | return wrapper 125 | 126 | return decorator 127 | 128 | 129 | def wrap_request(fn, schema): 130 | from flask import request 131 | 132 | @functools.wraps(fn) 133 | def wrapper(*args, **kwargs): 134 | payload = request.get_json(silent=True, cache=True, force=True) 135 | # If flask can't parse the payload, we want to return a sensible 136 | # error message, so we try and parse it ourselves. Setting silent 137 | # to False above isn't good enough, as the generated error message 138 | # is not informative enough. 139 | if payload is None: 140 | try: 141 | charset = request.mimetype_params.get("charset", "utf-8") 142 | payload = json.loads(request.data.decode(charset)) 143 | except ValueError as e: 144 | raise DataValidationError( 145 | ["Error decoding JSON request body: %s" % str(e)] 146 | ) 147 | error_list = validate(payload, schema) 148 | if error_list: 149 | raise DataValidationError(error_list) 150 | return fn(*args, **kwargs) 151 | 152 | return wrapper 153 | 154 | 155 | def record_schemas( 156 | fn, wrapper, location, request_schema=None, response_schema=None, params_schema=None 157 | ): 158 | """Support extracting the schema from the decorated function.""" 159 | # have we already been decorated by an acceptable api call? 160 | has_acceptable = hasattr(fn, "_acceptable_metadata") 161 | 162 | if params_schema is not None: 163 | wrapper._params_schema = params_schema 164 | wrapper._params_schema_location = location 165 | if has_acceptable: 166 | fn._acceptable_metadata._params_schema = params_schema 167 | fn._acceptable_metadata._params_schema_location = location 168 | 169 | if request_schema is not None: 170 | # preserve schema for later use 171 | wrapper._request_schema = wrapper._request_schema = request_schema 172 | wrapper._request_schema_location = location 173 | if has_acceptable: 174 | fn._acceptable_metadata._request_schema = request_schema 175 | fn._acceptable_metadata._request_schema_location = location 176 | 177 | if response_schema is not None: 178 | # preserve schema for later use 179 | wrapper._response_schema = wrapper._response_schema = response_schema 180 | wrapper._response_schema_location = location 181 | if has_acceptable: 182 | fn._acceptable_metadata._response_schema = response_schema 183 | fn._acceptable_metadata._response_schema_location = location 184 | 185 | 186 | def validate_output(schema): 187 | """Validate the body of a response from a flask view. 188 | 189 | Like `validate_body`, this function compares a json document to a 190 | jsonschema specification. However, this function applies the schema to the 191 | view response. 192 | 193 | Instead of the view returning a flask response object, it should instead 194 | return a Python list or dictionary. For example:: 195 | 196 | from snapstore_schemas import validate_output 197 | 198 | @validate_output({ 199 | 'type': 'object', 200 | 'properties': { 201 | 'ok': {'type': 'boolean'}, 202 | }, 203 | 'required': ['ok'], 204 | 'additionalProperties': False 205 | } 206 | def my_flask_view(): 207 | # view code here 208 | return {'ok': True} 209 | 210 | Every view response will be evaluated against the schema. Any that do not 211 | comply with the schema will cause DataValidationError to be raised. 212 | """ 213 | location = get_callsite_location() 214 | 215 | def decorator(fn): 216 | validate_schema(schema) 217 | wrapper = wrap_response(fn, schema) 218 | record_schemas(fn, wrapper, location, response_schema=sort_schema(schema)) 219 | return wrapper 220 | 221 | return decorator 222 | 223 | 224 | def wrap_response(fn, schema): 225 | from flask import current_app, jsonify 226 | 227 | @functools.wraps(fn) 228 | def wrapper(*args, **kwargs): 229 | result = fn(*args, **kwargs) 230 | if isinstance(result, tuple): 231 | resp = result[0] 232 | else: 233 | resp = result 234 | if not isinstance(resp, (list, dict)): 235 | raise ValueError( 236 | "Unknown response type '%s'. Supported types are list " 237 | "and dict." % type(resp) 238 | ) 239 | 240 | if current_app.config.get("ACCEPTABLE_VALIDATE_OUTPUT", True): 241 | error_list = validate(resp, schema) 242 | 243 | assert ( 244 | not error_list 245 | ), "Response does not comply with output schema: %r.\n%s" % ( 246 | error_list, 247 | resp, 248 | ) 249 | 250 | if isinstance(result, tuple): 251 | return (jsonify(resp),) + result[1:] 252 | else: 253 | return jsonify(result) 254 | 255 | return wrapper 256 | 257 | 258 | def validate(payload, schema): 259 | """Validate `payload` against `schema`, returning an error list. 260 | 261 | jsonschema provides lots of information in it's errors, but it can be a bit 262 | of work to extract all the information. 263 | """ 264 | v = jsonschema.Draft4Validator(schema, format_checker=jsonschema.FormatChecker()) 265 | error_list = [] 266 | for error in v.iter_errors(payload): 267 | message = error.message 268 | location = "/" + "/".join([str(c) for c in error.absolute_path]) 269 | error_list.append(message + " at " + location) 270 | return error_list 271 | 272 | 273 | def validate_schema(schema): 274 | """Validate that 'schema' is correct. 275 | 276 | This validates against the jsonschema v4 draft. 277 | 278 | :raises jsonschema.SchemaError: if the schema is invalid. 279 | """ 280 | jsonschema.Draft4Validator.check_schema(schema) 281 | -------------------------------------------------------------------------------- /acceptable/tests/test_djangoutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import json 4 | import os 5 | import subprocess 6 | import sys 7 | 8 | from django import forms 9 | from jsonschema import Draft4Validator, FormatChecker 10 | from testtools import TestCase 11 | 12 | from acceptable import djangoutil 13 | from acceptable._service import clear_metadata, get_metadata 14 | 15 | 16 | def setUpModule(): 17 | # This module tests against the django app in examples/django_app, so we 18 | # set that up and import it 19 | if "examples/django_app" not in sys.path: 20 | sys.path.append("examples/django_app") 21 | 22 | if "DJANGO_SETTINGS_MODULE" not in os.environ: 23 | os.environ["DJANGO_SETTINGS_MODULE"] = "django_app.settings" 24 | import django 25 | 26 | django.setup() 27 | djangoutil.get_urlmap() 28 | 29 | 30 | def tearDownModule(): 31 | if sys.path[-1] == "examples/django_app": 32 | sys.path = sys.path[:-1] 33 | os.environ.pop("DJANGO_SETTINGS_MODULE") 34 | clear_metadata() 35 | 36 | 37 | class TestUrlMap(TestCase): 38 | def test_urlmap_works(self): 39 | # this test asserts against the urlpatterns defined in: 40 | # examples/django_app/django_app/views.py 41 | urlmap = djangoutil.get_urlmap() 42 | 43 | # tests stripping of regex chars 44 | self.assertEqual(urlmap[None, "test"], "/test") 45 | 46 | # test regex params 47 | self.assertEqual(urlmap[None, "test2"], "/test2/(.*)") 48 | 49 | # tests mutliple namespaces with the same view name 50 | self.assertEqual(urlmap[None, "login"], "/login") 51 | self.assertEqual(urlmap["admin", "login"], "/prefix1/admin/login/") 52 | self.assertEqual(urlmap["other", "login"], "/prefix2/admin/login/") 53 | 54 | 55 | class TestFormSchema(TestCase): 56 | def test_get_form_schema_test_form(self): 57 | from django_app.views import TestForm 58 | 59 | schema = djangoutil.get_form_schema(TestForm) 60 | expected = { 61 | "type": "object", 62 | "required": ["foo"], 63 | "properties": { 64 | "foo": { 65 | "title": "foo", 66 | "description": "foo help", 67 | "type": "string", 68 | "format": "email", 69 | }, 70 | "bar": { 71 | "title": "bar", 72 | "description": "bar help", 73 | "type": "string", 74 | "enum": ["A", "B", "C"], 75 | }, 76 | "baz": {"title": "baz", "description": "baz help", "type": "number"}, 77 | "multi": { 78 | "title": "multi", 79 | "description": "multi help", 80 | "type": "array", 81 | "items": {"type": "string", "enum": ["A", "B", "C"]}, 82 | }, 83 | }, 84 | } 85 | self.assertEqual(expected, schema) 86 | 87 | def test_get_field_schema_uri(self): 88 | field = forms.URLField(label="label", help_text="help") 89 | self.assertEqual( 90 | { 91 | "type": "string", 92 | "format": "uri", 93 | "title": "label", 94 | "description": "help", 95 | }, 96 | djangoutil.get_field_schema("name", field), 97 | ) 98 | 99 | def test_get_field_schema_date(self): 100 | field = forms.DateField(label="label", help_text="help") 101 | self.assertEqual( 102 | { 103 | "type": "string", 104 | "format": "date", 105 | "title": "label", 106 | "description": "help", 107 | }, 108 | djangoutil.get_field_schema("name", field), 109 | ) 110 | 111 | def test_get_field_schema_datetime(self): 112 | field = forms.DateTimeField(label="label", help_text="help") 113 | self.assertEqual( 114 | { 115 | "type": "string", 116 | "format": "date-time", 117 | "title": "label", 118 | "description": "help", 119 | }, 120 | djangoutil.get_field_schema("name", field), 121 | ) 122 | 123 | def test_get_field_schema_decimal(self): 124 | field = forms.DecimalField(label="label", help_text="help") 125 | self.assertEqual( 126 | {"type": "number", "title": "label", "description": "help"}, 127 | djangoutil.get_field_schema("name", field), 128 | ) 129 | 130 | def test_get_field_schema_integer(self): 131 | field = forms.IntegerField(label="label", help_text="help") 132 | self.assertEqual( 133 | {"type": "integer", "title": "label", "description": "help"}, 134 | djangoutil.get_field_schema("name", field), 135 | ) 136 | 137 | def test_get_field_schema_choice(self): 138 | field = forms.ChoiceField( 139 | label="label", help_text="help", choices=["A", "B", "C"] 140 | ) 141 | self.assertEqual( 142 | { 143 | "type": "string", 144 | "title": "label", 145 | "description": "help", 146 | "enum": ["A", "B", "C"], 147 | }, 148 | djangoutil.get_field_schema("name", field), 149 | ) 150 | 151 | def test_get_field_schema_multiple_choice(self): 152 | field = forms.MultipleChoiceField( 153 | label="label", help_text="help", choices=["A", "B", "C"] 154 | ) 155 | self.assertEqual( 156 | { 157 | "type": "array", 158 | "title": "label", 159 | "description": "help", 160 | "items": {"type": "string", "enum": ["A", "B", "C"]}, 161 | }, 162 | djangoutil.get_field_schema("name", field), 163 | ) 164 | 165 | def test_get_field_schema_multiple_choice_checkbox_widget(self): 166 | field = forms.MultipleChoiceField( 167 | label="label", 168 | help_text="help", 169 | choices=["A", "B", "C"], 170 | widget=forms.CheckboxSelectMultiple(), 171 | ) 172 | self.assertEqual( 173 | { 174 | "type": "array", 175 | "title": "label", 176 | "description": "help", 177 | "items": {"type": "string", "enum": ["A", "B", "C"]}, 178 | }, 179 | djangoutil.get_field_schema("name", field), 180 | ) 181 | 182 | def get_errors(self, form, data): 183 | schema = djangoutil.get_form_schema(form) 184 | validator = Draft4Validator(schema, format_checker=FormatChecker()) 185 | 186 | # manipulate jsonschema errors to look like django form error dict 187 | schema_errors = {} 188 | for error in validator.iter_errors(data): 189 | if error.path: 190 | # strip out 'foo.n' naming for arrays to just 'foo' 191 | key = ".".join(i for i in error.path if isinstance(i, (str, bytes))) 192 | else: 193 | # missing required fields are not keyed by path 194 | key = error.validator_value[0] 195 | schema_errors[key] = error.message 196 | return form(data=data).errors, schema_errors 197 | 198 | def test_form_and_schema_validate_good_data(self): 199 | from django_app.views import TestForm 200 | 201 | form_errors, schema_errors = self.get_errors( 202 | TestForm, 203 | {"foo": "foo@example.com", "bar": "A", "baz": 12.34, "multi": ["B", "C"]}, 204 | ) 205 | self.assertEqual(form_errors, {}) 206 | self.assertEqual(schema_errors, {}) 207 | 208 | def test_form_and_schema_validate_optional(self): 209 | from django_app.views import TestForm 210 | 211 | form_errors, schema_errors = self.get_errors( 212 | TestForm, {"foo": "foo@example.com"} 213 | ) 214 | self.assertEqual(form_errors, {}) 215 | self.assertEqual(schema_errors, {}) 216 | 217 | def test_form_and_schema_validate_missing_required(self): 218 | from django_app.views import TestForm 219 | 220 | form_errors, schema_errors = self.get_errors(TestForm, {}) 221 | self.assertEqual(form_errors.keys(), schema_errors.keys()) 222 | 223 | def test_form_and_schema_invalidate_bad_format(self): 224 | from django_app.views import TestForm 225 | 226 | form_errors, schema_errors = self.get_errors( 227 | TestForm, 228 | {"foo": "not an email", "bar": "A", "baz": 12.34, "multi": ["B", "C"]}, 229 | ) 230 | self.assertEqual(form_errors.keys(), schema_errors.keys()) 231 | 232 | def test_form_and_schema_invalidate_bad_enum(self): 233 | from django_app.views import TestForm 234 | 235 | form_errors, schema_errors = self.get_errors( 236 | TestForm, 237 | { 238 | "foo": "foo@example.com", 239 | "bar": "INVALID", 240 | "baz": 12.34, 241 | "multi": ["B", "C"], 242 | }, 243 | ) 244 | self.assertEqual(form_errors.keys(), schema_errors.keys()) 245 | 246 | def test_form_and_schema_invalidate_bad_number(self): 247 | from django_app.views import TestForm 248 | 249 | form_errors, schema_errors = self.get_errors( 250 | TestForm, 251 | { 252 | "foo": "foo@example.com", 253 | "bar": "A", 254 | "baz": "not a number", 255 | "multi": ["B", "C"], 256 | }, 257 | ) 258 | self.assertEqual(form_errors.keys(), schema_errors.keys()) 259 | 260 | def test_form_and_schema_invalidate_bad_multiple_choice(self): 261 | from django_app.views import TestForm 262 | 263 | form_errors, schema_errors = self.get_errors( 264 | TestForm, 265 | {"foo": "foo@example.com", "bar": "A", "baz": 12.34, "multi": ["B", "XXX"]}, 266 | ) 267 | self.assertEqual(form_errors.keys(), schema_errors.keys()) 268 | 269 | 270 | def expected_metadata(): 271 | from django_app.views import TestForm 272 | 273 | return { 274 | "$version": 1, 275 | "default": { 276 | "title": "Default", 277 | "apis": { 278 | "test": { 279 | "url": "/test", 280 | "methods": ["POST"], 281 | "request_schema": djangoutil.get_form_schema(TestForm), 282 | "response_schema": None, 283 | "params_schema": None, 284 | "doc": "Documentation.\n\nMultiline.", 285 | "changelog": {}, 286 | "introduced_at": 1, 287 | "api_name": "test", 288 | "api_group": "default", 289 | "service": "django_app", 290 | "title": "Test", 291 | } 292 | }, 293 | }, 294 | } 295 | 296 | 297 | class TestDjangoAPI(TestCase): 298 | def test_example_app_works(self): 299 | metadata = get_metadata() 300 | api, _ = metadata.serialize() 301 | self.assertEqual(expected_metadata(), api) 302 | 303 | 304 | class TestManagementCommands(TestCase): 305 | # Note: this is located here as it tests all the django stuff, even though 306 | # the code is not in the djangoutil module 307 | 308 | def test_metadata_command(self): 309 | cmd = [sys.executable, "manage.py", "acceptable", "metadata"] 310 | output = subprocess.check_output( 311 | cmd, cwd="examples/django_app", universal_newlines=True 312 | ) 313 | self.assertEqual(expected_metadata(), json.loads(output)) 314 | 315 | def test_api_version_command(self): 316 | cmd = [sys.executable, "manage.py", "acceptable", "api-version", "../api.json"] 317 | output = subprocess.check_output( 318 | cmd, cwd="examples/django_app", universal_newlines=True 319 | ) 320 | self.assertEqual("../api.json: 2\nImported API: 1\n", output) 321 | -------------------------------------------------------------------------------- /acceptable/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import argparse 4 | import json 5 | import os 6 | import sys 7 | from collections import OrderedDict, defaultdict 8 | from importlib import import_module 9 | 10 | import yaml 11 | from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PackageLoader 12 | 13 | from acceptable import get_metadata, lint, openapi 14 | from acceptable.dummy_importer import DummyImporterContext 15 | 16 | 17 | def tojson_filter(json_object, indent=4): 18 | return json.dumps(json_object, indent=indent) 19 | 20 | 21 | TEMPLATES = Environment( 22 | loader=ChoiceLoader( 23 | [FileSystemLoader("./"), PackageLoader("acceptable", "templates")] 24 | ), 25 | autoescape=False, 26 | trim_blocks=True, 27 | lstrip_blocks=True, 28 | ) 29 | TEMPLATES.filters["tojson"] = tojson_filter 30 | 31 | 32 | class ForceAction(argparse.Action): 33 | def __call__(self, parser, namespace, values, option_string=None): 34 | if not namespace.update: 35 | if namespace.metadata: 36 | namespace.metadata.close() # suppresses resource warning 37 | parser.error("--force can only be used with --update") 38 | else: 39 | namespace.force = True 40 | 41 | 42 | def main(): 43 | try: 44 | cli_args = parse_args() 45 | sys.exit(cli_args.func(cli_args)) 46 | except Exception as e: 47 | raise 48 | sys.exit(str(e)) 49 | 50 | 51 | def parse_args(raw_args=None, parser_cls=None, stdin=None, stdout=None): 52 | if parser_cls is None: 53 | parser_cls = argparse.ArgumentParser 54 | if stdin is None: 55 | stdin = sys.stdin 56 | if stdout is None: 57 | stdout = sys.stdout 58 | 59 | parser = parser_cls(description="Tool for working with acceptable metadata") 60 | subparser = parser.add_subparsers(dest="cmd") 61 | subparser.required = True 62 | 63 | metadata_parser = subparser.add_parser( 64 | "metadata", help="Import project and print extracted metadata in JSON" 65 | ) 66 | metadata_parser.add_argument("modules", nargs="+") 67 | metadata_parser.add_argument( 68 | "--dummy-dependencies", 69 | "-d", 70 | action="store_true", 71 | default=False, 72 | help="Import code in a sandbox where dependencies are mocked", 73 | ) 74 | metadata_parser.add_argument( 75 | "--output", 76 | nargs="?", 77 | type=argparse.FileType("w"), 78 | default=stdout, 79 | help="metadata output file path, uses stdout if omitted", 80 | ) 81 | metadata_parser.set_defaults(func=metadata_cmd) 82 | 83 | render_parser = subparser.add_parser( 84 | "render", help="Render markdown documentation for API metadata" 85 | ) 86 | render_parser.add_argument( 87 | "metadata", nargs="?", type=argparse.FileType("r"), default=stdin 88 | ) 89 | render_parser.add_argument("--name", "-n", required=True, help="Name of service") 90 | render_parser.add_argument("--dir", "-d", default="docs", help="output directory") 91 | render_parser.add_argument( 92 | "--page-template", 93 | type=TEMPLATES.get_template, 94 | default=TEMPLATES.get_template("api_group.md.j2"), 95 | help="Jinja2 template to render each API", 96 | ) 97 | render_parser.add_argument( 98 | "--index-template", 99 | type=TEMPLATES.get_template, 100 | default=TEMPLATES.get_template("index.md.j2"), 101 | help="Jinja2 template to render the API index page", 102 | ) 103 | render_parser.add_argument( 104 | "--extension", 105 | "-e", 106 | default="md", 107 | help="File extension for rendered documentation", 108 | ) 109 | 110 | render_parser.set_defaults(func=render_cmd) 111 | 112 | lint_parser = subparser.add_parser( 113 | "lint", help="Compare current metadata against file metadata" 114 | ) 115 | lint_parser.add_argument("metadata", nargs="?", type=argparse.FileType("r")) 116 | lint_parser.add_argument("modules", nargs="+") 117 | lint_parser.add_argument( 118 | "-q", "--quiet", action="store_true", default=False, help="Do not emit warnings" 119 | ) 120 | lint_parser.add_argument( 121 | "--strict", 122 | "--pedantic", 123 | "--overhead", 124 | action="store_true", 125 | default=False, 126 | help="Even warnings count as failure", 127 | ) 128 | lint_parser.add_argument( 129 | "--update", 130 | action="store_true", 131 | default=False, 132 | help="Update metadata file to new metadata if lint passes", 133 | ) 134 | lint_parser.add_argument( 135 | "--force", 136 | action=ForceAction, 137 | nargs=0, 138 | default=False, 139 | help="Update metadata even if linting fails", 140 | ) 141 | 142 | lint_parser.set_defaults(func=lint_cmd) 143 | 144 | version_parser = subparser.add_parser( 145 | "api-version", 146 | help="Get the current API version from JSON meta, and " 147 | "optionally from current code also", 148 | ) 149 | version_parser.add_argument( 150 | "metadata", 151 | nargs="?", 152 | type=argparse.FileType("r"), 153 | default=stdin, 154 | help="The JSON metadata for the API", 155 | ) 156 | version_parser.add_argument( 157 | "modules", nargs="*", help="Optional modules to import for current imported API" 158 | ) 159 | version_parser.set_defaults(func=version_cmd) 160 | 161 | return parser.parse_args(raw_args) 162 | 163 | 164 | def metadata_cmd(cli_args): 165 | import_metadata(cli_args.modules, cli_args.dummy_dependencies) 166 | current, _ = get_metadata().serialize() 167 | cli_args.output.write(json.dumps(current, indent=2)) 168 | 169 | 170 | def add_working_dir_to_python_path(): 171 | cwd = os.getcwd() 172 | if cwd not in sys.path: 173 | sys.path.insert(0, cwd) 174 | 175 | 176 | def import_or_exec(path): 177 | """If path exists and ends in .py it is exec'd otherwise 178 | import is attempted""" 179 | if os.path.exists(path) and path.endswith(".py"): 180 | try: 181 | with open(path) as fd: 182 | exec(fd.read(), {}, {}) 183 | except Exception: 184 | raise Exception("Could not exec {!r}".format(path)) 185 | else: 186 | try: 187 | import_module(path) 188 | except Exception: 189 | raise Exception( 190 | "{!r} did not look like a filepath and could not be " 191 | "loaded as a module".format(path) 192 | ) 193 | 194 | 195 | def import_metadata_dummy_dependencies(module_paths): 196 | add_working_dir_to_python_path() 197 | for path in module_paths: 198 | with DummyImporterContext(path): 199 | import_or_exec(path) 200 | 201 | 202 | def import_metadata_real_dependencies(module_paths): 203 | add_working_dir_to_python_path() 204 | for path in module_paths: 205 | import_or_exec(path) 206 | 207 | 208 | def import_metadata(module_paths, dummy_dependencies=False): 209 | """Imports modules or execs filepaths in order 210 | to get acceptable decorator metadata. 211 | """ 212 | if dummy_dependencies: 213 | import_metadata_dummy_dependencies(module_paths) 214 | else: 215 | import_metadata_real_dependencies(module_paths) 216 | 217 | 218 | def load_metadata(stream): 219 | """Load JSON metadata from opened stream.""" 220 | try: 221 | metadata = json.load(stream, object_pairs_hook=OrderedDict) 222 | except json.JSONDecodeError as e: 223 | err = RuntimeError("Error parsing {}: {}".format(stream.name, e)) 224 | raise err from e 225 | else: 226 | # convert changelog keys back to ints for sorting 227 | for group in metadata: 228 | if group == "$version": 229 | continue 230 | apis = metadata[group]["apis"] 231 | for api in apis.values(): 232 | int_changelog = OrderedDict() 233 | for version, log in api.get("changelog", {}).items(): 234 | int_changelog[int(version)] = log 235 | api["changelog"] = int_changelog 236 | finally: 237 | stream.close() 238 | 239 | return metadata 240 | 241 | 242 | def render_cmd(cli_args): 243 | root_dir = cli_args.dir 244 | en_dir = os.path.join(root_dir, "en") 245 | if not os.path.exists(en_dir): 246 | os.makedirs(en_dir) 247 | metadata = load_metadata(cli_args.metadata) 248 | 249 | for path, content in render_markdown(metadata, cli_args): 250 | full_path = os.path.join(root_dir, path) 251 | with open(full_path, "w", encoding="utf8") as f: 252 | f.write(content) 253 | 254 | 255 | def render_markdown(metadata, cli_args): 256 | navigation = [{"title": "Index", "location": "index." + cli_args.extension}] 257 | version = metadata.pop("$version", None) 258 | changelog = defaultdict(dict) 259 | 260 | for group in metadata: 261 | apis = metadata[group]["apis"] 262 | for api in apis.values(): 263 | # collect global changelog 264 | for changed_version, log in api.get("changelog", {}).items(): 265 | changelog[changed_version][(group, api["api_name"])] = log 266 | 267 | documented_apis = [ 268 | api for api in apis.values() if not api.get("undocumented", False) 269 | ] 270 | 271 | if documented_apis: 272 | group_apis = [] 273 | deprecated_apis = [] 274 | for api in documented_apis: 275 | if api.get("deprecated_at", False): 276 | deprecated_apis.append(api) 277 | else: 278 | group_apis.append(api) 279 | sorted_apis = group_apis + deprecated_apis 280 | 281 | page_file = "{}.{}".format(group, cli_args.extension) 282 | page = {"title": group.title(), "location": page_file} 283 | navigation.append(page) 284 | 285 | path = os.path.join("en", page_file) 286 | yield path, cli_args.page_template.render( 287 | group_name=group, 288 | group_title=metadata[group].get("title", group), 289 | group_apis=sorted_apis, 290 | group_doc=metadata[group].get("docs", ""), 291 | ) 292 | 293 | yield ( 294 | os.path.join("en", "index." + cli_args.extension), 295 | cli_args.index_template.render( 296 | version=version, service_name=cli_args.name, changelog=changelog 297 | ), 298 | ) 299 | 300 | # documentation-builder requires yaml metadata files in certain locations 301 | yield os.path.join("en", "metadata.yaml"), yaml.safe_dump( 302 | {"navigation": navigation}, default_flow_style=False, encoding=None 303 | ) 304 | site_meta = { 305 | "site_title": "{} Documentation: version {}".format(cli_args.name, version) 306 | } 307 | yield "metadata.yaml", yaml.safe_dump( 308 | site_meta, default_flow_style=False, encoding=None 309 | ) 310 | 311 | 312 | def lint_cmd(cli_args, stream=sys.stdout): 313 | metadata = load_metadata(cli_args.metadata) 314 | import_metadata(cli_args.modules) 315 | _metadata = get_metadata() 316 | current, locations = _metadata.serialize() 317 | 318 | has_errors = False 319 | display_level = lint.WARNING 320 | error_level = lint.DOCUMENTATION 321 | 322 | if cli_args.strict: 323 | display_level = lint.WARNING 324 | error_level = lint.WARNING 325 | elif cli_args.quiet: 326 | display_level = lint.DOCUMENTATION 327 | 328 | for message in lint.metadata_lint(metadata, current, locations): 329 | if message.level >= display_level: 330 | stream.write("{}\n".format(message)) 331 | 332 | if message.level >= error_level: 333 | has_errors = True 334 | 335 | if cli_args.update and (not has_errors or cli_args.force): 336 | json_filename = cli_args.metadata.name 337 | with open(json_filename, "w") as j: 338 | json.dump(current, j, indent=2) 339 | 340 | if json_filename.endswith("api.json"): 341 | openapi_filename = json_filename.replace("api.json", "openapi.yaml") 342 | with open(openapi_filename, "w") as o: 343 | openapi.dump(_metadata, o) 344 | 345 | return 1 if has_errors else 0 346 | 347 | 348 | def version_cmd(cli_args, stream=sys.stdout): 349 | metadata = load_metadata(cli_args.metadata) 350 | json_version = metadata["$version"] 351 | import_version = None 352 | 353 | if cli_args.modules: 354 | import_metadata(cli_args.modules) 355 | import_version = get_metadata().current_version 356 | 357 | stream.write("{}: {}\n".format(cli_args.metadata.name, json_version)) 358 | 359 | if import_version is not None: 360 | if len(cli_args.modules) == 1: 361 | name = cli_args.modules[0] 362 | else: 363 | name = "Imported API" 364 | stream.write("{}: {}\n".format(name, import_version)) 365 | 366 | return 0 367 | 368 | 369 | if __name__ == "__main__": 370 | main() 371 | -------------------------------------------------------------------------------- /acceptable/tests/test_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import json 4 | 5 | import jsonschema.exceptions 6 | from fixtures import Fixture 7 | from flask import Flask 8 | from testtools import TestCase 9 | from testtools.matchers import Equals, Matcher 10 | 11 | from acceptable._service import ( 12 | AcceptableAPI, 13 | AcceptableService, 14 | APIMetadata, 15 | InvalidAPI, 16 | ) 17 | from acceptable._validation import DataValidationError, validate_body, validate_output 18 | 19 | 20 | class APIMetadataTestCase(TestCase): 21 | def test_register_api_duplicate_name(self): 22 | metadata = APIMetadata() 23 | api1 = AcceptableAPI(None, "api", "/api1", 1) 24 | api2 = AcceptableAPI(None, "api", "/api2", 1) 25 | metadata.register_service("test", None) 26 | metadata.register_api("test", None, api1) 27 | self.assertRaises(InvalidAPI, metadata.register_api, "other", None, api2) 28 | 29 | def test_register_api_duplicate_url(self): 30 | metadata = APIMetadata() 31 | api1 = AcceptableAPI(None, "api1", "/api", 1) 32 | api2 = AcceptableAPI(None, "api2", "/api", 1) 33 | metadata.register_service("test", None) 34 | metadata.register_service("other", None) 35 | metadata.register_api("test", None, api1) 36 | self.assertRaises(InvalidAPI, metadata.register_api, "other", None, api2) 37 | 38 | def test_register_api_allow_different_methods(self): 39 | metadata = APIMetadata() 40 | api1 = AcceptableAPI(None, "api1", "/api", 1) 41 | api2 = AcceptableAPI(None, "api2", "/api", 1, options={"methods": ["POST"]}) 42 | metadata.register_service("test", None) 43 | metadata.register_service("other", None) 44 | metadata.register_api("test", None, api1) 45 | metadata.register_api("other", None, api2) 46 | 47 | def test_register_service_handles_multiple(self): 48 | metadata = APIMetadata() 49 | api = AcceptableAPI(None, "api", "/api", 1) 50 | 51 | metadata.register_service("test", None) 52 | self.assertEqual({}, metadata.services["test"][None]) 53 | 54 | metadata.register_api("test", None, api) 55 | self.assertEqual({"api": api}, metadata.services["test"][None]) 56 | 57 | # register service again, shouldn't remove any apis 58 | metadata.register_service("test", None) 59 | self.assertEqual({"api": api}, metadata.services["test"][None]) 60 | 61 | def test_bind_works(self): 62 | app = Flask(__name__) 63 | metadata = APIMetadata() 64 | metadata.register_service("test", None) 65 | api1 = AcceptableAPI(None, "api1", "/api1", 1) 66 | api2 = AcceptableAPI(None, "api2", "/api2", 1) 67 | metadata.register_api("test", None, api1) 68 | metadata.register_api("test", None, api2) 69 | 70 | @api1.view(introduced_at=1) 71 | def api1_impl(): 72 | return "api1" 73 | 74 | metadata.bind(app, "test") 75 | 76 | self.assertEqual(api1_impl, app.view_functions["api1"]) 77 | self.assertNotIn("api2", app.view_functions) 78 | 79 | resp = app.test_client().get("/api1") 80 | self.assertThat(resp, IsResponse("api1")) 81 | 82 | def serialize_preserves_declaration_order(self): 83 | metadata = APIMetadata() 84 | svc = metadata.register_service("test", "group") 85 | svc.api("api5", "/api/5", 1) 86 | svc.api("api1", "/api/1", 1) 87 | svc.api("api3", "/api/3", 1) 88 | svc.api("api2", "/api/2", 1) 89 | 90 | serialized = metadata.serialize() 91 | 92 | self.assertEqual( 93 | ["api5", "api1", "api3", "api2"], list(serialized["group"]["apis"]) 94 | ) 95 | 96 | 97 | class AcceptableServiceTestCase(TestCase): 98 | def test_can_register_url_route(self): 99 | def view(): 100 | return "test view", 200 101 | 102 | service = AcceptableService("service", metadata=APIMetadata()) 103 | api = service.api("/foo", "foo_api") 104 | api.register_view(view, "1.0") 105 | 106 | app = Flask(__name__) 107 | service.bind(app) 108 | 109 | client = app.test_client() 110 | resp = client.get("/foo") 111 | 112 | self.assertThat(resp, IsResponse("test view")) 113 | 114 | 115 | class ServiceFixture(Fixture): 116 | """A reusable fixture that sets up several API endpoints.""" 117 | 118 | def _setUp(self): 119 | self.metadata = APIMetadata() 120 | self.service = AcceptableService("service", metadata=self.metadata) 121 | foo_api = self.service.api("/foo", "foo_api", methods=["POST"], introduced_at=1) 122 | 123 | @foo_api 124 | def foo(): 125 | return "foo", 200 126 | 127 | def bind(self, app=None): 128 | if app is None: 129 | app = Flask(__name__) 130 | self.service.bind(app) 131 | return app 132 | 133 | 134 | class AcceptableAPITestCase(TestCase): 135 | def test_acceptable_api_declaration_works(self): 136 | fixture = self.useFixture(ServiceFixture()) 137 | api = fixture.service.api("/new", "blah") 138 | 139 | self.assertEqual(api.url, "/new") 140 | self.assertEqual(api.options, {}) 141 | self.assertEqual(api.name, "blah") 142 | self.assertEqual(api.methods, ["GET"]) 143 | self.assertEqual(api, fixture.metadata.services["service"][None]["blah"]) 144 | 145 | def test_acceptable_api_explicit_docs_works(self): 146 | fixture = self.useFixture(ServiceFixture()) 147 | api = fixture.service.api("/docs", "blah") 148 | self.assertEqual(api.docs, None) 149 | api.docs = "Documentation." 150 | self.assertEqual(api.docs, "Documentation.") 151 | 152 | def test_acceptable_api_schemas_are_validated(self): 153 | fixture = self.useFixture(ServiceFixture()) 154 | api = fixture.service.api("/api", "blah") 155 | self.assertRaises( 156 | jsonschema.exceptions.SchemaError, setattr, api, "request_schema", "" 157 | ) 158 | self.assertRaises( 159 | jsonschema.exceptions.SchemaError, setattr, api, "response_schema", "" 160 | ) 161 | schema = {"type": "object"} 162 | api.request_schema = schema 163 | api.response_schema = schema 164 | self.assertEqual(api.request_schema, schema) 165 | self.assertEqual(api.response_schema, schema) 166 | 167 | def test_acceptable_api_schemas_are_sorted(self): 168 | fixture = self.useFixture(ServiceFixture()) 169 | api = fixture.service.api("/api", "blah") 170 | schema = { 171 | "type": "object", 172 | "properties": { 173 | "5": {"type": "string"}, 174 | "1": {"type": "string"}, 175 | "3": {"type": "string"}, 176 | }, 177 | } 178 | api.request_schema = schema 179 | api.response_schema = schema 180 | self.assertEqual(["1", "3", "5"], list(api.request_schema["properties"])) 181 | self.assertEqual(["1", "3", "5"], list(api.response_schema["properties"])) 182 | 183 | def test_acceptable_api_changelog_is_recorded(self): 184 | fixture = self.useFixture(ServiceFixture()) 185 | api = fixture.service.api("/api", "blah") 186 | api.changelog( 187 | 5, 188 | """ 189 | Changelog for version 5 190 | """, 191 | ) 192 | api.changelog(4, "Changelog for version 4") 193 | 194 | self.assertEqual("Changelog for version 5", api._changelog[5]) 195 | self.assertEqual("Changelog for version 4", api._changelog[4]) 196 | 197 | def test_decorator_and_bind_works(self): 198 | fixture = self.useFixture(ServiceFixture()) 199 | 200 | new_api = fixture.service.api("/new", "blah") 201 | 202 | @new_api 203 | def new_view(): 204 | """Documentation.""" 205 | return "new view", 200 206 | 207 | app = fixture.bind() 208 | 209 | client = app.test_client() 210 | resp = client.get("/new") 211 | 212 | self.assertThat(resp, IsResponse("new view")) 213 | view = app.view_functions["blah"] 214 | self.assertEqual(view.__name__, "new_view") 215 | self.assertEqual(new_api.docs, "Documentation.") 216 | 217 | def test_decorator_validates_bad_request(self): 218 | fixture = self.useFixture(ServiceFixture()) 219 | 220 | new_api = fixture.service.api("/new", "new", methods=["POST"]) 221 | new_api.request_schema = {"type": "object"} 222 | 223 | @new_api 224 | def new_view(): 225 | return "new view", 200 226 | 227 | app = fixture.bind() 228 | 229 | payload = dict( 230 | data=json.dumps([]), headers={"Content-Type": "application/json"} 231 | ) 232 | 233 | with app.test_request_context("/new", **payload): 234 | self.assertRaises(DataValidationError, new_view) 235 | 236 | def test_decorator_validates_bad_response(self): 237 | fixture = self.useFixture(ServiceFixture()) 238 | 239 | new_api = fixture.service.api("/new", "new") 240 | new_api.response_schema = {"type": "object"} 241 | 242 | @new_api 243 | def new_view(): 244 | return [] 245 | 246 | app = fixture.bind() 247 | 248 | with app.test_request_context("/new"): 249 | self.assertRaises(AssertionError, new_view) 250 | 251 | def test_can_still_call_view_directly(self): 252 | fixture = self.useFixture(ServiceFixture()) 253 | 254 | new_api = fixture.service.api("/new", "namegoeshere") 255 | 256 | @new_api 257 | def new_view(): 258 | return "new view", 200 259 | 260 | app = fixture.bind() 261 | with app.test_request_context("/new"): 262 | content, status = new_view() 263 | 264 | self.assertEqual(content, "new view") 265 | self.assertEqual(status, 200) 266 | 267 | def test_cannot_duplicate_name(self): 268 | fixture = self.useFixture(ServiceFixture()) 269 | 270 | self.assertRaises(InvalidAPI, fixture.service.api, "/bar", "foo_api") 271 | 272 | def test_cannot_duplicate_url_and_method(self): 273 | fixture = self.useFixture(ServiceFixture()) 274 | self.assertRaises( 275 | InvalidAPI, fixture.service.api, "/foo", "bar", methods=["POST"] 276 | ) 277 | 278 | def test_can_duplicate_url_different_method(self): 279 | fixture = self.useFixture(ServiceFixture()) 280 | 281 | alt_api = fixture.service.api("/foo", "foo_alt", methods=["GET"]) 282 | 283 | @alt_api.view(introduced_at=1) 284 | def foo_alt(): 285 | return "alt foo", 200 286 | 287 | app = fixture.bind() 288 | client = app.test_client() 289 | resp = client.get("/foo") 290 | 291 | self.assertThat(resp, IsResponse("alt foo")) 292 | 293 | 294 | class LegacyAcceptableAPITestCase(TestCase): 295 | def test_view_decorator_and_bind_works(self): 296 | fixture = self.useFixture(ServiceFixture()) 297 | 298 | new_api = fixture.service.api("/new", "blah") 299 | 300 | @new_api.view(introduced_at=1) 301 | def new_view(): 302 | """Documentation.""" 303 | return "new view", 200 304 | 305 | app = fixture.bind() 306 | 307 | client = app.test_client() 308 | resp = client.get("/new") 309 | 310 | self.assertThat(resp, IsResponse("new view")) 311 | view = app.view_functions["blah"] 312 | self.assertEqual(view.__name__, "new_view") 313 | self.assertEqual(new_api.docs, "Documentation.") 314 | 315 | def test_view_introduced_at_string(self): 316 | fixture = self.useFixture(ServiceFixture()) 317 | 318 | new_api = fixture.service.api("/new", "blah") 319 | self.assertEqual(new_api.introduced_at, None) 320 | 321 | @new_api.view(introduced_at="1") 322 | def new_view(): 323 | return "new view", 200 324 | 325 | self.assertEqual(new_api.introduced_at, 1) 326 | 327 | def test_view_introduced_at_1_0_string(self): 328 | fixture = self.useFixture(ServiceFixture()) 329 | new_api = fixture.service.api("/new", "blah") 330 | self.assertEqual(new_api.introduced_at, None) 331 | 332 | @new_api.view(introduced_at="1.0") 333 | def new_view(): 334 | return "new view", 200 335 | 336 | self.assertEqual(new_api.introduced_at, 1) 337 | 338 | def test_validate_body_records_metadata(self): 339 | fixture = self.useFixture(ServiceFixture()) 340 | new_api = fixture.service.api("/new", "blah") 341 | schema = {"type": "object"} 342 | 343 | @new_api.view(introduced_at=1) 344 | @validate_body(schema) 345 | def new_view(): 346 | return "new view", 200 347 | 348 | self.assertEqual(schema, fixture.service.apis["blah"].request_schema) 349 | 350 | def test_validate_body_records_metadata_reversed_order(self): 351 | fixture = self.useFixture(ServiceFixture()) 352 | new_api = fixture.service.api("/new", "blah") 353 | schema = {"type": "object"} 354 | 355 | @validate_body(schema) 356 | @new_api.view(introduced_at=1) 357 | def new_view(): 358 | return "new view", 200 359 | 360 | self.assertEqual(schema, fixture.service.apis["blah"].request_schema) 361 | 362 | def test_validate_output_records_metadata(self): 363 | fixture = self.useFixture(ServiceFixture()) 364 | new_api = fixture.service.api("/new", "blah") 365 | schema = {"type": "object"} 366 | 367 | @new_api.view(introduced_at=1) 368 | @validate_output(schema) 369 | def new_view(): 370 | return "new view", 200 371 | 372 | self.assertEqual(schema, fixture.service.apis["blah"].response_schema) 373 | 374 | def test_validate_output_records_metadata_reversed(self): 375 | fixture = self.useFixture(ServiceFixture()) 376 | new_api = fixture.service.api("/new", "blah") 377 | schema = {"type": "object"} 378 | 379 | @validate_output(schema) 380 | @new_api.view(introduced_at=1) 381 | def new_view(): 382 | return "new view", 200 383 | 384 | self.assertEqual(schema, fixture.service.apis["blah"].response_schema) 385 | 386 | def test_validate_both_records_metadata(self): 387 | fixture = self.useFixture(ServiceFixture()) 388 | new_api = fixture.service.api("/new", "blah") 389 | schema1 = {"type": "object"} 390 | schema2 = {"type": "array"} 391 | 392 | @new_api.view(introduced_at=1) 393 | @validate_body(schema1) 394 | @validate_output(schema2) 395 | def new_view(): 396 | return "new view", 200 397 | 398 | self.assertEqual(schema1, fixture.service.apis["blah"].request_schema) 399 | self.assertEqual(schema2, fixture.service.apis["blah"].response_schema) 400 | 401 | 402 | class IsResponse(Matcher): 403 | def __init__(self, expected_content, expected_code=200, decode=None): 404 | """Construct a new IsResponse matcher. 405 | 406 | :param expected_content: The content you want to match against the 407 | response body. This can either be a matcher, a string, or a 408 | bytestring. 409 | 410 | :param expected_code: Tht HTTP status code you want to match against. 411 | 412 | :param decode: Whether to decode the response data according to the 413 | response charset. This can either be set implicitly or explicitly. 414 | If the 'expected_content' parameter is a string, this will 415 | implicitly be set to True. If 'expected_content' is a bytestring, 416 | this will be set to False. If 'expected_content' is a matcher, 417 | this will be set to True. Setting this parameter to a value 418 | explicitly disables this implicit behavior. 419 | """ 420 | if isinstance(expected_content, str): 421 | self._decode = True 422 | expected_content = Equals(expected_content) 423 | elif isinstance(expected_content, bytes): 424 | self._decode = False 425 | expected_content = Equals(expected_content) 426 | else: 427 | self._decode = decode or True 428 | self.expected_content = expected_content 429 | self.expected_code = Equals(expected_code) 430 | 431 | def match(self, response): 432 | mismatch = self.expected_code.match(response.status_code) 433 | if mismatch: 434 | return mismatch 435 | data = response.data 436 | if self._decode: 437 | charset = response.mimetype_params.get("charset", "utf-8") 438 | data = data.decode(charset) 439 | return self.expected_content.match(data) 440 | 441 | def __str__(self): 442 | return "IsResponse(%r, %r)" % (self.expected_content, self.expected_code) 443 | -------------------------------------------------------------------------------- /acceptable/_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | 4 | """acceptable - Programmatic API Metadata for Flask apps.""" 5 | import textwrap 6 | from collections import OrderedDict 7 | 8 | from acceptable import _validation 9 | from acceptable.util import ( 10 | clean_docstring, 11 | get_callsite_location, 12 | get_function_location, 13 | sort_schema, 14 | ) 15 | 16 | 17 | class InvalidAPI(Exception): 18 | pass 19 | 20 | 21 | class APIMetadata: 22 | """Global datastructure for all services. 23 | 24 | Provides a single point to register apis against, so we can easily inspect 25 | and verify uniqueness. 26 | """ 27 | 28 | def __init__(self): 29 | self.services = OrderedDict() 30 | self.api_names = set() 31 | self.urls = set() 32 | self._current_version = None 33 | 34 | def register_service(self, service, group, docs=None, title=None): 35 | if service not in self.services: 36 | self.services[service] = OrderedDict() 37 | 38 | if group not in self.services[service]: 39 | self.services[service][group] = APIGroup(group, docs, title) 40 | elif docs is not None: 41 | additional_docs = "\n" + clean_docstring(docs) 42 | self.services[service][group].docs += additional_docs 43 | 44 | return self.services[service][group] 45 | 46 | def register_api(self, service, group, api): 47 | # check for name/url clashes globally 48 | # FIXME: this should probably be per service? 49 | if api.name in self.api_names: 50 | raise InvalidAPI( 51 | "API {} is already registered in service {}".format(api.name, service) 52 | ) 53 | self.api_names.add(api.name) 54 | 55 | if api.url is not None: 56 | url_key = (api.url, tuple(api.methods)) 57 | if url_key in self.urls: 58 | raise InvalidAPI( 59 | "URL {} {} is already in service {}".format( 60 | "|".join(api.methods), api.url, service 61 | ) 62 | ) 63 | self.urls.add(url_key) 64 | 65 | self.services[service][group][api.name] = api 66 | 67 | @property 68 | def current_version(self): 69 | if self._current_version is None: 70 | versions = set() 71 | for service in self.services.values(): 72 | for group in service.values(): 73 | for api in group.values(): 74 | versions.add(api.introduced_at) 75 | if api._changelog: 76 | versions.add(max(api._changelog)) 77 | if versions: 78 | self._current_version = max(versions) 79 | return self._current_version 80 | 81 | def bind(self, flask_app, service, group=None): 82 | """Bind the service API urls to a flask app.""" 83 | if group not in self.services[service]: 84 | raise RuntimeError( 85 | "API group {} does not exist in service {}".format(group, service) 86 | ) 87 | for name, api in self.services[service][group].items(): 88 | # only bind APIs that have views associated with them 89 | if api.view_fn is None: 90 | continue 91 | if name not in flask_app.view_functions: 92 | flask_app.add_url_rule( 93 | api.url, name, view_func=api.view_fn, **api.options 94 | ) 95 | 96 | def bind_all(self, flask_app): 97 | for service, groups in self.services.items(): 98 | for group in groups: 99 | self.bind(flask_app, service, group) 100 | 101 | def clear(self): 102 | self.services.clear() 103 | self.api_names.clear() 104 | self.urls.clear() 105 | self._current_version = None 106 | 107 | def groups(self): 108 | for service, groups in self.services.items(): 109 | for group in groups.values(): 110 | yield service, group 111 | 112 | def serialize(self): 113 | """Serialize into JSON-able dict, and associated locations data.""" 114 | api_metadata = OrderedDict() 115 | # $ char makes this come first in sort ordering 116 | api_metadata["$version"] = self.current_version 117 | locations = {} 118 | 119 | for svc_name, group in self.groups(): 120 | group_apis = OrderedDict() 121 | group_metadata = OrderedDict() 122 | group_metadata["apis"] = group_apis 123 | group_metadata["title"] = group.title 124 | api_metadata[group.name] = group_metadata 125 | 126 | if group.docs is not None: 127 | group_metadata["docs"] = group.docs 128 | 129 | for name, api in group.items(): 130 | group_apis[name] = OrderedDict() 131 | group_apis[name]["service"] = svc_name 132 | group_apis[name]["api_group"] = group.name 133 | group_apis[name]["api_name"] = api.name 134 | group_apis[name]["introduced_at"] = api.introduced_at 135 | group_apis[name]["methods"] = api.methods 136 | group_apis[name]["request_schema"] = api.request_schema 137 | group_apis[name]["response_schema"] = api.response_schema 138 | group_apis[name]["params_schema"] = api.params_schema 139 | group_apis[name]["doc"] = api.docs 140 | group_apis[name]["changelog"] = api._changelog 141 | if api.title: 142 | group_apis[name]["title"] = api.title 143 | else: 144 | title = name.replace("-", " ").replace("_", " ").title() 145 | group_apis[name]["title"] = title 146 | 147 | group_apis[name]["url"] = api.resolve_url() 148 | 149 | if api.undocumented: 150 | group_apis[name]["undocumented"] = True 151 | if api.deprecated_at is not None: 152 | group_apis[name]["deprecated_at"] = api.deprecated_at 153 | 154 | locations[name] = { 155 | "api": api.location, 156 | "request_schema": api._request_schema_location, 157 | "response_schema": api._response_schema_location, 158 | "params_schema": api._params_schema_location, 159 | "changelog": api._changelog_locations, 160 | "view": api.view_fn_location, 161 | } 162 | 163 | return api_metadata, locations 164 | 165 | 166 | _metadata = None 167 | 168 | 169 | def get_metadata(): 170 | global _metadata 171 | if _metadata is None: 172 | _metadata = APIMetadata() 173 | return _metadata 174 | 175 | 176 | def clear_metadata(): 177 | global _metadata 178 | _metadata = None 179 | 180 | 181 | class APIGroup(OrderedDict): 182 | """Wrapper for collection of APIs, with associated documentation.""" 183 | 184 | def __init__(self, name=None, docs=None, title=None): 185 | self.name = name 186 | self.title = title 187 | if self.name is None: 188 | self.name = "default" 189 | self.title = "Default" 190 | elif title is None: 191 | self.title = name.replace("-", " ").title() 192 | self.docs = docs 193 | super().__init__() 194 | 195 | 196 | class AcceptableService: 197 | """User facing API for a service using acceptable to manage API versions. 198 | 199 | This provides a nicer interface to manage the global API metadata within 200 | a single file. 201 | 202 | It is just a factory and proxy to the global metadata state, it does not 203 | store any API state internally. 204 | """ 205 | 206 | def __init__(self, name, group=None, title=None, metadata=None): 207 | """Create an instance of AcceptableService. 208 | 209 | :param name: The service name. 210 | :param group: An arbitrary API group within a service. 211 | """ 212 | self.name = name 213 | self.group = group 214 | if metadata is None: 215 | self.metadata = get_metadata() 216 | else: 217 | self.metadata = metadata 218 | 219 | self.location = get_callsite_location() 220 | self.doc = None 221 | module = self.location["module"] 222 | docs = None 223 | if module and module.__doc__: 224 | docs = clean_docstring(module.__doc__) 225 | self.metadata.register_service(name, group, docs, title) 226 | 227 | @property 228 | def apis(self): 229 | return self.metadata.services[self.name][self.group] 230 | 231 | def api( 232 | self, 233 | url, 234 | name, 235 | introduced_at=None, 236 | undocumented=False, 237 | deprecated_at=None, 238 | title=None, 239 | **options 240 | ): 241 | """Add an API to the service. 242 | 243 | :param url: This is the url that the API should be registered at. 244 | :param name: This is the name of the api, and will be registered with 245 | flask apps under. 246 | 247 | Other keyword arguments may be used, and they will be passed to the 248 | flask application when initialised. Of particular interest is the 249 | 'methods' keyword argument, which can be used to specify the HTTP 250 | method the URL will be added for. 251 | """ 252 | location = get_callsite_location() 253 | api = AcceptableAPI( 254 | self, 255 | name, 256 | url, 257 | introduced_at, 258 | options, 259 | undocumented=undocumented, 260 | deprecated_at=deprecated_at, 261 | title=title, 262 | location=location, 263 | ) 264 | self.metadata.register_api(self.name, self.group, api) 265 | return api 266 | 267 | def django_api( 268 | self, 269 | name, 270 | introduced_at, 271 | undocumented=False, 272 | deprecated_at=None, 273 | title=None, 274 | **options 275 | ): 276 | """Add a django API handler to the service. 277 | 278 | :param name: This is the name of the django url to use. 279 | 280 | The 'methods' parameter can be supplied as normal, you can also user 281 | the @api.handler decorator to link this API to its handler. 282 | 283 | """ 284 | from acceptable.djangoutil import DjangoAPI 285 | 286 | location = get_callsite_location() 287 | api = DjangoAPI( 288 | self, 289 | name, 290 | introduced_at, 291 | options, 292 | location=location, 293 | undocumented=undocumented, 294 | deprecated_at=deprecated_at, 295 | title=title, 296 | ) 297 | self.metadata.register_api(self.name, self.group, api) 298 | return api 299 | 300 | def bind(self, flask_app): 301 | """Bind the service API urls to a flask app.""" 302 | self.metadata.bind(flask_app, self.name, self.group) 303 | 304 | # b/w compat 305 | initialise = bind 306 | 307 | 308 | class AcceptableAPI: 309 | """Metadata about an api endpoint.""" 310 | 311 | def __init__( 312 | self, 313 | service, 314 | name, 315 | url, 316 | introduced_at, 317 | options=None, 318 | location=None, 319 | undocumented=False, 320 | deprecated_at=None, 321 | title=None, 322 | ): 323 | 324 | self.service = service 325 | self.name = name 326 | self.url = url 327 | self.introduced_at = introduced_at 328 | self.options = options or {} 329 | self.view_fn = None 330 | self.view_fn_location = None 331 | self.docs = None 332 | self._request_schema = None 333 | self._request_schema_location = None 334 | self._response_schema = None 335 | self._response_schema_location = None 336 | self._params_schema = None 337 | self._params_schema_location = None 338 | self._changelog = OrderedDict() 339 | self._changelog_locations = OrderedDict() 340 | if location is None: 341 | self.location = get_callsite_location() 342 | else: 343 | self.location = location 344 | self.undocumented = undocumented 345 | self.deprecated_at = deprecated_at 346 | self.title = title 347 | 348 | @property 349 | def methods(self): 350 | return list(self.options.get("methods", ["GET"])) 351 | 352 | def resolve_url(self): 353 | return self.url 354 | 355 | @property 356 | def request_schema(self): 357 | return self._request_schema 358 | 359 | @request_schema.setter 360 | def request_schema(self, schema): 361 | if schema is not None: 362 | _validation.validate_schema(schema) 363 | self._request_schema = sort_schema(schema) 364 | # this location is the last item in the dict, sadly 365 | self._request_schema_location = get_callsite_location() 366 | 367 | @property 368 | def response_schema(self): 369 | return self._response_schema 370 | 371 | @response_schema.setter 372 | def response_schema(self, schema): 373 | if schema is not None: 374 | _validation.validate_schema(schema) 375 | self._response_schema = sort_schema(schema) 376 | # this location is the last item in the dict, sadly 377 | self._response_schema_location = get_callsite_location() 378 | 379 | @property 380 | def params_schema(self): 381 | return self._params_schema 382 | 383 | @params_schema.setter 384 | def params_schema(self, schema): 385 | if schema is not None: 386 | _validation.validate_schema(schema) 387 | self._params_schema = sort_schema(schema) 388 | self._params_schema_location = get_callsite_location() 389 | 390 | def changelog(self, api_version, doc): 391 | """Add a changelog entry for this api.""" 392 | doc = textwrap.dedent(doc).strip() 393 | self._changelog[api_version] = doc 394 | self._changelog_locations[api_version] = get_callsite_location() 395 | 396 | def __call__(self, fn): 397 | wrapped = fn 398 | if self.response_schema: 399 | wrapped = _validation.wrap_response(wrapped, self.response_schema) 400 | if self.request_schema: 401 | wrapped = _validation.wrap_request(wrapped, self.request_schema) 402 | 403 | location = get_function_location(fn) 404 | # this will be the lineno of the last decorator, so we want one 405 | # below it for the actual function 406 | location["lineno"] += 1 407 | self.register_view(wrapped, location) 408 | return wrapped 409 | 410 | def register_view(self, view_fn, location=None, introduced_at=None): 411 | if self.view_fn is not None: 412 | raise InvalidAPI("api already has view registered") 413 | self.view_fn = view_fn 414 | self.view_fn_location = location 415 | if self.introduced_at is None: 416 | self.introduced_at = introduced_at 417 | if self.docs is None and self.view_fn.__doc__ is not None: 418 | self.docs = clean_docstring(self.view_fn.__doc__) 419 | 420 | # legacy view decorator 421 | def view(self, introduced_at): 422 | def decorator(fn): 423 | location = get_function_location(fn) 424 | # this will be the lineno of the last decorator, so we want one 425 | # below it for the actual function 426 | location["lineno"] += 1 427 | 428 | # convert older style version strings 429 | if introduced_at == "1.0": 430 | self.introduced_at = 1 431 | elif introduced_at is not None: 432 | self.introduced_at = int(introduced_at) 433 | 434 | self.register_view(fn, location, introduced_at) 435 | 436 | # support for legacy @validate_{body,output} decorators 437 | # we don't know the order of decorators, so allow for both. 438 | # Note that if these schemas come from the @validate decorators, 439 | # they are already validated, so we set directly. 440 | fn._acceptable_metadata = self 441 | if self._request_schema is None: 442 | self._request_schema = getattr(fn, "_request_schema", None) 443 | self._request_schema_location = getattr( 444 | fn, "_request_schema_location", None 445 | ) 446 | if self._response_schema is None: 447 | self._response_schema = getattr(fn, "_response_schema", None) 448 | self._response_schema_location = getattr( 449 | fn, "_response_schema_location", None 450 | ) 451 | if self._params_schema is None: 452 | self._params_schema = getattr(fn, "_params_schema", None) 453 | self._params_schema_location = getattr( 454 | fn, "_params_schema_location", None 455 | ) 456 | return fn 457 | 458 | return decorator 459 | -------------------------------------------------------------------------------- /acceptable/tests/test_main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import argparse 4 | import contextlib 5 | import io 6 | import json 7 | import os 8 | import subprocess 9 | import tempfile 10 | from collections import OrderedDict 11 | from functools import partial 12 | 13 | import fixtures 14 | import testtools 15 | import yaml 16 | 17 | from acceptable import __main__ as main 18 | from acceptable import get_metadata 19 | from acceptable.tests._fixtures import CleanUpModuleImport, TemporaryModuleFixture 20 | 21 | 22 | # sys.exit on error, but rather throws an exception, so we can catch that in 23 | # our tests: 24 | class SaneArgumentParser(argparse.ArgumentParser): 25 | def error(self, message): 26 | raise RuntimeError(message) 27 | 28 | 29 | class ParseArgsTests(testtools.TestCase): 30 | def test_error_with_no_args(self): 31 | self.assertRaisesRegex( 32 | RuntimeError, 33 | "arguments are required", 34 | main.parse_args, 35 | [], 36 | SaneArgumentParser, 37 | ) 38 | 39 | def test_metadata_requires_files(self): 40 | self.assertRaisesRegex( 41 | RuntimeError, 42 | "arguments are required", 43 | main.parse_args, 44 | ["metadata"], 45 | SaneArgumentParser, 46 | ) 47 | 48 | def test_metadata_parses_files(self): 49 | args = main.parse_args(["metadata", "foo", "bar"]) 50 | self.assertEqual(["foo", "bar"], args.modules) 51 | self.assertEqual("metadata", args.cmd) 52 | 53 | def test_render_parses_file(self): 54 | with tempfile.NamedTemporaryFile("w") as api: 55 | api.write("hi") 56 | api.flush() 57 | args = main.parse_args(["render", "--name=name", api.name]) 58 | 59 | self.assertTrue("hi", args.metadata.read()) 60 | self.assertEqual("render", args.cmd) 61 | 62 | args.metadata.close() # suppresses ResourceWarning 63 | 64 | def test_render_parses_stdin_with_no_metadata(self): 65 | stdin = io.StringIO("hi") 66 | args = main.parse_args(["render", "--name=name"], stdin=stdin) 67 | self.assertEqual("hi", args.metadata.read()) 68 | 69 | def test_lint_reads_file(self): 70 | with tempfile.NamedTemporaryFile("w") as api: 71 | api.write("hi") 72 | api.flush() 73 | args = main.parse_args(["lint", api.name, "foo", "bar"]) 74 | 75 | self.assertEqual("hi", args.metadata.read()) 76 | self.assertEqual(args.modules, ["foo", "bar"]) 77 | 78 | args.metadata.close() # suppresses ResourceWarning 79 | 80 | def test_lint_force_without_update(self): 81 | with tempfile.NamedTemporaryFile("w") as api: 82 | api.write("hi") 83 | api.flush() 84 | self.assertRaisesRegex( 85 | RuntimeError, 86 | "--force can only be used with --update", 87 | main.parse_args, 88 | ["lint", api.name, "foo", "--force"], 89 | parser_cls=SaneArgumentParser, 90 | ) 91 | 92 | 93 | class MetadataTests(testtools.TestCase): 94 | def test_importing_api_metadata_works(self): 95 | service = """ 96 | from acceptable import AcceptableService 97 | service = AcceptableService('myservice', 'group') 98 | 99 | root_api = service.api('/', 'root', introduced_at=1) 100 | root_api.request_schema = {'type': 'object'} 101 | root_api.response_schema = {'type': 'object'} 102 | root_api.params_schema = {'type': 'object'} 103 | root_api.changelog(4, "changelog") 104 | 105 | @root_api 106 | def my_view(): 107 | "Documentation." 108 | """ 109 | 110 | fixture = self.useFixture(TemporaryModuleFixture("service", service)) 111 | main.import_metadata(["service"]) 112 | import service as svc_mod # noqa: we monkey-patched this in the line above 113 | 114 | metadata, locations = get_metadata().serialize() 115 | 116 | self.assertEqual( 117 | { 118 | "$version": 4, 119 | "group": { 120 | "title": "Group", 121 | "apis": { 122 | "root": { 123 | "service": "myservice", 124 | "api_group": "group", 125 | "api_name": "root", 126 | "methods": ["GET"], 127 | "url": "/", 128 | "doc": "Documentation.", 129 | "changelog": {4: "changelog"}, 130 | "request_schema": {"type": "object"}, 131 | "response_schema": {"type": "object"}, 132 | "params_schema": {"type": "object"}, 133 | "introduced_at": 1, 134 | "title": "Root", 135 | } 136 | }, 137 | }, 138 | }, 139 | metadata, 140 | ) 141 | 142 | self.assertEqual( 143 | { 144 | "root": { 145 | "api": {"filename": fixture.path, "lineno": 4, "module": svc_mod}, 146 | "changelog": { 147 | 4: {"filename": fixture.path, "lineno": 8, "module": svc_mod} 148 | }, 149 | "request_schema": { 150 | "filename": fixture.path, 151 | "lineno": 5, 152 | "module": svc_mod, 153 | }, 154 | "response_schema": { 155 | "filename": fixture.path, 156 | "lineno": 6, 157 | "module": svc_mod, 158 | }, 159 | "params_schema": { 160 | "filename": fixture.path, 161 | "lineno": 7, 162 | "module": svc_mod, 163 | }, 164 | "view": {"filename": fixture.path, "lineno": 12, "module": svc_mod}, 165 | } 166 | }, 167 | locations, 168 | ) 169 | 170 | def test_legacy_api_still_works(self): 171 | service = """ 172 | 173 | from acceptable import * 174 | service = AcceptableService('service') 175 | 176 | root_api = service.api('/', 'root') 177 | root_api.changelog(4, "changelog") 178 | 179 | @root_api.view(introduced_at='1.0') 180 | @validate_body({'type': 'object'}) 181 | @validate_output({'type': 'object'}) 182 | @validate_params( 183 | {'type': 'object', 'properties': {'test': {'type': 'string'}}} 184 | ) 185 | def my_view(): 186 | "Documentation." 187 | """ 188 | fixture = self.useFixture(TemporaryModuleFixture("service", service)) 189 | 190 | main.import_metadata(["service"]) 191 | import service as svc_mod # noqa: we monkey-patched this in the line above 192 | 193 | metadata, locations = get_metadata().serialize() 194 | 195 | self.assertEqual( 196 | { 197 | "$version": 4, 198 | "default": { 199 | "title": "Default", 200 | "apis": { 201 | "root": { 202 | "service": "service", 203 | "api_group": "default", 204 | "api_name": "root", 205 | "methods": ["GET"], 206 | "url": "/", 207 | "doc": "Documentation.", 208 | "changelog": {4: "changelog"}, 209 | "request_schema": {"type": "object"}, 210 | "response_schema": {"type": "object"}, 211 | "params_schema": { 212 | "type": "object", 213 | "properties": {"test": {"type": "string"}}, 214 | }, 215 | "introduced_at": 1, 216 | "title": "Root", 217 | } 218 | }, 219 | }, 220 | }, 221 | metadata, 222 | ) 223 | 224 | self.assertEqual( 225 | { 226 | "root": { 227 | "api": {"filename": fixture.path, "lineno": 4, "module": svc_mod}, 228 | "changelog": { 229 | 4: {"filename": fixture.path, "lineno": 5, "module": svc_mod} 230 | }, 231 | "request_schema": { 232 | "filename": fixture.path, 233 | "lineno": 8, 234 | "module": svc_mod, 235 | }, 236 | "response_schema": { 237 | "filename": fixture.path, 238 | "lineno": 9, 239 | "module": svc_mod, 240 | }, 241 | "params_schema": { 242 | "filename": fixture.path, 243 | "lineno": 10, 244 | "module": svc_mod, 245 | }, 246 | "view": {"filename": fixture.path, "lineno": 14, "module": svc_mod}, 247 | } 248 | }, 249 | locations, 250 | ) 251 | 252 | 253 | class LoadMetadataTests(testtools.TestCase): 254 | def metadata(self): 255 | metadata = OrderedDict() 256 | metadata["$version"] = 2 257 | metadata["group"] = dict(apis=dict()) 258 | metadata["group"]["apis"]["api1"] = { 259 | "api_group": "group", 260 | "api_name": "api1", 261 | "methods": ["GET"], 262 | "url": "/", 263 | "doc": "doc1", 264 | "changelog": {2: "change 2", 1: "change 1"}, 265 | "request_schema": {"request_schema": 1}, 266 | "response_schema": {"response_schema": 2}, 267 | "introduced_at": 1, 268 | "title": "Api1", 269 | } 270 | return metadata 271 | 272 | def test_load_json_metadata(self): 273 | json_file = tempfile.NamedTemporaryFile("w") 274 | json.dump(self.metadata(), json_file) 275 | json_file.flush() 276 | 277 | # json converts int keys to string 278 | with open(json_file.name) as fd: 279 | json_dict = json.load(fd) 280 | self.assertEqual( 281 | json_dict["group"]["apis"]["api1"]["changelog"], 282 | {"1": "change 1", "2": "change 2"}, 283 | ) 284 | 285 | with open(json_file.name) as fd: 286 | result = main.load_metadata(fd) 287 | 288 | self.assertEqual( 289 | result["group"]["apis"]["api1"]["changelog"], {1: "change 1", 2: "change 2"} 290 | ) 291 | 292 | 293 | def builder_installed(): 294 | return subprocess.call(["which", "documentation-builder"]) == 0 295 | 296 | 297 | class RenderMarkdownTests(testtools.TestCase): 298 | page = main.TEMPLATES.get_template("api_group.md.j2") 299 | index = main.TEMPLATES.get_template("index.md.j2") 300 | 301 | def metadata(self): 302 | metadata = OrderedDict() 303 | metadata["$version"] = 2 304 | metadata["group"] = dict(apis=dict()) 305 | metadata["group"]["apis"]["api1"] = { 306 | "api_group": "group", 307 | "api_name": "api1", 308 | "methods": ["GET"], 309 | "url": "/", 310 | "doc": "doc1", 311 | "changelog": {"1": "change"}, 312 | "request_schema": {"request_schema": 1}, 313 | "response_schema": {"response_schema": 2}, 314 | "introduced_at": 1, 315 | "title": "Api1", 316 | } 317 | metadata["group"]["apis"]["api2"] = { 318 | "api_group": "group", 319 | "api_name": "api1", 320 | "methods": ["GET"], 321 | "url": "/", 322 | "doc": "doc2", 323 | "changelog": {"2": "2nd change"}, 324 | "request_schema": {"request_schema": 1}, 325 | "response_schema": {"response_schema": 2}, 326 | "introduced_at": 1, 327 | "title": "Api2", 328 | } 329 | return metadata 330 | 331 | def test_render_markdown_success(self): 332 | args = main.parse_args(["render", "examples/api.json", "--name=SERVICE"]) 333 | 334 | with contextlib.closing(args.metadata): 335 | iterator = main.render_markdown(self.metadata(), args) 336 | output = OrderedDict((str(k), v) for k, v in iterator) 337 | 338 | self.assertEqual( 339 | {"en/group.md", "en/index.md", "en/metadata.yaml", "metadata.yaml"}, 340 | set(output), 341 | ) 342 | 343 | top_level_md = yaml.safe_load(output["metadata.yaml"]) 344 | self.assertEqual( 345 | {"site_title": "SERVICE Documentation: version 2"}, top_level_md 346 | ) 347 | 348 | md = yaml.safe_load(output["en/metadata.yaml"]) 349 | self.assertEqual( 350 | { 351 | "navigation": [ 352 | {"location": "index.md", "title": "Index"}, 353 | {"location": "group.md", "title": "Group"}, 354 | ] 355 | }, 356 | md, 357 | ) 358 | 359 | def test_render_markdown_undocumented(self): 360 | args = main.parse_args(["render", "examples/api.json", "--name=SERVICE"]) 361 | with contextlib.closing(args.metadata): 362 | m = self.metadata() 363 | m["group"]["apis"]["api2"]["undocumented"] = True 364 | iterator = main.render_markdown(m, args) 365 | output = OrderedDict((str(k), v) for k, v in iterator) 366 | 367 | self.assertEqual( 368 | {"en/group.md", "en/index.md", "en/metadata.yaml", "metadata.yaml"}, 369 | set(output), 370 | ) 371 | 372 | self.assertNotIn("api2", output["en/group.md"]) 373 | 374 | md = yaml.safe_load(output["en/metadata.yaml"]) 375 | self.assertEqual( 376 | { 377 | "navigation": [ 378 | {"location": "index.md", "title": "Index"}, 379 | {"location": "group.md", "title": "Group"}, 380 | ] 381 | }, 382 | md, 383 | ) 384 | 385 | def test_render_markdown_deprecated_at(self): 386 | args = main.parse_args(["render", "examples/api.json", "--name=SERVICE"]) 387 | with contextlib.closing(args.metadata): 388 | m = self.metadata() 389 | m["group"]["apis"]["api2"]["deprecated_at"] = 2 390 | iterator = main.render_markdown(m, args) 391 | output = OrderedDict((str(k), v) for k, v in iterator) 392 | 393 | self.assertEqual( 394 | {"en/group.md", "en/index.md", "en/metadata.yaml", "metadata.yaml"}, 395 | set(output), 396 | ) 397 | 398 | md = yaml.safe_load(output["en/metadata.yaml"]) 399 | self.assertEqual( 400 | { 401 | "navigation": [ 402 | {"location": "index.md", "title": "Index"}, 403 | {"location": "group.md", "title": "Group"}, 404 | ] 405 | }, 406 | md, 407 | ) 408 | 409 | def test_render_markdown_multiple_groups(self): 410 | args = main.parse_args(["render", "examples/api.json", "--name=SERVICE"]) 411 | with contextlib.closing(args.metadata): 412 | metadata = self.metadata() 413 | metadata["group2"] = { 414 | "apis": {"api2": metadata["group"]["apis"].pop("api2")} 415 | } 416 | iterator = main.render_markdown(metadata, args) 417 | output = OrderedDict((str(k), v) for k, v in iterator) 418 | 419 | self.assertEqual( 420 | { 421 | "en/group.md", 422 | "en/group2.md", 423 | "en/index.md", 424 | "en/metadata.yaml", 425 | "metadata.yaml", 426 | }, 427 | set(output), 428 | ) 429 | 430 | top_level_md = yaml.safe_load(output["metadata.yaml"]) 431 | self.assertEqual( 432 | {"site_title": "SERVICE Documentation: version 2"}, top_level_md 433 | ) 434 | 435 | md = yaml.safe_load(output["en/metadata.yaml"]) 436 | self.assertEqual( 437 | { 438 | "navigation": [ 439 | {"location": "index.md", "title": "Index"}, 440 | {"location": "group.md", "title": "Group"}, 441 | {"location": "group2.md", "title": "Group2"}, 442 | ] 443 | }, 444 | md, 445 | ) 446 | 447 | def test_render_markdown_group_omitted_with_undocumented(self): 448 | args = main.parse_args(["render", "examples/api.json", "--name=SERVICE"]) 449 | with contextlib.closing(args.metadata): 450 | metadata = self.metadata() 451 | metadata["group2"] = { 452 | "apis": {"api2": metadata["group"]["apis"].pop("api2")} 453 | } 454 | metadata["group2"]["apis"]["api2"]["undocumented"] = True 455 | iterator = main.render_markdown(metadata, args) 456 | output = OrderedDict((str(k), v) for k, v in iterator) 457 | 458 | self.assertEqual( 459 | {"en/group.md", "en/index.md", "en/metadata.yaml", "metadata.yaml"}, 460 | set(output), 461 | ) 462 | 463 | top_level_md = yaml.safe_load(output["metadata.yaml"]) 464 | self.assertEqual( 465 | {"site_title": "SERVICE Documentation: version 2"}, top_level_md 466 | ) 467 | 468 | md = yaml.safe_load(output["en/metadata.yaml"]) 469 | self.assertEqual( 470 | { 471 | "navigation": [ 472 | {"location": "index.md", "title": "Index"}, 473 | {"location": "group.md", "title": "Group"}, 474 | ] 475 | }, 476 | md, 477 | ) 478 | 479 | def test_render_cmd_with_templates(self): 480 | markdown_dir = self.useFixture(fixtures.TempDir()) 481 | with tempfile.NamedTemporaryFile("w") as metadata: 482 | with tempfile.NamedTemporaryFile("w", dir=os.getcwd()) as template: 483 | metadata.write(json.dumps(self.metadata())) 484 | metadata.flush() 485 | 486 | template.write("TEMPLATE") 487 | template.flush() 488 | name = os.path.relpath(template.name) 489 | 490 | args = main.parse_args( 491 | [ 492 | "render", 493 | metadata.name, 494 | "--name=SERVICE", 495 | "--dir={}".format(markdown_dir.path), 496 | "--page-template=" + name, 497 | "--index-template=" + name, 498 | ] 499 | ) 500 | main.render_cmd(args) 501 | 502 | p = partial(os.path.join, markdown_dir.path) 503 | with open(p("en/group.md")) as f: 504 | self.assertEqual(f.read(), "TEMPLATE") 505 | with open(p("en/index.md")) as f: 506 | self.assertEqual(f.read(), "TEMPLATE") 507 | 508 | @testtools.skipIf(not builder_installed(), "documentation-builder not installed") 509 | def test_render_cmd_with_documentation_builder(self): 510 | # documentation-builder is a strict snap, can only work out of $HOME 511 | home = os.environ["HOME"] 512 | markdown_dir = self.useFixture(fixtures.TempDir(rootdir=home)) 513 | html_dir = self.useFixture(fixtures.TempDir(rootdir=home)) 514 | 515 | with tempfile.NamedTemporaryFile("w") as metadata: 516 | metadata.write(json.dumps(self.metadata())) 517 | metadata.flush() 518 | 519 | args = main.parse_args( 520 | [ 521 | "render", 522 | metadata.name, 523 | "--name=SERVICE", 524 | "--dir={}".format(markdown_dir.path), 525 | ] 526 | ) 527 | main.render_cmd(args) 528 | 529 | build = [ 530 | "documentation-builder", 531 | "--base-directory={}".format(markdown_dir.path), 532 | "--output-path={}".format(html_dir.path), 533 | ] 534 | try: 535 | subprocess.check_output(build) 536 | except subprocess.CalledProcessError as e: 537 | print(e.output) 538 | raise 539 | 540 | p = partial(os.path.join, html_dir.path) 541 | self.assertTrue(os.path.exists(p("en/group.html"))) 542 | self.assertTrue(os.path.exists(p("en/index.html"))) 543 | 544 | 545 | EXPECTED_LINT_OUTPUT = [ 546 | ("examples/api.py", 7, " Error: API foo at request_schema.required"), 547 | ("examples/api.py", 7, " Warning: API foo at request_schema.foo.description"), 548 | ( 549 | "examples/api.py", 550 | 23, 551 | " Warning: API foo at response_schema.foo_result.description", 552 | ), 553 | ( 554 | "examples/api.py", 555 | 23, 556 | " Documentation: API foo at response_schema.foo_result.introduced_at", 557 | ), 558 | ] 559 | 560 | 561 | class LintTests(testtools.TestCase): 562 | def test_basic_api_changes(self): 563 | self.useFixture(CleanUpModuleImport("examples.api")) 564 | 565 | args = main.parse_args(["lint", "examples/api.json", "examples.api"]) 566 | 567 | output = io.StringIO() 568 | result = main.lint_cmd(args, stream=output) 569 | self.assertEqual(1, result) 570 | lines = output.getvalue().splitlines() 571 | 572 | for actual, expected in zip(lines, EXPECTED_LINT_OUTPUT): 573 | self.assertIn(":".join(map(str, expected)), actual) 574 | 575 | def test_openapi_output(self): 576 | self.useFixture(CleanUpModuleImport("examples.oas_testcase")) 577 | 578 | # Given the collection of files "examples/oas_testcase.*" 579 | # When we perform a lint-update 580 | args = main.parse_args( 581 | [ 582 | "lint", 583 | "--update", 584 | "examples/oas_testcase_api.json", 585 | "examples.oas_testcase", 586 | ] 587 | ) 588 | 589 | output = io.StringIO() 590 | result = main.lint_cmd(args, stream=output) 591 | 592 | # Then we exit without error 593 | self.assertEqual(0, result) 594 | 595 | # And there is no content in the output 596 | self.assertEqual([], output.getvalue().splitlines()) 597 | 598 | # And the OpenAPI file contains the expected value 599 | with open("examples/oas_testcase_openapi.yaml", "r") as _result: 600 | result = _result.readlines() 601 | 602 | with open("examples/oas_testcase_expected.yaml", "r") as _expected: 603 | expected = _expected.readlines() 604 | 605 | self.assertListEqual(expected, result) 606 | 607 | # And we implicitly assume the files have not changed 608 | 609 | 610 | class VersionTests(testtools.TestCase): 611 | def test_version(self): 612 | self.useFixture(CleanUpModuleImport("examples.api")) 613 | 614 | args = main.parse_args(["api-version", "examples/api.json", "examples.api"]) 615 | 616 | output = io.StringIO() 617 | result = main.version_cmd(args, stream=output) 618 | self.assertEqual(0, result) 619 | self.assertEqual("examples/api.json: 2\nexamples.api: 5\n", output.getvalue()) 620 | -------------------------------------------------------------------------------- /acceptable/tests/test_lint.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Canonical Ltd. This software is licensed under the 2 | # GNU Lesser General Public License version 3 (see the file LICENSE). 3 | import testtools 4 | 5 | from acceptable import get_metadata, lint 6 | from acceptable.__main__ import import_metadata 7 | from acceptable.tests.test_main import TemporaryModuleFixture 8 | 9 | 10 | class LintTestCase(testtools.TestCase): 11 | def get_metadata(self, code="", module="service", locations=True): 12 | fixture = self.useFixture(TemporaryModuleFixture(module, code)) 13 | import_metadata([module]) 14 | metadata, locations = get_metadata().serialize() 15 | return metadata, locations, fixture.path 16 | 17 | 18 | class LintTests(LintTestCase): 19 | def test_not_modify(self): 20 | metadata, locations, path = self.get_metadata( 21 | """ 22 | from acceptable import * 23 | service = AcceptableService('myservice', 'group') 24 | api = service.api('/', 'api') 25 | 26 | @api 27 | def view(): 28 | "Docs" 29 | """ 30 | ) 31 | 32 | self.assertIn("$version", metadata) 33 | orig = metadata.copy() 34 | list(lint.metadata_lint(metadata, metadata, locations)) 35 | self.assertEqual(metadata, orig) 36 | 37 | def test_missing_api_documentation(self): 38 | metadata, locations, path = self.get_metadata( 39 | """ 40 | from acceptable import * 41 | service = AcceptableService('myservice', 'group') 42 | api = service.api('/', 'api', introduced_at=1) 43 | 44 | @api 45 | def view(): 46 | pass 47 | """ 48 | ) 49 | 50 | msgs = list(lint.metadata_lint(metadata, metadata, locations)) 51 | self.assertEqual(msgs[0].level, lint.WARNING) 52 | self.assertEqual("doc", msgs[0].name) 53 | self.assertEqual("api", msgs[0].api_name) 54 | self.assertEqual(msgs[0].location["filename"], path) 55 | self.assertEqual(msgs[0].location["lineno"], 7) 56 | 57 | # test with new api 58 | msgs = list(lint.metadata_lint({}, metadata, locations)) 59 | self.assertEqual(msgs[0].level, lint.ERROR) 60 | self.assertEqual("doc", msgs[0].name) 61 | self.assertEqual("api", msgs[0].api_name) 62 | self.assertEqual(msgs[0].location["filename"], path) 63 | self.assertEqual(msgs[0].location["lineno"], 7) 64 | 65 | def test_missing_introduced_at(self): 66 | metadata, locations, path = self.get_metadata( 67 | """ 68 | from acceptable import * 69 | service = AcceptableService('myservice', 'group') 70 | api = service.api('/', 'api') 71 | 72 | @api 73 | def view(): 74 | "Docs" 75 | """ 76 | ) 77 | 78 | msgs = list(lint.metadata_lint({}, metadata, locations)) 79 | self.assertEqual(msgs[0].level, lint.ERROR) 80 | self.assertEqual("introduced_at", msgs[0].name) 81 | self.assertEqual("api", msgs[0].api_name) 82 | self.assertEqual(msgs[0].location["filename"], path) 83 | self.assertEqual(msgs[0].location["lineno"], 3) 84 | 85 | def test_changed_introduced_at(self): 86 | old, _, _ = self.get_metadata( 87 | module="old", 88 | code=""" 89 | from acceptable import * 90 | service = AcceptableService('myservice', 'group') 91 | api = service.api('/', 'api', introduced_at=1) 92 | 93 | @api 94 | def view(): 95 | "Docs" 96 | """, 97 | ) 98 | 99 | new, locations, _ = self.get_metadata( 100 | module="new", 101 | code=""" 102 | from acceptable import * 103 | service = AcceptableService('myservice', 'group') 104 | api = service.api('/', 'api', introduced_at=2) 105 | 106 | @api 107 | def view(): 108 | "Docs" 109 | """, 110 | ) 111 | 112 | msgs = list(lint.metadata_lint(old, new, locations)) 113 | self.assertEqual(msgs[0].level, lint.ERROR) 114 | self.assertEqual("introduced_at", msgs[0].name) 115 | self.assertIn("changed from 1 to 2", msgs[0].msg) 116 | 117 | # new api shouldn't warn about introduced at 118 | msgs = list(lint.metadata_lint({}, new, locations)) 119 | self.assertEqual(0, len(msgs)) 120 | 121 | def test_method_added_is_ok(self): 122 | old, _, _ = self.get_metadata( 123 | module="old", 124 | code=""" 125 | from acceptable import * 126 | service = AcceptableService('myservice', 'group') 127 | api = service.api('/', 'api', introduced_at=1) 128 | 129 | @api 130 | def view(): 131 | "Docs" 132 | """, 133 | ) 134 | 135 | new, locations, _ = self.get_metadata( 136 | module="new", 137 | code=""" 138 | from acceptable import * 139 | service = AcceptableService('myservice', 'group') 140 | api = service.api( 141 | '/', 'api', introduced_at=1, methods=['GET', 'POST']) 142 | 143 | @api 144 | def view(): 145 | "Docs" 146 | """, 147 | ) 148 | 149 | self.assertEqual([], list(lint.metadata_lint(old, new, locations))) 150 | 151 | def test_method_removed_is_error(self): 152 | old, _, _ = self.get_metadata( 153 | module="old", 154 | code=""" 155 | from acceptable import * 156 | service = AcceptableService('myservice', 'group') 157 | api = service.api('/', 'api', introduced_at=1) 158 | 159 | @api 160 | def view(): 161 | "Docs" 162 | """, 163 | ) 164 | 165 | new, locations, _ = self.get_metadata( 166 | module="new", 167 | code=""" 168 | from acceptable import * 169 | service = AcceptableService('myservice', 'group') 170 | api = service.api( 171 | '/', 'api', introduced_at=1, methods=['POST']) 172 | 173 | @api 174 | def view(): 175 | "Docs" 176 | """, 177 | ) 178 | 179 | msgs = list(lint.metadata_lint(old, new, locations)) 180 | self.assertEqual(msgs[0].level, lint.ERROR) 181 | self.assertEqual("methods", msgs[0].name) 182 | self.assertIn("GET removed", msgs[0].msg) 183 | 184 | def test_url_changed_is_error(self): 185 | old, _, _ = self.get_metadata( 186 | module="old", 187 | code=""" 188 | from acceptable import * 189 | service = AcceptableService('myservice', 'group') 190 | api = service.api('/', 'api', introduced_at=1) 191 | 192 | @api 193 | def view(): 194 | "Docs" 195 | """, 196 | ) 197 | 198 | new, locations, _ = self.get_metadata( 199 | module="new", 200 | code=""" 201 | from acceptable import * 202 | service = AcceptableService('myservice', 'group') 203 | api = service.api('/other', 'api', introduced_at=1) 204 | 205 | @api 206 | def view(): 207 | "Docs" 208 | """, 209 | ) 210 | 211 | msgs = list(lint.metadata_lint(old, new, locations)) 212 | self.assertEqual(msgs[0].level, lint.ERROR) 213 | self.assertEqual("url", msgs[0].name) 214 | self.assertIn("/other", msgs[0].msg) 215 | 216 | def test_required_on_new_api_ok(self): 217 | old, _, _ = self.get_metadata( 218 | module="old", 219 | code=""" 220 | from acceptable import * 221 | service = AcceptableService('myservice', 'group') 222 | """, 223 | ) 224 | 225 | new, locations, _ = self.get_metadata( 226 | module="new", 227 | code=""" 228 | from acceptable import * 229 | service = AcceptableService('myservice', 'group') 230 | api = service.api('/other', 'api', introduced_at=1) 231 | api.request_schema = { 232 | 'type': 'object', 233 | 'properties': { 234 | 'context': {'type': 'string', 'description': 'Context'} 235 | }, 236 | 'required': ['context'] 237 | } 238 | 239 | @api 240 | def view(): 241 | "Docs" 242 | """, 243 | ) 244 | self.assertEqual([], [str(i) for i in lint.metadata_lint(old, new, locations)]) 245 | 246 | def test_required_on_existing_api_is_error(self): 247 | old, _, _ = self.get_metadata( 248 | module="old", 249 | code=""" 250 | from acceptable import * 251 | service = AcceptableService('myservice', 'group') 252 | 253 | api = service.api('/other', 'api', introduced_at=1) 254 | 255 | @api 256 | def view(): 257 | "Docs" 258 | """, 259 | ) 260 | 261 | new, locations, _ = self.get_metadata( 262 | module="new", 263 | code=""" 264 | from acceptable import * 265 | service = AcceptableService('myservice', 'group') 266 | api = service.api('/other', 'api', introduced_at=1) 267 | api.request_schema = { 268 | 'type': 'object', 269 | 'properties': { 270 | 'context': { 271 | 'type': 'string', 272 | 'introduced_at': 2, 273 | 'description': 'Context' 274 | } 275 | }, 276 | 'required': ['context'] 277 | } 278 | api.changelog(2, 'Added context field') 279 | 280 | @api 281 | def view(): 282 | "Docs" 283 | """, 284 | ) 285 | self.assertEqual( 286 | ["Cannot require new field context"], 287 | [i.msg for i in lint.metadata_lint(old, new, locations)], 288 | ) 289 | 290 | def test_schema_removed_is_error(self): 291 | old, _, _ = self.get_metadata( 292 | module="old", 293 | code=""" 294 | from acceptable import * 295 | service = AcceptableService('myservice', 'group') 296 | api = service.api('/other', 'api', introduced_at=1) 297 | api.request_schema = { 298 | 'type': 'object', 299 | 'properties': { 300 | 'context': {'type': 'string', 'description': 'Context'} 301 | }, 302 | 'required': ['context'] 303 | } 304 | 305 | @api 306 | def view(): 307 | "Docs" 308 | """, 309 | ) 310 | 311 | new, locations, _ = self.get_metadata( 312 | module="new", 313 | code=""" 314 | from acceptable import * 315 | service = AcceptableService('myservice', 'group') 316 | api = service.api('/other', 'api', introduced_at=1) 317 | 318 | @api 319 | def view(): 320 | "Docs" 321 | """, 322 | ) 323 | self.assertEqual( 324 | ["Request schema removed"], 325 | [i.msg for i in lint.metadata_lint(old, new, locations)], 326 | ) 327 | 328 | def test_changelog_required_for_revision_new_api(self): 329 | old, _, _ = self.get_metadata( 330 | module="old", 331 | code=""" 332 | """, 333 | ) 334 | 335 | new, locations, _ = self.get_metadata( 336 | module="new", 337 | code=""" 338 | from acceptable import * 339 | service = AcceptableService('myservice', 'group') 340 | 341 | api = service.api('/other', 'api', introduced_at=1) 342 | api.request_schema = { 343 | 'type': 'object', 344 | 'properties': { 345 | 'context': { 346 | 'type': 'string', 347 | 'introduced_at': 2, 348 | 'description': 'Context' 349 | } 350 | }, 351 | } 352 | 353 | @api 354 | def view(): 355 | "Docs" 356 | """, 357 | ) 358 | self.assertEqual( 359 | ["No changelog entry for revision 2"], 360 | [i.msg for i in lint.metadata_lint(old, new, locations)], 361 | ) 362 | 363 | def test_changelog_required_for_revision_existing_api(self): 364 | old, _, _ = self.get_metadata( 365 | module="old", 366 | code=""" 367 | from acceptable import * 368 | service = AcceptableService('myservice', 'group') 369 | 370 | api = service.api('/other', 'api', introduced_at=1) 371 | api.request_schema = { 372 | 'type': 'object', 373 | 'properties': { 374 | }, 375 | } 376 | 377 | @api 378 | def view(): 379 | "Docs" 380 | """, 381 | ) 382 | 383 | new, locations, _ = self.get_metadata( 384 | module="new", 385 | code=""" 386 | from acceptable import * 387 | service = AcceptableService('myservice', 'group') 388 | 389 | api = service.api('/other', 'api', introduced_at=1) 390 | api.request_schema = { 391 | 'type': 'object', 392 | 'properties': { 393 | 'context': { 394 | 'type': 'string', 395 | 'introduced_at': 2, 396 | 'description': 'Context' 397 | } 398 | }, 399 | } 400 | 401 | @api 402 | def view(): 403 | "Docs" 404 | """, 405 | ) 406 | self.assertEqual( 407 | ["No changelog entry for revision 2"], 408 | [i.msg for i in lint.metadata_lint(old, new, locations)], 409 | ) 410 | 411 | 412 | class WalkSchemaTests(LintTestCase): 413 | def test_type_changed_is_error(self): 414 | old = {"type": "string"} 415 | new = {"type": "object"} 416 | 417 | msgs = list(lint.walk_schema("name", old, new, root=True)) 418 | self.assertEqual(1, len(msgs)) 419 | self.assertEqual(msgs[0].level, lint.ERROR) 420 | self.assertEqual("name.type", msgs[0].name) 421 | self.assertIn("remove type string", msgs[0].msg) 422 | 423 | def test_type_changed_is_error_multiple_types(self): 424 | old = {"type": ["string", "object"]} 425 | new = {"type": "object"} 426 | 427 | msgs = list(lint.walk_schema("name", old, new, root=True)) 428 | self.assertEqual(1, len(msgs)) 429 | self.assertEqual(msgs[0].level, lint.ERROR) 430 | self.assertEqual("name.type", msgs[0].name) 431 | self.assertIn("remove type string", msgs[0].msg) 432 | 433 | def test_added_required_is_error(self): 434 | old = { 435 | "type": "object", 436 | "required": ["foo"], 437 | "properties": { 438 | "foo": { 439 | "type": "string", 440 | "description": "description", 441 | "introduced_at": 1, 442 | }, 443 | "bar": { 444 | "type": "string", 445 | "description": "description", 446 | "introduced_at": 1, 447 | }, 448 | }, 449 | } 450 | new = { 451 | "type": "object", 452 | "required": ["foo", "bar"], 453 | "properties": { 454 | "foo": { 455 | "type": "string", 456 | "description": "description", 457 | "introduced_at": 1, 458 | }, 459 | "bar": { 460 | "type": "string", 461 | "description": "description", 462 | "introduced_at": 1, 463 | }, 464 | }, 465 | } 466 | msgs = list(lint.walk_schema("name", old, new, root=True)) 467 | self.assertEqual(3, len(msgs)) 468 | self.assertEqual("name.required", msgs[0].name) 469 | self.assertIn("bar", msgs[0].msg) 470 | 471 | self.assertIsInstance(msgs[1], lint.CheckChangelog) 472 | self.assertIsInstance(msgs[2], lint.CheckChangelog) 473 | 474 | def test_delete_property_is_error(self): 475 | old = { 476 | "type": "object", 477 | "properties": {"foo": {"type": "string"}, "bar": {"type": "string"}}, 478 | } 479 | new = { 480 | "type": "object", 481 | "properties": { 482 | "foo": { 483 | "type": "string", 484 | "description": "description", 485 | "introduced_at": 1, 486 | } 487 | }, 488 | } 489 | 490 | msgs = list(lint.walk_schema("name", old, new, root=True)) 491 | self.assertEqual(2, len(msgs)) 492 | self.assertEqual(msgs[0].level, lint.ERROR) 493 | self.assertEqual("name.bar", msgs[0].name) 494 | 495 | self.assertIsInstance(msgs[1], lint.CheckChangelog) 496 | 497 | def test_missing_doc_and_introduced_when_adding_new_field(self): 498 | old = {"type": "object"} 499 | 500 | new = {"type": "object", "properties": {"foo": {"type": "object"}}} 501 | msgs = list(lint.walk_schema("name", old, new, root=True)) 502 | self.assertEqual(2, len(msgs)) 503 | 504 | self.assertEqual(msgs[0].level, lint.WARNING) 505 | self.assertEqual("name.foo.description", msgs[0].name) 506 | self.assertIn("missing", msgs[0].msg) 507 | 508 | self.assertEqual(msgs[1].level, lint.DOCUMENTATION) 509 | self.assertEqual("name.foo.introduced_at", msgs[1].name) 510 | self.assertIn("missing", msgs[1].msg) 511 | 512 | def test_no_introduced_at_when_present_in_old(self): 513 | old = {"type": "object", "properties": {"foo": {"type": "object"}}} 514 | 515 | new = { 516 | "type": "object", 517 | "properties": {"foo": {"type": "object", "description": "description"}}, 518 | } 519 | msgs = list(lint.walk_schema("name", old, new, root=True)) 520 | self.assertEqual(0, len(msgs)) 521 | 522 | def test_missing_introduced_at_skipped_if_new_api(self): 523 | old = {"type": "object"} 524 | 525 | new = {"type": "object", "properties": {"foo": {"type": "object"}}} 526 | 527 | msgs = list(lint.walk_schema("name", old, new, root=True, new_api=True)) 528 | self.assertEqual(1, len(msgs)) 529 | self.assertEqual(msgs[0].level, lint.WARNING) 530 | self.assertEqual("name.foo.description", msgs[0].name) 531 | self.assertIn("missing", msgs[0].msg) 532 | 533 | def test_nested_objects(self): 534 | old = { 535 | "type": "object", 536 | "properties": { 537 | "foo": { 538 | "type": "object", 539 | "required": ["bar"], 540 | "properties": { 541 | "bar": {"type": "string"}, 542 | "baz": { 543 | "type": "string", 544 | "description": "description", 545 | "introduced_at": 2, 546 | }, 547 | }, 548 | } 549 | }, 550 | } 551 | new = { 552 | "type": "object", 553 | "properties": { 554 | "foo": { 555 | "description": "description", 556 | "type": "object", 557 | "required": ["bar", "foo"], # error for required change 558 | "properties": { 559 | "bar": { 560 | "type": "object", # type changed 561 | "description": "description", 562 | }, 563 | "baz": { 564 | "type": "string", 565 | "description": "description", 566 | "introduced_at": 3, # changed 567 | }, 568 | }, 569 | } 570 | }, 571 | } 572 | 573 | msgs = list(lint.walk_schema("name", old, new, root=True)) 574 | self.assertEqual(3, len(msgs)) 575 | 576 | self.assertEqual(msgs[0].level, lint.ERROR) 577 | self.assertEqual("name.foo.required", msgs[0].name) 578 | 579 | self.assertEqual(msgs[1].level, lint.ERROR) 580 | self.assertEqual("name.foo.bar.type", msgs[1].name) 581 | 582 | self.assertEqual(msgs[2].level, lint.ERROR) 583 | self.assertEqual("name.foo.baz.introduced_at", msgs[2].name) 584 | 585 | def test_arrays(self): 586 | old = { 587 | "type": "array", 588 | "items": { 589 | "description": "description", 590 | "type": "object", 591 | "properties": {"foo": {"type": "object", "description": "description"}}, 592 | }, 593 | } 594 | new = { 595 | "type": "array", 596 | "items": { 597 | "description": "description", 598 | "type": "object", 599 | "properties": { 600 | "foo": {"type": "object", "description": "description"}, 601 | "bar": {"type": "object"}, 602 | }, 603 | }, 604 | } 605 | 606 | msgs = list(lint.walk_schema("name", old, new, root=True)) 607 | 608 | self.assertEqual(2, len(msgs)) 609 | 610 | self.assertEqual(msgs[0].level, lint.WARNING) 611 | self.assertEqual("name.items.bar.description", msgs[0].name) 612 | 613 | self.assertEqual(msgs[1].level, lint.DOCUMENTATION) 614 | self.assertEqual("name.items.bar.introduced_at", msgs[1].name) 615 | 616 | def test_new_api_introduced_at_enforced(self): 617 | old, _, _ = self.get_metadata( 618 | module="old", 619 | code=""" 620 | from acceptable import AcceptableService 621 | service = AcceptableService('myservice', 'group') 622 | api1 = service.api('/api1', 'api1', introduced_at=1) 623 | @api1 624 | def view(): 625 | "Docs" 626 | """, 627 | ) 628 | 629 | new, locations, _ = self.get_metadata( 630 | module="new", 631 | code=""" 632 | from acceptable import AcceptableService 633 | service = AcceptableService('myservice', 'group') 634 | api1 = service.api('/api1', 'api1', introduced_at=1) 635 | @api1 636 | def view1(): 637 | "Docs" 638 | 639 | api2 = service.api('/api2', 'api2', introduced_at=1) 640 | @api2 641 | def view2(): 642 | "Docs" 643 | """, 644 | ) 645 | self.assertEqual( 646 | ["introduced_at should be > 1"], 647 | [i.msg for i in lint.metadata_lint(old, new, locations)], 648 | ) 649 | 650 | def test_new_api_introduced_at_is_int(self): 651 | old, _, _ = self.get_metadata( 652 | module="old", 653 | code=""" 654 | from acceptable import AcceptableService 655 | service = AcceptableService('myservice', 'group') 656 | """, 657 | ) 658 | 659 | new, locations, _ = self.get_metadata( 660 | module="new", 661 | code=""" 662 | from acceptable import AcceptableService 663 | service = AcceptableService('myservice', 'group') 664 | api1 = service.api('/api1', 'api1', introduced_at="1") 665 | @api1 666 | def view1(): 667 | "Docs" 668 | """, 669 | ) 670 | self.assertEqual( 671 | ["introduced_at should be an integer"], 672 | [i.msg for i in lint.metadata_lint(old, new, locations)], 673 | ) 674 | --------------------------------------------------------------------------------