├── tests ├── demoapp │ └── demo │ │ ├── views.py │ │ ├── __init__.py │ │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ │ ├── admin.py │ │ ├── serializers.py │ │ ├── urls.py │ │ ├── models.py │ │ ├── api.py │ │ ├── factories.py │ │ └── settings.py ├── test_demo.py ├── _api_checker_2.2 │ ├── test_api │ │ ├── TestUrls │ │ │ ├── fixtures.json │ │ │ ├── _master_detail_101_ │ │ │ │ └── get │ │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ └── _master_list_ │ │ │ │ └── get │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ ├── Test1DemoApi │ │ │ ├── fixtures.json │ │ │ ├── _master_delete_1_ │ │ │ │ └── delete │ │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── _master_update_1_ │ │ │ │ └── put │ │ │ │ │ └── 10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json │ │ │ ├── _master_detail_1_ │ │ │ │ └── get │ │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── _master_list_ │ │ │ │ └── get │ │ │ │ │ ├── 240f5ff9499fabe7952369a2a095ad1d8dedba650ea844f3fa0027e5ddc12f49.response.json │ │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── add_field │ │ │ │ └── get │ │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ └── remove_field │ │ │ │ └── get │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ └── Test2DemoApi │ │ │ ├── fixtures.json │ │ │ ├── _master_delete_1_ │ │ │ └── delete │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── _master_create_ │ │ │ └── post │ │ │ │ └── 10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json │ │ │ ├── _master_update_1_ │ │ │ └── put │ │ │ │ └── 10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json │ │ │ ├── _master_detail_1_ │ │ │ └── get │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── _master_list_ │ │ │ └── get │ │ │ │ ├── 240f5ff9499fabe7952369a2a095ad1d8dedba650ea844f3fa0027e5ddc12f49.response.json │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ ├── add_field │ │ │ └── get │ │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ │ └── remove_field │ │ │ └── get │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ └── test_pytest │ │ ├── test_url_delete │ │ └── _master_delete_1_ │ │ │ └── delete │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ ├── frozen_detail.fixture.json │ │ ├── test_url_post │ │ └── _master_create_ │ │ │ └── post │ │ │ └── bf4f4f4c8448f5a38fe96411603d22a6aedab96466c044e0ba5bc10bb5aaf3fe.response.json │ │ ├── test_url_get │ │ └── _master_list_ │ │ │ └── get │ │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ └── test_parametrize │ │ └── _master_list_ │ │ ├── get │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json │ │ └── options │ │ └── dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json ├── .coveragerc ├── test_collector.py ├── conftest.py ├── test_pytest.py ├── test_recorder.py ├── test_utils.py └── test_api.py ├── src └── drf_api_checker │ ├── __init__.py │ ├── apps.py │ ├── fs.py │ ├── collector.py │ ├── exceptions.py │ ├── pytest.py │ ├── utils.py │ ├── unittest.py │ └── recorder.py ├── docs ├── pytest.rst ├── globals.txt ├── install.rst ├── unittest_recipes.rst ├── api.rst ├── unittest.rst ├── index.rst ├── pytest_recipes.rst ├── overview.txt ├── _ext │ ├── version.py │ ├── github.py │ └── djangodocs.py └── conf.py ├── .gitignore ├── MANIFEST.in ├── .bumpversion.cfg ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── tests.yml ├── pyproject.toml ├── .pre-commit-config.yaml ├── Makefile ├── LICENSE ├── tox.ini ├── CHANGES └── README.md /tests/demoapp/demo/views.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demoapp/demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/drf_api_checker/__init__.py: -------------------------------------------------------------------------------- 1 | NAME = "drf-api-checker" 2 | VERSION = __version__ = "0.13" 3 | __author__ = "Stefano Apostolico" 4 | -------------------------------------------------------------------------------- /src/drf_api_checker/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class Config(AppConfig): 5 | name = "drf_api_checker" 6 | -------------------------------------------------------------------------------- /docs/pytest.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _pytest: 4 | 5 | PyTest 6 | ====== 7 | 8 | pytest is supported via :ref:`frozenfixture` and :ref:`contract` 9 | 10 | -------------------------------------------------------------------------------- /tests/demoapp/demo/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib.admin import ModelAdmin, register 2 | 3 | from .models import Master 4 | 5 | 6 | @register(Master) 7 | class MasterAdmin(ModelAdmin): 8 | pass 9 | -------------------------------------------------------------------------------- /tests/test_demo.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from demo.models import Master 3 | 4 | 5 | @pytest.mark.django_db(transaction=False) 6 | def test_model(): 7 | m = Master() 8 | m.save() 9 | assert m.pk 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | *.pyc 3 | *.pyo 4 | *.sqlite 5 | *.~* 6 | *credentials.* 7 | .cache 8 | .coverage 9 | .idea 10 | .tox 11 | /dist 12 | /media 13 | __pycache__ 14 | CACHE 15 | celerybeat-schedule 16 | coverage.xml 17 | djcelery.schedulers.DatabaseScheduler 18 | local.yaml 19 | pytest.xml 20 | ~* 21 | .project 22 | .pydevproject 23 | poetry.lock 24 | .venv/ 25 | .envrc 26 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README 2 | include CHANGES 3 | include LICENSE 4 | include MANIFEST.in 5 | include pyproject.toml 6 | 7 | recursive-include docs * 8 | recursive-include src/requirements *.pip 9 | recursive-include src/drf_api_checker * 10 | 11 | prune docs 12 | prune tests 13 | prune .github 14 | 15 | exclude Makefile 16 | exclude tox.ini 17 | exclude Pipfile 18 | exclude Pipfile.lock 19 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.12 3 | commit = False 4 | tag = False 5 | allow_dirty = True 6 | parse = (?P\d+)\.(?P\d+)\.(?P\d+) 7 | serialize = 8 | {major}.{minor}.{release} 9 | message = 10 | Bump version: {current_version} → {new_version} 11 | 12 | [bumpversion:file:pyproject.toml] 13 | 14 | [bumpversion:file:src/drf_api_checker/__init__.py] 15 | 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * DRF API Checker version: 2 | * Django version: 3 | * Python version: 4 | * Operating System: 5 | 6 | ### Description 7 | 8 | Describe what you were trying to get done. 9 | Tell us what happened, what went wrong, and what you expected to happen. 10 | 11 | ### What I Did 12 | 13 | ``` 14 | Paste the command(s) you ran and the output. 15 | If there was a crash, please include the traceback here. 16 | ``` 17 | -------------------------------------------------------------------------------- /tests/demoapp/demo/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Detail, Master 4 | 5 | 6 | class MasterSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Master 9 | fields = ("id", "name", "capabilities", "timestamp") 10 | 11 | 12 | class DetailSerializer(serializers.ModelSerializer): 13 | class Meta: 14 | model = Detail 15 | exclude = () 16 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/TestUrls/fixtures.json: -------------------------------------------------------------------------------- 1 | { 2 | "master": { 3 | "master": { 4 | "model": "demo.master", 5 | "pk": 101, 6 | "fields": { 7 | "name": "Name 014", 8 | "alias": "Alias 014", 9 | "timestamp": "2020-09-10T10:34:51.429", 10 | "capabilities": [] 11 | } 12 | }, 13 | "deps": [] 14 | } 15 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/fixtures.json: -------------------------------------------------------------------------------- 1 | { 2 | "master": { 3 | "master": { 4 | "model": "demo.master", 5 | "pk": 1, 6 | "fields": { 7 | "name": "Name 012", 8 | "alias": "Alias 012", 9 | "timestamp": "2020-09-10T10:34:50.225", 10 | "capabilities": [] 11 | } 12 | }, 13 | "deps": [] 14 | } 15 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/fixtures.json: -------------------------------------------------------------------------------- 1 | { 2 | "master": { 3 | "master": { 4 | "model": "demo.master", 5 | "pk": 1, 6 | "fields": { 7 | "name": "Name 013", 8 | "alias": "Alias 013", 9 | "timestamp": "2020-09-10T10:34:50.882", 10 | "capabilities": [] 11 | } 12 | }, 13 | "deps": [] 14 | } 15 | } -------------------------------------------------------------------------------- /docs/globals.txt: -------------------------------------------------------------------------------- 1 | 2 | .. _pip: http://pip.openplans.org/ 3 | .. _PyPI: http://pypi.python.org/ 4 | .. _South: http://south.aeracode.org/ 5 | .. _ol: http://sites.fas.harvard.edu/~cs265/papers/kung-1981.pdf 6 | .. _protocol: http://en.wikipedia.org/wiki/Optimistic_concurrency_control 7 | .. _issue_11313: https://code.djangoproject.com/ticket/11313 8 | .. _factory_boy: https://factoryboy.readthedocs.io/en/latest/ 9 | 10 | .. |pkg| replace:: **drf-api-checker** 11 | .. |version| replace:: 0.1 12 | 13 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _help: 4 | 5 | Install 6 | ======= 7 | 8 | Using ``pip``:: 9 | 10 | pip install drf-api-checker 11 | 12 | Go to https://github.com/saxix/drf-api-checker if you need to download a package or clone the repo. 13 | 14 | 15 | |pkg| does not need to be added into ``INSTALLED_APPS`` 16 | 17 | 18 | .. _test_suite: 19 | 20 | 21 | How to run the tests 22 | -------------------- 23 | 24 | .. code-block:: bash 25 | 26 | $ pip install tox 27 | $ tox 28 | 29 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/_master_delete_1_/delete/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 204, 3 | "headers": { 4 | "vary": [ 5 | "Vary", 6 | "Accept, Cookie" 7 | ], 8 | "allow": [ 9 | "Allow", 10 | "DELETE, OPTIONS" 11 | ], 12 | "x-frame-options": [ 13 | "X-Frame-Options", 14 | "SAMEORIGIN" 15 | ], 16 | "content-length": [ 17 | "Content-Length", 18 | "0" 19 | ] 20 | }, 21 | "data": null, 22 | "content_type": null 23 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_delete_1_/delete/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 204, 3 | "headers": { 4 | "vary": [ 5 | "Vary", 6 | "Accept, Cookie" 7 | ], 8 | "allow": [ 9 | "Allow", 10 | "DELETE, OPTIONS" 11 | ], 12 | "x-frame-options": [ 13 | "X-Frame-Options", 14 | "SAMEORIGIN" 15 | ], 16 | "content-length": [ 17 | "Content-Length", 18 | "0" 19 | ] 20 | }, 21 | "data": null, 22 | "content_type": null 23 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/test_url_delete/_master_delete_1_/delete/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 204, 3 | "headers": { 4 | "vary": [ 5 | "Vary", 6 | "Accept, Cookie" 7 | ], 8 | "allow": [ 9 | "Allow", 10 | "DELETE, OPTIONS" 11 | ], 12 | "x-frame-options": [ 13 | "X-Frame-Options", 14 | "SAMEORIGIN" 15 | ], 16 | "content-length": [ 17 | "Content-Length", 18 | "0" 19 | ] 20 | }, 21 | "data": null, 22 | "content_type": null 23 | } -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "drf-api-checker" 3 | version = "0.13" 4 | description = "" 5 | authors = ["sax "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | djangorestframework = "*" 11 | pytz = "*" 12 | Django = "*" 13 | 14 | [tool.poetry.group.dev.dependencies] 15 | django-webtest = "*" 16 | factory-boy = "*" 17 | pyfakefs = "*" 18 | pytest-cov = "*" 19 | pytest-django = "*" 20 | pytest-echo = "*" 21 | pytest-pythonpath = "*" 22 | python-dateutil = "*" 23 | pdbpp = "*" 24 | sphinx = "*" 25 | tox-poetry = {extras = ["poetry"], version = "*"} 26 | 27 | [build-system] 28 | requires = ["poetry"] 29 | build-backend = "poetry.masonry.api" 30 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = drf_api_checker 4 | include = 5 | 6 | omit = tests/** 7 | 8 | [report] 9 | # Regexes for lines to exclude from consideration 10 | exclude_lines = 11 | # Have to re-enable the standard pragma 12 | pragma: no cover 13 | # Don't complain about missing debug-only code: 14 | def __repr__ 15 | if self\.debug 16 | # Don't complain if tests don't hit defensive assertion code: 17 | raise AssertionError 18 | raise NotImplementedError 19 | except ImportError 20 | # Don't complain if non-runnable code isn't run: 21 | #if 0: 22 | if __name__ == .__main__.: 23 | 24 | ignore_errors = True 25 | 26 | [html] 27 | directory = ~build/coverage 28 | -------------------------------------------------------------------------------- /docs/unittest_recipes.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _unittest_recipes: 4 | 5 | Unittest Recipes 6 | ================ 7 | 8 | 9 | Check ``DateTimeField()`` with ``auto_now=True`` 10 | ------------------------------------------------ 11 | 12 | Add a method ``assert_`` that check by format instead 13 | 14 | .. code-block:: python 15 | 16 | class TestUrls(TestCase, metaclass=ApiCheckerBase): 17 | 18 | def assert_timestamp(self, response, expected, path=''): 19 | value = response['timestamp'] 20 | assert datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') 21 | 22 | 23 | Check protected url 24 | ------------------- 25 | 26 | Using standard DRF way: ``self.client.login()`` or ``self.client.force_authenticate()`` 27 | 28 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/frozen_detail.fixture.json: -------------------------------------------------------------------------------- 1 | { 2 | "frozen_detail": { 3 | "master": { 4 | "model": "demo.detail", 5 | "pk": 1, 6 | "fields": { 7 | "name": "Name 003", 8 | "master": 1, 9 | "timestamp": "2020-09-10T10:34:49.586" 10 | } 11 | }, 12 | "deps": [ 13 | { 14 | "model": "demo.master", 15 | "pk": 1, 16 | "fields": { 17 | "name": "Name 005", 18 | "alias": "Alias 005", 19 | "timestamp": "2020-09-10T10:34:49.585", 20 | "capabilities": [] 21 | } 22 | } 23 | ] 24 | } 25 | } -------------------------------------------------------------------------------- /tests/test_collector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from drf_api_checker.collector import ForeignKeysCollector 4 | 5 | 6 | @pytest.mark.django_db(transaction=False) 7 | def test_collect_single(detail): 8 | collector = ForeignKeysCollector(None) 9 | collector.collect(detail) 10 | assert collector.data == [detail, detail.master] 11 | 12 | 13 | @pytest.mark.django_db(transaction=False) 14 | def test_collect_duplicate(detail): 15 | collector = ForeignKeysCollector(None) 16 | collector.collect([detail, detail.master]) 17 | assert collector.data == [detail, detail.master] 18 | 19 | 20 | @pytest.mark.django_db(transaction=False) 21 | def test_collect_multiple(detail, masters): 22 | m1, m2 = masters 23 | collector = ForeignKeysCollector(None) 24 | collector.collect([detail, m1, m2]) 25 | assert collector.data == [detail, detail.master, m1, m2] 26 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: '^$' 2 | fail_fast: false 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: isort 7 | args: 8 | - --check-only 9 | - -rc 10 | - src/ 11 | - tests/ 12 | exclude: docs/ 13 | name: isort 14 | entry: isort 15 | language: system 16 | types: [python] 17 | 18 | - repo: git://github.com/pre-commit/pre-commit-hooks 19 | rev: v1.4.0 20 | hooks: 21 | - id: debug-statements 22 | - id: flake8 23 | exclude: docs/ 24 | args: 25 | - src/ 26 | - tests/ 27 | - id: check-merge-conflict 28 | - repo: https://github.com/saxix/pch.git 29 | rev: 0b3bfbc75c2e27b5ff78f0a92642ef100de1c5a2 30 | hooks: 31 | - id: check-untracked 32 | args: 33 | - src 34 | - tests 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BUILDDIR='~build' 2 | 3 | 4 | .mkbuilddir: 5 | mkdir -p ${BUILDDIR} 6 | 7 | 8 | #test: 9 | # py.test -v --create-db 10 | 11 | lint: 12 | pre-commit run --all-files 13 | 14 | test: 15 | coverage run --rcfile=tests/.coveragerc --source drf_api_checker -m pytest tests 16 | coverage report --rcfile=tests/.coveragerc 17 | coverage html --rcfile=tests/.coveragerc 18 | 19 | clean: 20 | rm -fr ${BUILDDIR} dist src/*.egg-info .coverage coverage.xml .eggs *.sqlite 21 | find src -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf 22 | find tests -name __pycache__ -o -name "*.py?" -o -name "*.orig" -prune | xargs rm -rf 23 | 24 | fullclean: 25 | rm -fr .tox .cache .pytest_cache 26 | $(MAKE) clean 27 | 28 | 29 | docs: .mkbuilddir 30 | mkdir -p ${BUILDDIR}/docs 31 | sphinx-build -aE docs/ ${BUILDDIR}/docs 32 | ifdef BROWSE 33 | firefox ${BUILDDIR}/docs/index.html 34 | endif 35 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_create_/post/10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 201, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "POST, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "80" 23 | ] 24 | }, 25 | "data": { 26 | "id": 2, 27 | "name": "abc", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:51.057356" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/_master_update_1_/put/10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "PUT, PATCH, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "80" 23 | ] 24 | }, 25 | "data": { 26 | "id": 1, 27 | "name": "abc", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:50.651073" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_update_1_/put/10f2c3ca779ef07024ec58f430451609e8f5c75afbc954e5adf558bcb0129fdf.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "PUT, PATCH, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "80" 23 | ] 24 | }, 25 | "data": { 26 | "id": 1, 27 | "name": "abc", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:51.115302" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/test_url_post/_master_create_/post/bf4f4f4c8448f5a38fe96411603d22a6aedab96466c044e0ba5bc10bb5aaf3fe.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 201, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "POST, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "82" 23 | ] 24 | }, 25 | "data": { 26 | "id": 2, 27 | "name": "name1", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:49.862992" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/_master_detail_1_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "85" 23 | ] 24 | }, 25 | "data": { 26 | "id": 1, 27 | "name": "Name 012", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:50.225000" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_detail_1_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "85" 23 | ] 24 | }, 25 | "data": { 26 | "id": 1, 27 | "name": "Name 013", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:50.882000" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/TestUrls/_master_detail_101_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": { 26 | "id": 101, 27 | "name": "Name 014", 28 | "capabilities": [], 29 | "timestamp": "2020-09-10T10:34:51.429908" 30 | }, 31 | "content_type": null 32 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/_master_list_/get/240f5ff9499fabe7952369a2a095ad1d8dedba650ea844f3fa0027e5ddc12f49.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 012", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.225000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/_master_list_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 012", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.225015" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/add_field/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 012", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.225000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test1DemoApi/remove_field/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 012", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.225000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_list_/get/240f5ff9499fabe7952369a2a095ad1d8dedba650ea844f3fa0027e5ddc12f49.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 013", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.882000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/_master_list_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 013", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.882425" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/add_field/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 013", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.882000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/Test2DemoApi/remove_field/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 013", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:50.882000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_api/TestUrls/_master_list_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "89" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 101, 28 | "name": "Name 014", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:51.429000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/demoapp/demo/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import re_path 3 | 4 | from .api import (MasterCreatAPIView, MasterDeleteAPIView, MasterListAPIView, 5 | MasterRetrieveAPIView, MasterUpdateAPIView) 6 | 7 | admin.autodiscover() 8 | 9 | urlpatterns = ( 10 | re_path(r"^master/list/", MasterListAPIView.as_view(), name="master-list"), 11 | re_path( 12 | r"^master/detail/(?P.*)/", 13 | MasterRetrieveAPIView.as_view(), 14 | name="master-detail", 15 | ), 16 | re_path( 17 | r"^master/update/(?P.*)/", 18 | MasterUpdateAPIView.as_view(), 19 | name="master-update", 20 | ), 21 | re_path( 22 | r"^master/delete/(?P.*)/", 23 | MasterDeleteAPIView.as_view(), 24 | name="master-delete", 25 | ), 26 | re_path(r"^master/create/", MasterCreatAPIView.as_view(), name="master-create"), 27 | re_path(r"^admin/", admin.site.urls), 28 | ) 29 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/test_url_get/_master_list_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 005", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:49.585000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/test_parametrize/_master_list_/get/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "87" 23 | ] 24 | }, 25 | "data": [ 26 | { 27 | "id": 1, 28 | "name": "Name 005", 29 | "capabilities": [], 30 | "timestamp": "2020-09-10T10:34:49.585000" 31 | } 32 | ], 33 | "content_type": null 34 | } -------------------------------------------------------------------------------- /tests/demoapp/demo/models.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db import models 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Capability(models.Model): 9 | name = models.CharField(max_length=100) 10 | 11 | class Meta: 12 | ordering = ("id",) 13 | app_label = "demo" 14 | 15 | 16 | class Master(models.Model): 17 | name = models.CharField(max_length=100) 18 | alias = models.CharField(max_length=100) 19 | 20 | capabilities = models.ManyToManyField(Capability, blank=True, null=True) 21 | timestamp = models.DateTimeField(auto_now=True) 22 | 23 | class Meta: 24 | ordering = ("id",) 25 | app_label = "demo" 26 | 27 | 28 | class Detail(models.Model): 29 | name = models.CharField(max_length=100) 30 | master = models.ForeignKey(Master, on_delete=models.CASCADE) 31 | timestamp = models.DateTimeField(auto_now=True) 32 | 33 | class Meta: 34 | ordering = ("id",) 35 | app_label = "demo" 36 | -------------------------------------------------------------------------------- /tests/demoapp/demo/api.py: -------------------------------------------------------------------------------- 1 | from rest_framework.generics import (CreateAPIView, DestroyAPIView, 2 | ListCreateAPIView, RetrieveAPIView, 3 | UpdateAPIView) 4 | 5 | from .models import Master 6 | from .serializers import MasterSerializer 7 | 8 | 9 | class MasterListAPIView(ListCreateAPIView): 10 | serializer_class = MasterSerializer 11 | queryset = Master.objects.all() 12 | 13 | 14 | class MasterUpdateAPIView(UpdateAPIView): 15 | serializer_class = MasterSerializer 16 | queryset = Master.objects.all() 17 | 18 | 19 | class MasterCreatAPIView(CreateAPIView): 20 | serializer_class = MasterSerializer 21 | queryset = Master.objects.all() 22 | 23 | 24 | class MasterDeleteAPIView(DestroyAPIView): 25 | serializer_class = MasterSerializer 26 | queryset = Master.objects.all() 27 | 28 | 29 | class MasterRetrieveAPIView(RetrieveAPIView): 30 | serializer_class = MasterSerializer 31 | queryset = Master.objects.all() 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2018, Stefano Apostolico 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _api: 4 | 5 | === 6 | API 7 | === 8 | 9 | 10 | .. contents:: 11 | :local: 12 | 13 | 14 | 15 | Unittest/django support 16 | ======================= 17 | 18 | 19 | .. _ApiCheckerMixin: 20 | 21 | ApiCheckerMixin 22 | --------------- 23 | 24 | .. autoclass:: drf_api_checker.unittest.ApiCheckerMixin 25 | 26 | 27 | .. _ApiCheckerBase: 28 | 29 | ApiCheckerBase 30 | --------------- 31 | 32 | .. autoclass:: drf_api_checker.unittest.ApiCheckerBase 33 | 34 | 35 | pytest support 36 | ============== 37 | 38 | .. _frozenfixture: 39 | 40 | @frozenfixture 41 | --------------- 42 | 43 | .. autofunction:: drf_api_checker.pytest.frozenfixture 44 | 45 | 46 | 47 | .. _contract: 48 | 49 | @contract 50 | --------- 51 | 52 | .. autofunction:: drf_api_checker.pytest.contract 53 | 54 | 55 | Internals 56 | ========= 57 | 58 | 59 | .. _Recorder: 60 | 61 | Recorder 62 | -------- 63 | 64 | .. autoclass:: drf_api_checker.recorder.Recorder 65 | 66 | 67 | 68 | .. _ForeignKeysCollector: 69 | 70 | ForeignKeysCollector 71 | -------------------- 72 | 73 | .. autoclass:: drf_api_checker.collector.ForeignKeysCollector 74 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | def pytest_configure(config): 9 | here = os.path.dirname(__file__) 10 | sys.path.insert(0, str(Path(here) / "demoapp")) 11 | 12 | 13 | @pytest.fixture(scope="session") 14 | def client(request): 15 | import django_webtest 16 | 17 | wtm = django_webtest.WebTestMixin() 18 | wtm.csrf_checks = False 19 | wtm._patch_settings() 20 | request.addfinalizer(wtm._unpatch_settings) 21 | app = django_webtest.DjangoTestApp() 22 | return app 23 | 24 | 25 | @pytest.fixture 26 | def master(db): 27 | from demo.factories import MasterFactory 28 | 29 | return MasterFactory() 30 | 31 | 32 | @pytest.fixture 33 | def detail(master): 34 | from demo.factories import DetailFactory 35 | 36 | return DetailFactory(master=master) 37 | 38 | 39 | @pytest.fixture 40 | def masters(db): 41 | from demo.factories import MasterFactory 42 | 43 | return MasterFactory(), MasterFactory() 44 | 45 | 46 | @pytest.fixture 47 | def details(db): 48 | from demo.factories import DetailFactory 49 | 50 | return DetailFactory(), DetailFactory() 51 | -------------------------------------------------------------------------------- /tests/demoapp/demo/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from factory.django import DjangoModelFactory 3 | 4 | from .models import Capability, Detail, Master 5 | 6 | 7 | class CapabilityFactory(DjangoModelFactory): 8 | name = factory.Sequence(lambda n: "Name %03d" % n) 9 | 10 | class Meta: 11 | model = Capability 12 | # django_get_or_create = ('id',) 13 | 14 | 15 | class MasterFactory(DjangoModelFactory): 16 | name = factory.Sequence(lambda n: "Name %03d" % n) 17 | alias = factory.Sequence(lambda n: "Alias %03d" % n) 18 | 19 | class Meta: 20 | model = Master 21 | # django_get_or_create = ('id',) 22 | 23 | @factory.post_generation 24 | def capabilities(self, create, extracted, **kwargs): 25 | if not create: 26 | self.capabilities.add(CapabilityFactory()) 27 | 28 | if extracted: 29 | for capability in extracted: 30 | self.capabilities.add(capability) 31 | 32 | 33 | class DetailFactory(DjangoModelFactory): 34 | name = factory.Sequence(lambda n: "Name %03d" % n) 35 | master = factory.SubFactory(MasterFactory) 36 | 37 | class Meta: 38 | model = Detail 39 | # django_get_or_create = ('id',) 40 | -------------------------------------------------------------------------------- /src/drf_api_checker/fs.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import os 3 | 4 | 5 | def mktree(newdir): 6 | """works the way a good mkdir should :) 7 | - already exists, silently complete 8 | - regular file in the way, raise an exception 9 | - parent directory(ies) does not exist, make them as well 10 | """ 11 | if os.path.isdir(newdir): 12 | pass 13 | elif os.path.isfile(newdir): 14 | raise OSError( 15 | "a file with the same name as the desired " 16 | "dir, '%s', already exists." % newdir 17 | ) 18 | else: 19 | os.makedirs(newdir) 20 | 21 | 22 | def clean_url(method, url, data=""): 23 | if not isinstance(data, (str, bytes)): 24 | data = hashlib.sha256(str(data).encode()).hexdigest() 25 | return f"{url.strip('.').replace('/', '_')}/{method}/{str(data)}" 26 | 27 | 28 | def get_filename(base, name): 29 | filename = os.path.join(base, name) 30 | if not os.path.exists(filename): 31 | mktree(os.path.dirname(filename)) 32 | return filename 33 | 34 | 35 | # 36 | # def get_response_filename(base, url): 37 | # return get_filename(base, clean_url(url) + '.response.json') 38 | # 39 | # 40 | # def get_fixtures_filename(base, basename='fixtures'): 41 | # return get_filename(base, f'{basename}.json') 42 | -------------------------------------------------------------------------------- /tests/test_pytest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.urls import reverse 3 | 4 | from drf_api_checker.pytest import (api_checker_datadir, contract, # noqa 5 | frozenfixture) 6 | from drf_api_checker.recorder import Recorder 7 | 8 | 9 | class MyRecorder(Recorder): 10 | def assert_timestamp(self, response, stored, path): 11 | assert response 12 | 13 | 14 | @frozenfixture() 15 | def frozen_detail(request, db): 16 | from demo.factories import DetailFactory 17 | 18 | return DetailFactory() 19 | 20 | 21 | @contract(recorder_class=MyRecorder) 22 | def test_url_get(frozen_detail): 23 | url = reverse("master-list") 24 | return url 25 | 26 | 27 | @pytest.mark.parametrize("method", ["get", "options"]) 28 | def test_parametrize(frozen_detail, api_checker_datadir, method): 29 | url = reverse("master-list") 30 | recorder = MyRecorder(api_checker_datadir) 31 | recorder.assertCALL(url, method=method) 32 | 33 | 34 | @contract(recorder_class=MyRecorder, method="post") 35 | def test_url_post(frozen_detail): 36 | url = reverse("master-create") 37 | return url, {"name": "name1"} 38 | 39 | 40 | @contract(recorder_class=MyRecorder, method="delete", allow_empty=True) 41 | def test_url_delete(frozen_detail): 42 | url = reverse("master-delete", args=[frozen_detail.pk]) 43 | return url 44 | -------------------------------------------------------------------------------- /docs/unittest.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _unittest: 4 | 5 | Unitest style test support 6 | ========================== 7 | 8 | Unitest style is supported via :ref:`ApiCheckerMixin` and :ref:`ApiCheckerBase` 9 | 10 | ApiCheckerMixin 11 | --------------- 12 | 13 | Base test looks like:: 14 | 15 | class TestAPIAgreements(ApiCheckerMixin, TestCase): 16 | def get_fixtures(self): 17 | return {'customer': CustomerFactory()} 18 | 19 | def test_customer_detail(self): 20 | url = reverse("customer-detail", args=[self.get_fixture('customer').pk]) 21 | self.assertGET(url) 22 | 23 | ``get_fixtures`` must returns a dictionary of all the fixtures that need to be restored to 24 | have comparable responses. 25 | 26 | **WARNING**: when `factory_boy`_ is used pay attention to ForeignKeys. They need to be listed too 27 | and the factory need to be written in a way that can reproduce predictable records 28 | 29 | 30 | ApiCheckerBase 31 | -------------- 32 | 33 | .. code-block:: python 34 | 35 | class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase): 36 | URLS = [ 37 | reverse("intervention-list"), 38 | reverse("intervention-detail", args=[101]), 39 | ] 40 | 41 | def get_fixtures(cls): 42 | return {'intervention': InterventionFactory(id=101), 43 | 'result': ResultFactory(), 44 | } 45 | -------------------------------------------------------------------------------- /src/drf_api_checker/collector.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django.db.models import ForeignKey, ManyToManyField 4 | 5 | 6 | class ForeignKeysCollector: 7 | def __init__(self, using): 8 | self._visited = [] 9 | super().__init__() 10 | 11 | def _collect(self, objs): 12 | objects = [] 13 | for obj in objs: 14 | if obj and obj not in self._visited: 15 | concrete_model = obj._meta.concrete_model 16 | obj = concrete_model.objects.get(pk=obj.pk) 17 | opts = obj._meta 18 | 19 | self._visited.append(obj) 20 | objects.append(obj) 21 | for field in chain(opts.fields, opts.local_many_to_many): 22 | if isinstance(field, ManyToManyField): 23 | target = getattr(obj, field.name).all() 24 | objects.extend(self._collect(target)) 25 | elif isinstance(field, ForeignKey): 26 | target = getattr(obj, field.name) 27 | objects.extend(self._collect([target])) 28 | return objects 29 | 30 | def collect(self, obj): 31 | if not hasattr(obj, "__iter__"): 32 | obj = [obj] 33 | self._visited = [] 34 | self.data = self._collect(obj) 35 | self.models = set([o.__class__ for o in self.data]) 36 | 37 | # 38 | # def __str__(self): 39 | # return mark_safe(self.data) 40 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{39,310,311,312,313}-d{32,42,52} 3 | isolated_build = true 4 | ;skipsdist=True 5 | 6 | [pytest] 7 | python_paths=./tests/demoapp/ 8 | django_find_project = false 9 | DJANGO_SETTINGS_MODULE=demo.settings 10 | python_files=tests/test_*.py 11 | addopts = 12 | -vv 13 | --pyargs drf_api_checker 14 | --doctest-modules 15 | -p no:warnings 16 | -p no:cov 17 | --reuse-db 18 | --tb=short 19 | --capture=no 20 | --echo-version django 21 | ; --cov=drf_api_checker 22 | ; --cov-report=html 23 | ; --cov-config=tests/.coveragerc 24 | 25 | [testenv] 26 | ;setenv = 27 | ; PYTHONPATH = {toxinidir}:{toxinidir}/drf_api_checker 28 | ;install_command=pip install {opts} {packages} 29 | passenv = 30 | TRAVIS 31 | TRAVIS_JOB_ID 32 | TRAVIS_BRANCH 33 | PYTHONDONTWRITEBYTECODE 34 | DJANGO 35 | 36 | deps= 37 | d22: django==2.2.* 38 | d32: django==3.2.* 39 | d40: django==4.2.* 40 | trunk: git+git://github.com/django/django.git#egg=django 41 | poetry 42 | codecov 43 | 44 | setenv= 45 | d22: DJANGO="django<3.0" 46 | d32: DJANGO="django<4.0" 47 | d40: DJANGO="django<5.0" 48 | 49 | commands = 50 | poetry install --no-root 51 | {posargs:poetry run coverage run --rcfile=tests/.coveragerc --source drf_api_checker -m pytest tests --create-db} 52 | 53 | 54 | [flake8] 55 | multi_line_output = 3 56 | max-complexity = 14 57 | max-line-length = 120 58 | exclude = migrations settings 59 | ignore = E401,W391,E128,E261,E731,Q000,W504,W606 60 | putty-ignore = 61 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | pull_request: 9 | 10 | jobs: 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-python@v3 16 | 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip flake8 isort 20 | - name: Lint with flake8 21 | run: | 22 | flake8 src 23 | isort -c src 24 | test: 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13" ] 30 | django-version: [ "3.2", "4.2", "5.2" ] 31 | env: 32 | PY_VER: ${{ matrix.python-version}} 33 | DJ_VER: ${{ matrix.django-version}} 34 | 35 | steps: 36 | - uses: actions/checkout@v3 37 | 38 | - name: Set up Python ${{ matrix.python-version }} 39 | uses: actions/setup-python@v2 40 | with: 41 | python-version: ${{ matrix.python-version }} 42 | 43 | - name: Install tox 44 | run: python -m pip install --upgrade pip tox 45 | 46 | - name: Test with 47 | run: tox -e "py${PY_VER//.}-d${DJ_VER//.}" 48 | 49 | - uses: codecov/codecov-action@v2 50 | with: 51 | verbose: true -------------------------------------------------------------------------------- /tests/test_recorder.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from drf_api_checker.exceptions import (FieldAddedError, FieldMissedError, 4 | FieldValueError) 5 | from drf_api_checker.recorder import Recorder 6 | 7 | 8 | def test_field_missing(): 9 | checker = Recorder("") 10 | with pytest.raises(FieldMissedError, match="Missing fields: `b`"): 11 | assert checker.compare( 12 | expected={"a": 1, "b": 2}, response={"a": 1}, view="ViewSet" 13 | ) 14 | 15 | 16 | def test_field_added(): 17 | checker = Recorder("") 18 | with pytest.raises(FieldAddedError, match="New fields are: `b`"): 19 | assert checker.compare( 20 | expected={"a": 1}, response={"a": 1, "b": 1}, view="ViewSet" 21 | ) 22 | 23 | 24 | def test_field_different_format(): 25 | checker = Recorder("") 26 | with pytest.raises(FieldValueError, match="expected: `11`"): 27 | assert checker.compare({"a": 1}, {"a": 11}, view="ViewSet") 28 | 29 | 30 | def test_innner_dict(): 31 | checker = Recorder("") 32 | with pytest.raises(FieldValueError, match=""): 33 | assert checker.compare( 34 | {"a": 1, "b": {"b1": 1}}, {"a": 1, "b": {"b1": 22}}, view="ViewSet" 35 | ) 36 | 37 | 38 | class R(Recorder): 39 | def get_single_record(self, response, expected): 40 | return response["results"][0], expected["results"][0] 41 | 42 | 43 | def test_custom_response(): 44 | checker = R("") 45 | assert checker.compare( 46 | {"count": 1, "results": [{"a": 1, "b": {"b1": 1}}]}, 47 | {"count": 1, "results": [{"a": 1, "b": {"b1": 1}}]}, 48 | view="ViewSet", 49 | ) 50 | 51 | 52 | def test_custom_response_error(): 53 | checker = R("") 54 | with pytest.raises(FieldValueError, match="") as excinfo: 55 | assert checker.compare( 56 | {"count": 1, "results": [{"a": 1, "b": {"b1": 1}}]}, 57 | {"count": 1, "results": [{"a": 1, "b": {"b1": 22}}]}, 58 | view="ViewSet", 59 | ) 60 | assert """ 61 | - expected: `22` 62 | - received: `1`""" in str( 63 | excinfo.value 64 | ) 65 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | Release 0.13 (in development) 2 | ------------ 3 | * added support to django 4.0 4 | * added support to python 3.10 5 | 6 | 7 | Release 0.12 8 | ------------ 9 | * settings 10 | 11 | 12 | Release 0.11 13 | ------------ 14 | * add support Django 3.1, 3.2 15 | * add support Python 3.9 16 | * drop support Django < 2.2 17 | 18 | 19 | Release 0.10 20 | ------------ 21 | * fixes #4 - Windows Filename Incompatibility 22 | * drop support Django < 2.x 23 | 24 | 25 | Release 0.9 26 | ----------- 27 | * add ability to invoke `frozenfixture` manually 28 | 29 | 30 | Release 0.8.2 31 | ------------- 32 | * bug fixes 33 | 34 | 35 | Release 0.8.1 36 | ------------- 37 | * bug fixes 38 | 39 | 40 | Release 0.8 41 | ----------- 42 | * handle OrderedDict 43 | * BACKWARD INCOMPATIBLE: changed way to create fixtures/cassettes names 44 | * BACKWARD INCOMPATIBLE: @frozenfixture is now a function decorator. 45 | * new arg in `frozenfixture` fixture_name 46 | 47 | 48 | Release 0.7 49 | ----------- 50 | * improves some customization capabilities 51 | * deprecate 'check_headers' and `check_status` arguments 52 | * add ability to determinate checks order: default at [STATUS_CODE, FIELDS, HEADERS] 53 | 54 | 55 | Release 0.6 56 | ----------- 57 | * fixes compatibility pytest 5.0 58 | 59 | 60 | Release 0.5.1 61 | ------------- 62 | * fixes wrong packaging 63 | 64 | 65 | Release 0.5 66 | ----------- 67 | * consider url arguments when generate filename to avoid name clashing 68 | 69 | 70 | Release 0.4.1 71 | ------------- 72 | * fixes wrong packaging 73 | 74 | 75 | Release 0.4 76 | ----------- 77 | * add pytest option `--reset-contracts` 78 | * fixes custom asserter resolution 79 | * add support django 2.1 80 | * add support python 3.7 81 | 82 | 83 | Release 0.3 84 | ----------- 85 | * add support for GET/POST/PATCH/DELETE/HEAD 86 | * BACKWARD INCONPATIBLE: `assertAPI` is now `assertGET` more consistent with new `assertPUT`, `assertPOST`, `assertDELETE` 87 | 88 | 89 | Release 0.2 90 | ----------- 91 | * improve subclasssing support 92 | * add support for protected url 93 | * raise error if response code > 299 94 | 95 | 96 | Release 0.1 97 | ----------- 98 | * Initial release 99 | -------------------------------------------------------------------------------- /tests/_api_checker_2.2/test_pytest/test_parametrize/_master_list_/options/dc937b59892604f5a86ac96936cd7ff09e25f18ae6b758e8014a24c7fa039e91.response.json: -------------------------------------------------------------------------------- 1 | { 2 | "status_code": 200, 3 | "headers": { 4 | "content-type": [ 5 | "Content-Type", 6 | "application/json" 7 | ], 8 | "vary": [ 9 | "Vary", 10 | "Accept, Cookie" 11 | ], 12 | "allow": [ 13 | "Allow", 14 | "GET, POST, HEAD, OPTIONS" 15 | ], 16 | "x-frame-options": [ 17 | "X-Frame-Options", 18 | "SAMEORIGIN" 19 | ], 20 | "content-length": [ 21 | "Content-Length", 22 | "533" 23 | ] 24 | }, 25 | "data": { 26 | "name": "Master List Api", 27 | "description": "", 28 | "renders": [ 29 | "application/json", 30 | "text/html" 31 | ], 32 | "parses": [ 33 | "application/json", 34 | "application/x-www-form-urlencoded", 35 | "multipart/form-data" 36 | ], 37 | "actions": { 38 | "POST": { 39 | "id": { 40 | "type": "integer", 41 | "required": false, 42 | "read_only": true, 43 | "label": "ID" 44 | }, 45 | "name": { 46 | "type": "string", 47 | "required": true, 48 | "read_only": false, 49 | "label": "Name", 50 | "max_length": 100 51 | }, 52 | "capabilities": { 53 | "type": "field", 54 | "required": false, 55 | "read_only": false, 56 | "label": "Capabilities" 57 | }, 58 | "timestamp": { 59 | "type": "datetime", 60 | "required": false, 61 | "read_only": true, 62 | "label": "Timestamp" 63 | } 64 | } 65 | } 66 | }, 67 | "content_type": null 68 | } -------------------------------------------------------------------------------- /tests/demoapp/demo/settings.py: -------------------------------------------------------------------------------- 1 | import django 2 | 3 | DEBUG = True 4 | STATIC_URL = "/static/" 5 | 6 | SITE_ID = 1 7 | ROOT_URLCONF = "demo.urls" 8 | SECRET_KEY = "abc" 9 | STATIC_ROOT = "static" 10 | MEDIA_ROOT = "media" 11 | 12 | INSTALLED_APPS = [ 13 | "django.contrib.auth", 14 | "django.contrib.contenttypes", 15 | "django.contrib.sessions", 16 | "django.contrib.sites", 17 | "django.contrib.messages", 18 | "django.contrib.staticfiles", 19 | "django.contrib.admin", 20 | "drf_api_checker.apps.Config", 21 | "demo", 22 | ] 23 | 24 | if django.VERSION[0] == 2: 25 | MIDDLEWARE = ( 26 | "django.contrib.sessions.middleware.SessionMiddleware", 27 | "django.middleware.common.CommonMiddleware", 28 | "django.middleware.csrf.CsrfViewMiddleware", 29 | "django.contrib.auth.middleware.AuthenticationMiddleware", 30 | "django.contrib.messages.middleware.MessageMiddleware", 31 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 32 | ) 33 | else: 34 | MIDDLEWARE_CLASSES = ( 35 | "django.contrib.sessions.middleware.SessionMiddleware", 36 | "django.middleware.common.CommonMiddleware", 37 | "django.middleware.csrf.CsrfViewMiddleware", 38 | "django.contrib.auth.middleware.AuthenticationMiddleware", 39 | "django.contrib.messages.middleware.MessageMiddleware", 40 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 41 | ) 42 | 43 | TEMPLATES = [ 44 | { 45 | "BACKEND": "django.template.backends.django.DjangoTemplates", 46 | "DIRS": [], 47 | "APP_DIRS": True, 48 | "OPTIONS": { 49 | "context_processors": [ 50 | "django.contrib.messages.context_processors.messages", 51 | "django.contrib.auth.context_processors.auth", 52 | "django.template.context_processors.request", 53 | ] 54 | }, 55 | }, 56 | ] 57 | 58 | 59 | LOGGING = { 60 | "version": 1, 61 | "disable_existing_loggers": False, 62 | "formatters": { 63 | "debug": { 64 | "format": "%(levelno)s:%(levelname)-8s %(name)s %(funcName)s:%(lineno)s:: %(message)s" 65 | } 66 | }, 67 | "handlers": { 68 | "null": {"level": "DEBUG", "class": "logging.NullHandler"}, 69 | "console": { 70 | "level": "DEBUG", 71 | "class": "logging.StreamHandler", 72 | "formatter": "debug", 73 | }, 74 | }, 75 | "loggers": { 76 | "drf_api_checker": {"handlers": ["null"], "propagate": False, "level": "DEBUG"} 77 | }, 78 | } 79 | 80 | DATABASES = { 81 | "default": { 82 | "ENGINE": "django.db.backends.sqlite3", 83 | "NAME": "db.sqlite", 84 | } 85 | } 86 | 87 | BY_DJANGO_VERSION = True 88 | -------------------------------------------------------------------------------- /tests/demoapp/demo/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.6 on 2018-06-02 11:36 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Capability", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("name", models.CharField(max_length=100)), 26 | ], 27 | options={ 28 | "ordering": ("id",), 29 | }, 30 | ), 31 | migrations.CreateModel( 32 | name="Detail", 33 | fields=[ 34 | ( 35 | "id", 36 | models.AutoField( 37 | auto_created=True, 38 | primary_key=True, 39 | serialize=False, 40 | verbose_name="ID", 41 | ), 42 | ), 43 | ("name", models.CharField(max_length=100)), 44 | ("timestamp", models.DateTimeField(auto_now=True)), 45 | ], 46 | options={ 47 | "ordering": ("id",), 48 | }, 49 | ), 50 | migrations.CreateModel( 51 | name="Master", 52 | fields=[ 53 | ( 54 | "id", 55 | models.AutoField( 56 | auto_created=True, 57 | primary_key=True, 58 | serialize=False, 59 | verbose_name="ID", 60 | ), 61 | ), 62 | ("name", models.CharField(max_length=100)), 63 | ("alias", models.CharField(max_length=100)), 64 | ("timestamp", models.DateTimeField(auto_now=True)), 65 | ( 66 | "capabilities", 67 | models.ManyToManyField(to="demo.Capability", blank=True, null=True), 68 | ), 69 | ], 70 | options={ 71 | "ordering": ("id",), 72 | }, 73 | ), 74 | migrations.AddField( 75 | model_name="detail", 76 | name="master", 77 | field=models.ForeignKey( 78 | on_delete=django.db.models.deletion.CASCADE, to="demo.Master" 79 | ), 80 | ), 81 | ] 82 | -------------------------------------------------------------------------------- /src/drf_api_checker/exceptions.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class ContractError(AssertionError): 5 | pass 6 | 7 | 8 | class DictKeyMissed(ContractError): 9 | def __init__(self, keys): 10 | self.keys = keys 11 | 12 | def __str__(self) -> str: 13 | return f"Missing fields: `{self.keys}` " 14 | 15 | 16 | class DictKeyAdded(ContractError): 17 | def __init__(self, keys): 18 | self.keys = keys 19 | 20 | 21 | class FieldMissedError(ContractError): 22 | def __init__(self, view, field_name): 23 | self.view = view 24 | self.field_name = field_name 25 | 26 | def __str__(self) -> str: 27 | return f"View `{self.view}` breaks the contract. Missing fields: `{self.field_name}` " 28 | 29 | 30 | class FieldAddedError(ContractError): 31 | def __init__(self, view, field_names, filename): 32 | self.view = view 33 | self.field_names = field_names 34 | self.filename = filename 35 | 36 | def __str__(self) -> str: 37 | return rf"""View '{self.view}' returned more field than expected. 38 | Action needed: {os.path.basename(self.filename)} need rebuild. 39 | New fields are: `{self.field_names}`""" 40 | 41 | 42 | class FieldValueError(ContractError): 43 | def __init__( 44 | self, 45 | view, 46 | field_name, 47 | expected, 48 | received, 49 | filename, 50 | message="Field `{0.field_name}` does not match.", 51 | ): 52 | self.view = view 53 | self.field_name = field_name 54 | self.expected = expected 55 | self.received = received 56 | self.filename = filename 57 | self.message = message.format(self) 58 | 59 | def __str__(self) -> str: 60 | return rf"""View `{self.view}` breaks the contract. 61 | Datadir: {self.filename} 62 | {self.message} 63 | - expected: `{self.expected}` 64 | - received: `{self.received}`""" 65 | 66 | 67 | class HeaderError(ContractError): 68 | def __init__(self, view, header, expected, received, filename, extra=""): 69 | self.view = view 70 | self.field_name = header 71 | self.expected = expected 72 | self.received = received 73 | self.filename = filename 74 | self.extra = extra 75 | 76 | def __str__(self) -> str: 77 | return rf"""View `{self.view}` breaks the contract. 78 | Datadir: {self.filename} 79 | Field `{self.field_name}` does not match. 80 | - expected: `{self.expected}` 81 | - received: `{self.received}` 82 | {self.extra} 83 | """ 84 | 85 | 86 | class StatusCodeError(ContractError): 87 | def __init__(self, view, received, expected): 88 | self.view = view 89 | self.received = received 90 | self.expected = expected 91 | 92 | def __str__(self) -> str: 93 | return f"View `{self.view}` breaks the contract. Expected status {self.expected}, received {self.received}" 94 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _index: 4 | 5 | =============================== 6 | DjangoRestFramework API checker 7 | =============================== 8 | 9 | Overview 10 | ======== 11 | 12 | .. image:: https://img.shields.io/travis/saxix/drf-api-checker/master.svg 13 | :target: http://travis-ci.org/saxix/drf-api-checker/ 14 | :alt: Test status 15 | 16 | .. image:: https://codecov.io/github/saxix/drf-api-checker/coverage.svg?branch=master 17 | :target: https://codecov.io/github/saxix/drf-api-checker?branch=master 18 | :alt: Coverage 19 | 20 | 21 | 22 | 23 | This module offers some utilities to avoid unwanted changes in Django Rest Framework responses, 24 | so to keep stable contracts 25 | 26 | The purpose is to guarantee that any code changes never introduce 'contract violations' 27 | changing the Serialization behaviour. 28 | 29 | 30 | Contract violations can happen when: 31 | 32 | - fields are removed from Serializer 33 | - field representation changes (ie. date/number format, ) 34 | - Response status code changes (optional) 35 | - Response headers change (optional) 36 | 37 | 38 | How it works: 39 | ------------- 40 | 41 | First time the test run, the response and model instances are serialized and 42 | saved on the disk; any further execution is checked against this first response. 43 | 44 | Test data are saved in the same directory where the test module lives, 45 | under ``_api_checker//`` 46 | 47 | Fields that cannot be checked by value (ie timestamps/last modified) can be tested writing 48 | custom ``assert_`` methods. 49 | 50 | In case of nested objects, method names must follow the field "path". 51 | (ie. ``assert_permission_modified`` vs ``assert_modified``) 52 | 53 | This module can also intercept when a field is added, 54 | in this case it is mandatory recreate stored test data; simply delete them from the disk 55 | or set ``API_CHECKER_RESET`` environment variable and run the test again, 56 | 57 | 58 | 59 | In case something goes wrong the output will be 60 | 61 | 62 | **Field values mismatch** 63 | 64 | 65 | .. code-block:: 66 | 67 | AssertionError: View '' breaks the contract. 68 | Field 'name' does not match. 69 | - expected: 'Partner 0' 70 | - received: 'Partner 11' 71 | 72 | 73 | **Field removed** 74 | 75 | .. code-block:: bash 76 | 77 | 78 | AssertionError: View `` breaks the contract. 79 | Field `id` is missing in the new response 80 | 81 | 82 | **Field added** 83 | 84 | 85 | .. code-block:: bash 86 | 87 | 88 | AssertionError: View `` returned more field than expected. 89 | Action needed api_customers.response.json need rebuild. 90 | New fields are: 91 | `['country']` 92 | 93 | 94 | Table Of Contents 95 | ================= 96 | 97 | .. toctree:: 98 | :maxdepth: 1 99 | 100 | install 101 | unittest 102 | unittest_recipes 103 | pytest 104 | pytest_recipes 105 | api 106 | 107 | 108 | Links 109 | ===== 110 | 111 | * Project home page: https://github.com/saxix/drf-api-checker 112 | * Issue tracker: https://github.com/saxix/drf-api-checker/issues?sort 113 | * Download: http://pypi.python.org/pypi/drf-api-checker/ 114 | * Docs: http://readthedocs.org/docs/drf-api-checker/en/latest/ 115 | -------------------------------------------------------------------------------- /docs/pytest_recipes.rst: -------------------------------------------------------------------------------- 1 | .. include:: globals.txt 2 | 3 | .. _pytest_recipes: 4 | 5 | PyTest Recipes 6 | ============== 7 | 8 | 9 | Check ``DateTimeField()`` with ``auto_now=True`` 10 | ------------------------------------------------ 11 | 12 | Create a custom :ref:`Recorder` and pass it to :ref:`contract` 13 | 14 | 15 | .. code-block:: python 16 | 17 | from drf_api_checker.recorder import Recorder 18 | from drf_api_checker.pytest import contract, frozenfixture 19 | 20 | class MyRecorder(Recorder): 21 | 22 | def assert_timestamp(self, response, expected, path=''): 23 | value = response['timestamp'] 24 | assert datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') 25 | 26 | 27 | @contract(recorder_class=MyRecorder) 28 | def test_user_list(user): 29 | return reverse('api:user-list') 30 | 31 | 32 | Check protected url 33 | ------------------- 34 | 35 | Create a custom :ref:`Recorder` and override ``client`` property 36 | 37 | 38 | .. code-block:: python 39 | 40 | class MyRecorder(Recorder): 41 | @property 42 | def client(self): 43 | user = UserFactory(is_superuser=True) 44 | client = APIClient() 45 | client.force_authenticate(user) 46 | return client 47 | 48 | @contract(recorder_class=MyRecorder) 49 | def test_user_list(user): 50 | return reverse('api:user-list') 51 | 52 | 53 | 54 | Check methods other than GET 55 | ---------------------------- 56 | 57 | .. code-block:: python 58 | 59 | from drf_api_checker.recorder import Recorder 60 | from drf_api_checker.pytest import contract, frozenfixture 61 | 62 | @contract(recorder_class=MyRecorder, method='post') 63 | def test_url_post(frozen_detail): 64 | url = reverse("master-create") 65 | return url, {"name": "name1"} 66 | 67 | 68 | Change the name of frozenfixture filename 69 | ----------------------------------------- 70 | 71 | .. code-block:: python 72 | 73 | def get_fixture_name(seed, request): 74 | viewset = request.getfixturevalue('viewset') 75 | url = viewset.get_service().endpoint.strip('.').replace('/', '_') 76 | return os.path.join(seed, url) + '.fixture.json' 77 | 78 | 79 | @frozenfixture(fixture_name=get_fixture_name) 80 | def data(db, request): 81 | viewset = request.getfixturevalue('viewset') 82 | factory = factories_registry[viewset.serializer_class.Meta.model] 83 | data = (factory(schema_name='bolivia'), 84 | factory(schema_name='chad'), 85 | factory(schema_name='lebanon')) 86 | return data 87 | 88 | 89 | 90 | Use pytest.parametrize 91 | ---------------------- 92 | 93 | .. code-block:: python 94 | 95 | @pytest.mark.parametrize("method", ['get', 'options']) 96 | def test_parametrize(frozen_detail, api_checker_datadir, method): 97 | url = reverse("master-list") 98 | recorder = MyRecorder(api_checker_datadir) 99 | recorder.assertCALL(url, method=method) 100 | 101 | 102 | 103 | Authenticate client with different users 104 | ---------------------------------------- 105 | 106 | *pseudo-code* 107 | 108 | .. code-block:: python 109 | 110 | @pytest.mark.parametrize("permission", ['can_read', 'can_write']) 111 | def test_parametrize(frozen_detail, api_checker_datadir, permission): 112 | url = reverse("master-list") 113 | user = UserFactory() 114 | with user_grant_permissions(user, [permission]) 115 | recorder = MyRecorder(api_checker_datadir, as_user=user): 116 | recorder.assertCALL(url, method=method) 117 | 118 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from datetime import datetime 4 | from io import BytesIO 5 | 6 | import pytest 7 | import pytz 8 | from dateutil.utils import today 9 | from rest_framework.response import Response 10 | 11 | from drf_api_checker.fs import mktree 12 | from drf_api_checker.utils import (_read, _write, dump_fixtures, load_fixtures, 13 | load_response, serialize_response) 14 | 15 | 16 | def test_mktree(tmpdir): 17 | target1 = f"{tmpdir}/aa/bb/cc" 18 | target2 = f"{tmpdir}/aa/bb/ee" 19 | file1 = f"{tmpdir}/file.txt" 20 | 21 | with open(file1, "w") as f: 22 | f.write("aaa") 23 | 24 | mktree(target1) 25 | assert os.path.isdir(target1) 26 | 27 | mktree(target2) 28 | assert os.path.isdir(target2) 29 | 30 | mktree(target2) 31 | assert os.path.isdir(target2) 32 | with pytest.raises(OSError): 33 | mktree(file1) 34 | 35 | 36 | def test_write_file(tmpdir): 37 | file1 = f"{tmpdir}/file.txt" 38 | _write(file1, b"content") 39 | 40 | 41 | def test_write_buffer(): 42 | _write(BytesIO(), b"content") 43 | 44 | 45 | def test_write_error(): 46 | with pytest.raises(ValueError): 47 | _write(22, b"content") 48 | 49 | 50 | def test_read_buffer(): 51 | assert _read(BytesIO(b"abc")) == b"abc" 52 | 53 | 54 | def test_read_error(): 55 | with pytest.raises(ValueError): 56 | _read(22) 57 | 58 | 59 | def test_read_file(tmpdir): 60 | with open(f"{tmpdir}/f.txt", "w") as f: 61 | f.write("aaa") 62 | assert _read(f"{tmpdir}/f.txt") == b"aaa" 63 | 64 | 65 | def test_dump_fixtures_single(detail): 66 | stream = BytesIO() 67 | data = dump_fixtures({"d": detail}, stream) 68 | stream.seek(0) 69 | assert json.loads(stream.read()) 70 | assert data["d"]["master"]["pk"] == detail.pk 71 | assert [e["pk"] for e in data["d"]["deps"]] == [detail.master.pk] 72 | 73 | 74 | def test_dump_fixtures_multiple(details): 75 | d1, d2 = details 76 | stream = BytesIO() 77 | data = dump_fixtures({"d": details}, stream) 78 | stream.seek(0) 79 | assert json.loads(stream.read()) 80 | assert [e["pk"] for e in data["d"]["master"]] == [d1.pk, d2.pk] 81 | assert [e["pk"] for e in data["d"]["deps"]] == [d1.master.pk, d2.master.pk] 82 | 83 | 84 | def test_load_fixtures_single(detail): 85 | stream = BytesIO() 86 | dump_fixtures({"d": detail}, stream) 87 | stream.seek(0) 88 | assert load_fixtures(stream) == {"d": detail} 89 | 90 | 91 | def test_load_fixtures_multiple(details): 92 | d1, d2 = details 93 | stream = BytesIO() 94 | dump_fixtures({"d": details}, stream) 95 | stream.seek(0) 96 | assert load_fixtures(stream) == {"d": list(details)} 97 | 98 | 99 | def test_serialize_response(): 100 | assert serialize_response( 101 | Response( 102 | { 103 | "set": set(), 104 | "int": 1, 105 | "str": "abc", 106 | "utc": datetime.utcnow().replace(tzinfo=pytz.utc), 107 | "now": datetime.now(), 108 | "date": today().date(), 109 | }, 110 | status=200, 111 | ) 112 | ) 113 | 114 | 115 | def test_load_response(): 116 | response = Response( 117 | { 118 | "set": set(), 119 | "int": 1, 120 | "str": "abc", 121 | "utc": datetime.utcnow().replace(tzinfo=pytz.utc), 122 | "now": datetime.now(), 123 | "date": today().date(), 124 | }, 125 | status=200, 126 | ) 127 | r = load_response(BytesIO(serialize_response(response))) 128 | assert r.status_code == response.status_code 129 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from unittest import mock 4 | 5 | import pytest 6 | from demo.factories import MasterFactory 7 | from demo.serializers import MasterSerializer 8 | from django.test import TestCase 9 | from django.urls import reverse 10 | from rest_framework.response import Response 11 | 12 | from drf_api_checker.exceptions import FieldAddedError, FieldMissedError 13 | from drf_api_checker.recorder import FIELDS, Recorder 14 | from drf_api_checker.unittest import ApiCheckerBase, ApiCheckerMixin 15 | 16 | 17 | class MyRecorder(Recorder): 18 | def assert_timestamp(self, response: Response, stored: Response, path: str): 19 | # only check datetime format 20 | value = response["timestamp"] 21 | assert datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f") 22 | 23 | 24 | class DemoApi(ApiCheckerMixin): 25 | recorder_class = MyRecorder 26 | 27 | def setUp(self): 28 | super().setUp() 29 | self.url = reverse("master-list") 30 | self._base_fields = list(MasterSerializer.Meta.fields) 31 | 32 | def get_fixtures(self): 33 | return {"master": MasterFactory()} 34 | 35 | def test_a_base(self): 36 | self.assertGET(self.url) 37 | 38 | def test_a_base_allow_empty(self): 39 | self.assertGET(self.url, allow_empty=True, data={"a": 1}) 40 | 41 | def test_a_put(self): 42 | self.url = reverse("master-update", args=[self.get_fixture("master").pk]) 43 | self.assertPUT(self.url, {"name": "abc", "capabilities": []}) 44 | 45 | def test_a_post(self): 46 | self.url = reverse("master-create") 47 | self.assertPOST(self.url, {"name": "abc", "capabilities": []}) 48 | 49 | def test_a_delete(self): 50 | self.url = reverse("master-delete", args=[self.get_fixture("master").pk]) 51 | self.assertDELETE(self.url, {"name": "abc", "capabilities": []}) 52 | 53 | def test_b_remove_field(self): 54 | self.assertGET(self.url, name="remove_field", checks=[FIELDS]) 55 | os.environ["API_CHECKER_RESET"] = "" # ignore --reset-contracts 56 | with mock.patch("demo.serializers.MasterSerializer.Meta.fields", ("name",)): 57 | with pytest.raises(FieldMissedError): 58 | self.assertGET(self.url, name="remove_field", checks=[FIELDS]) 59 | 60 | def test_c_add_field(self): 61 | self.assertGET(self.url, name="add_field", checks=[FIELDS]) 62 | os.environ["API_CHECKER_RESET"] = "" # ignore --reset-contracts 63 | with mock.patch( 64 | "demo.serializers.MasterSerializer.Meta.fields", 65 | ("id", "name", "alias", "capabilities", "timestamp"), 66 | ): 67 | with pytest.raises(FieldAddedError): 68 | self.assertGET(self.url, name="add_field", checks=[FIELDS]) 69 | 70 | def test_detail(self): 71 | self.url = reverse("master-detail", args=[self.get_fixture("master").pk]) 72 | self.assertGET(self.url) 73 | 74 | 75 | class Test1DemoApi(DemoApi, TestCase): 76 | def assert_timestamp(self, response, expected, path=""): 77 | value = response["timestamp"] 78 | assert datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f") 79 | 80 | 81 | class Test2DemoApi(DemoApi, TestCase): 82 | @classmethod 83 | def setUpClass(cls): 84 | super().setUpClass() 85 | # cls.data_dir = tempfile.mkdtemp() 86 | cls.recorder = Recorder(cls.data_dir, cls) 87 | 88 | 89 | class TestUrls(TestCase, metaclass=ApiCheckerBase): 90 | URLS = [ 91 | reverse("master-list"), 92 | reverse("master-detail", args=[101]), 93 | ] 94 | 95 | def get_fixtures(cls): 96 | return {"master": MasterFactory(id=101)} 97 | 98 | def assert_timestamp(self, response, expected, path=""): 99 | value = response["timestamp"] 100 | assert datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f") 101 | -------------------------------------------------------------------------------- /src/drf_api_checker/pytest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import os 4 | import sys 5 | from functools import wraps 6 | 7 | import pytest 8 | 9 | from drf_api_checker.recorder import BASE_DATADIR, Recorder 10 | 11 | 12 | def pytest_addoption(parser): 13 | group = parser.getgroup("DRF API Checker") 14 | group._addoption( 15 | "--reset-contracts", 16 | action="store_true", 17 | dest="reset_contracts", 18 | default=False, 19 | help="Re-creates all API checker contracts ", 20 | ) 21 | 22 | 23 | @pytest.fixture(autouse=True, scope="session") 24 | def configure_env(request): 25 | option = False 26 | if hasattr(pytest, "config"): 27 | option = pytest.config.option.reset_contracts 28 | elif hasattr(request, "config"): 29 | option = request.config.option.reset_contracts 30 | 31 | if option: 32 | os.environ["API_CHECKER_RESET"] = "1" 33 | 34 | 35 | @pytest.fixture() 36 | def api_checker_datadir(request): 37 | return get_data_dir(request.function) 38 | 39 | 40 | def default_fixture_name(seed, request): 41 | return seed + ".fixture.json" 42 | 43 | 44 | def frozenfixture(fixture_name=default_fixture_name): 45 | def deco(func): 46 | from drf_api_checker.fs import mktree 47 | from drf_api_checker.utils import dump_fixtures, load_fixtures 48 | 49 | @wraps(func) 50 | def _inner(*args, **kwargs): 51 | if "request" not in kwargs: 52 | raise ValueError("frozenfixture must have `request` argument") 53 | request = kwargs["request"] 54 | parts = [ 55 | os.path.dirname(func.__code__.co_filename), 56 | BASE_DATADIR, 57 | func.__module__, 58 | func.__name__, 59 | ] 60 | # for x in (fixture_names or []): 61 | # if callable(x): 62 | # part = x(request) 63 | # else: 64 | # part = request.getfixturevalue(x) 65 | # parts.append(part.__name__) 66 | # 67 | # destination = os.path.join(*parts) + '.fixture.json' 68 | seed = os.path.join(*parts) 69 | destination = fixture_name(seed, request) 70 | 71 | if not os.path.exists(destination) or os.environ.get("API_CHECKER_RESET"): 72 | mktree(os.path.dirname(destination)) 73 | data = func(*args, **kwargs) 74 | dump_fixtures({func.__name__: data}, destination) 75 | return load_fixtures(destination)[func.__name__] 76 | 77 | return pytest.fixture(_inner) 78 | 79 | return deco 80 | 81 | 82 | def get_data_dir(func): 83 | return os.path.join( 84 | os.path.dirname(inspect.getfile(func)), 85 | BASE_DATADIR, 86 | func.__module__, 87 | func.__name__, 88 | ) 89 | 90 | 91 | def contract( 92 | recorder_class=Recorder, 93 | allow_empty=False, 94 | name=None, 95 | method="get", 96 | checks=None, 97 | debug=False, 98 | **kwargs 99 | ): 100 | if kwargs: 101 | raise AttributeError("Unknown arguments %s" % ",".join(kwargs.keys())) 102 | 103 | def _inner1(func): 104 | @wraps(func) 105 | def _inner(*args, **kwargs): 106 | data_dir = get_data_dir(func) 107 | data = None 108 | url = func(*args, **kwargs) 109 | if isinstance(url, (list, tuple)): 110 | url, data = url 111 | recorder = recorder_class(data_dir) 112 | current, contract = recorder.assertCALL( 113 | url, 114 | allow_empty=allow_empty, 115 | checks=checks, 116 | name=name, 117 | method=method, 118 | data=data, 119 | ) 120 | if debug: 121 | sys.stderr.write("Current Response\n") 122 | sys.stderr.write(json.dumps(current.data, indent=4, sort_keys=True)) 123 | sys.stderr.write("Expected Response\n") 124 | sys.stderr.write(json.dumps(contract.data, indent=4, sort_keys=True)) 125 | 126 | return _inner 127 | 128 | return _inner1 129 | -------------------------------------------------------------------------------- /docs/overview.txt: -------------------------------------------------------------------------------- 1 | 2 | How it works: 3 | ------------- 4 | 5 | The First time the test is ran, the response and model instances are serialized and 6 | saved on the disk; any further execution is checked against this first response. 7 | 8 | Test data are saved in the same directory where the test module lives, 9 | under `_api_checker//` 10 | 11 | Fields that cannot be checked by value (ie timestamps/last modified) can be tested writing 12 | custom `assert_` methods. 13 | 14 | In case of nested objects, method names must follow the field "path". 15 | (ie. `assert_permission_modified` vs `assert_modified`) 16 | 17 | This module can also intercept when a field is added, 18 | in this case it is mandatory recreate stored test data; simply delete them from the disk 19 | or set `API_CHECKER_RESET` environment variable and run the test again, 20 | 21 | 22 | in case something goes wrong the output will be 23 | 24 | Field values mismatch 25 | ~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | .. code-block:: bash 28 | 29 | 30 | AssertionError: View '' breaks the contract. 31 | Field 'name' does not match. 32 | - expected: 'Partner 0' 33 | - received: 'Partner 11' 34 | 35 | 36 | Field removed 37 | ~~~~~~~~~~~~~ 38 | 39 | .. code-block:: bash 40 | 41 | 42 | AssertionError: View '' breaks the contract. 43 | Field 'id' is missing in the new response 44 | 45 | 46 | Field added 47 | ~~~~~~~~~~~ 48 | 49 | 50 | .. code-block:: bash 51 | 52 | 53 | AssertionError: View '' returned more field than expected. 54 | Action needed api_customers.response.json need rebuild. 55 | New fields are: 56 | '['country']' 57 | 58 | 59 | How To use it: 60 | -------------- 61 | 62 | **unittest** 63 | 64 | 65 | Using ApiCheckerMixin:: 66 | 67 | class TestAPIAgreements(ApiCheckerMixin, TestCase): 68 | def get_fixtures(self): 69 | return {'customer': CustomerFactory()} 70 | 71 | def test_customer_detail(self): 72 | url = reverse("customer-detail", args=[self.get_fixture('customer').pk]) 73 | self.assertGET(url) 74 | 75 | 76 | Using ApiCheckerBase metaclass:: 77 | 78 | 79 | class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase): 80 | URLS = [ 81 | reverse("intervention-list"), 82 | reverse("intervention-detail", args=[101]), 83 | ] 84 | 85 | def get_fixtures(cls): 86 | return {'intervention': InterventionFactory(id=101), 87 | 'result': ResultFactory(), 88 | } 89 | 90 | ApiCheckerBase can produce API test with minimum effort but it offers less flexibility 91 | than ApiCheckerMixin. 92 | 93 | **pytest** 94 | 95 | 96 | pytest integraation is provided by two helpers `frozenfixture` and `contract`:: 97 | 98 | 99 | from django.urls import reverse 100 | from drf_api_checker.pytest import contract, frozenfixture 101 | 102 | 103 | @frozenfixture 104 | def frozen_detail(db): 105 | from demo.factories import DetailFactory 106 | return DetailFactory() 107 | 108 | @contract() 109 | def test_url(frozen_detail): 110 | url = reverse("master-list") 111 | return url 112 | 113 | 114 | 115 | 116 | Links 117 | ----- 118 | 119 | ||| 120 | |--------------------|------------------------------------------------------------| 121 | | Develop | [![travis-png-d]][travis-l-d]| 122 | | Master | [![travis-png-m]][travis-l-m]| 123 | | Project home page: | https://github.com/saxix/drf-api-checker | 124 | | Issue tracker: | https://github.com/saxix/drf-api-checker/issues?sort | 125 | | Download: | http://pypi.python.org/pypi/drf-api-checker/ | 126 | | Documentation: | https://drf-api-checker.readthedocs.org/en/latest/ | 127 | 128 | 129 | 130 | [travis-png-m]: https://secure.travis-ci.org/saxix/drf-api-checker.svg?branch=master 131 | [travis-l-m]: https://travis-ci.org/saxix/drf-api-checker?branch=master 132 | 133 | [travis-png-d]: https://secure.travis-ci.org/saxix/drf-api-checker.svg?branch=develop 134 | [travis-l-d]: https://travis-ci.org/saxix/drf-api-checker?branch=develop 135 | 136 | [codecov-badge]: https://codecov.io/gh/saxix/drf-api-checker/branch/develop/graph/badge.svg 137 | [codecov]: https://codecov.io/gh/saxix/drf-api-checker 138 | 139 | [pypi-version]: https://img.shields.io/pypi/v/drf-api-checker.svg 140 | [pypi]: https://pypi.org/project/drf-api-checker/ 141 | -------------------------------------------------------------------------------- /docs/_ext/version.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from docutils.parsers.rst import Directive 4 | from sphinx import addnodes 5 | try: 6 | from sphinx.writers.html import SmartyPantsHTMLTranslator as HTMLTranslator 7 | except ImportError: # Sphinx 1.6+ 8 | from sphinx.writers.html import HTMLTranslator 9 | 10 | # RE for option descriptions without a '--' prefix 11 | simple_option_desc_re = re.compile( 12 | r'([-_a-zA-Z0-9]+)(\s*.*?)(?=,\s+(?:/|-|--)|$)') 13 | 14 | 15 | def setup(app): 16 | app.add_crossref_type( 17 | directivename="setting", 18 | rolename="setting", 19 | indextemplate="pair: %s; setting", 20 | ) 21 | app.add_crossref_type( 22 | directivename="templatetag", 23 | rolename="ttag", 24 | indextemplate="pair: %s; template tag" 25 | ) 26 | app.add_crossref_type( 27 | directivename="templatefilter", 28 | rolename="tfilter", 29 | indextemplate="pair: %s; template filter" 30 | ) 31 | app.add_crossref_type( 32 | directivename="fieldlookup", 33 | rolename="lookup", 34 | indextemplate="pair: %s; field lookup type", 35 | ) 36 | app.add_config_value('next_version', '0.0', True) 37 | app.add_directive('versionadded', VersionDirective) 38 | app.add_directive('versionchanged', VersionDirective) 39 | app.add_crossref_type( 40 | directivename="release", 41 | rolename="release", 42 | indextemplate="pair: %s; release", 43 | ) 44 | 45 | class DjangoHTMLTranslator(HTMLTranslator): 46 | """ 47 | Django-specific reST to HTML tweaks. 48 | """ 49 | 50 | # Don't use border=1, which docutils does by default. 51 | def visit_table(self, node): 52 | self.context.append(self.compact_p) 53 | self.compact_p = True 54 | self._table_row_index = 0 # Needed by Sphinx 55 | self.body.append(self.starttag(node, 'table', CLASS='docutils')) 56 | 57 | def depart_table(self, node): 58 | self.compact_p = self.context.pop() 59 | self.body.append('\n') 60 | 61 | def visit_desc_parameterlist(self, node): 62 | self.body.append('(') # by default sphinx puts around the "(" 63 | self.first_param = 1 64 | self.optional_param_level = 0 65 | self.param_separator = node.child_text_separator 66 | self.required_params_left = sum([isinstance(c, addnodes.desc_parameter) 67 | for c in node.children]) 68 | 69 | def depart_desc_parameterlist(self, node): 70 | self.body.append(')') 71 | 72 | 73 | version_text = { 74 | 'deprecated': 'Deprecated in DRF API Checker %s', 75 | 'versionchanged': 'Changed in DRF API Checker %s', 76 | 'versionadded': 'New in DRF API Checker %s', 77 | } 78 | 79 | def visit_versionmodified(self, node): 80 | self.body.append( 81 | self.starttag(node, 'div', CLASS=node['type']) 82 | ) 83 | version_text = self.version_text.get(node['type']) 84 | if version_text: 85 | title = "%s%s" % ( 86 | version_text % node['version'], 87 | ":" if len(node) else "." 88 | ) 89 | self.body.append('%s ' % title) 90 | 91 | def depart_versionmodified(self, node): 92 | self.body.append("\n") 93 | 94 | # Give each section a unique ID -- nice for custom CSS hooks 95 | def visit_section(self, node): 96 | old_ids = node.get('ids', []) 97 | node['ids'] = ['s-' + i for i in old_ids] 98 | node['ids'].extend(old_ids) 99 | SmartyPantsHTMLTranslator.visit_section(self, node) 100 | node['ids'] = old_ids 101 | 102 | 103 | 104 | class VersionDirective(Directive): 105 | has_content = True 106 | required_arguments = 1 107 | optional_arguments = 1 108 | final_argument_whitespace = True 109 | option_spec = {} 110 | 111 | def run(self): 112 | if len(self.arguments) > 1: 113 | msg = """Only one argument accepted for directive '{directive_name}::'. 114 | Comments should be provided as content, 115 | not as an extra argument.""".format(directive_name=self.name) 116 | raise self.error(msg) 117 | 118 | env = self.state.document.settings.env 119 | ret = [] 120 | node = addnodes.versionmodified() 121 | ret.append(node) 122 | 123 | if self.arguments[0] == env.config.next_version: 124 | node['version'] = "Development version" 125 | else: 126 | node['version'] = self.arguments[0] 127 | 128 | node['type'] = self.name 129 | if self.content: 130 | self.state.nested_parse(self.content, self.content_offset, node) 131 | env.note_versionchange(node['type'], node['version'], node, self.lineno) 132 | return ret 133 | -------------------------------------------------------------------------------- /src/drf_api_checker/utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime 3 | import json 4 | 5 | from django import VERSION as dj_version 6 | from django.core import serializers as ser 7 | from django.db import DEFAULT_DB_ALIAS 8 | 9 | from .collector import ForeignKeysCollector 10 | 11 | 12 | class ResponseEncoder(json.JSONEncoder): 13 | def default(self, obj): 14 | if dj_version >= (3, 2): 15 | from django.http.response import ResponseHeaders 16 | 17 | if isinstance(obj, ResponseHeaders): 18 | return dict(obj) 19 | if isinstance(obj, set): 20 | return list(obj) 21 | elif isinstance(obj, datetime.datetime): 22 | if obj.utcoffset() is not None: 23 | obj = obj - obj.utcoffset() 24 | millis = int( 25 | calendar.timegm(obj.timetuple()) * 1000 + obj.microsecond / 1000 26 | ) 27 | return millis 28 | # return json.JSONEncoder.default(self, obj) 29 | 30 | 31 | def _write(dest, content): 32 | if isinstance(dest, str): 33 | open(dest, "wb").write(content) 34 | elif hasattr(dest, "write"): 35 | dest.write(content) 36 | else: 37 | raise ValueError( 38 | f"'dest' must be a filepath or file-like object. It is {type(dest)}" 39 | ) 40 | 41 | 42 | def _read(source): 43 | if isinstance(source, str): 44 | return open(source, "rb").read() 45 | elif hasattr(source, "read"): 46 | return source.read() 47 | raise ValueError( 48 | f"'source' must be a filepath or file-like object. It is {type(source)}" 49 | ) 50 | 51 | 52 | def dump_fixtures(fixtures, destination): 53 | data = {} 54 | j = ser.get_serializer("json")() 55 | 56 | for k, instances in fixtures.items(): 57 | collector = ForeignKeysCollector(None) 58 | if isinstance(instances, (list, tuple)): 59 | data[k] = {"master": [], "deps": []} 60 | for r in instances: 61 | collector.collect([r]) 62 | ret = j.serialize(collector.data, use_natural_foreign_keys=False) 63 | data[k]["master"].append(json.loads(ret)[0]) 64 | data[k]["deps"].extend(json.loads(ret)[1:]) 65 | else: 66 | collector.collect([instances]) 67 | ret = j.serialize(collector.data, use_natural_foreign_keys=False) 68 | data[k] = {"master": json.loads(ret)[0], "deps": json.loads(ret)[1:]} 69 | 70 | _write(destination, json.dumps(data, indent=4, cls=ResponseEncoder).encode("utf8")) 71 | return data 72 | 73 | 74 | def load_fixtures(file, ignorenonexistent=False, using=DEFAULT_DB_ALIAS): 75 | content = json.loads(_read(file)) 76 | ret = {} 77 | for name, struct in content.items(): 78 | master = struct["master"] 79 | many = isinstance(master, (list, tuple)) 80 | deps = struct["deps"] 81 | if not many: 82 | master = [master] 83 | 84 | objects = ser.deserialize( 85 | "json", 86 | json.dumps(master + deps), 87 | using=using, 88 | ignorenonexistent=ignorenonexistent, 89 | ) 90 | saved = [] 91 | for obj in objects: 92 | # if router.allow_migrate_model(using, obj.object.__class__): 93 | obj.save(using=using) 94 | saved.append(obj.object) 95 | 96 | if many: 97 | ret[name] = list(saved[: len(master)]) 98 | else: 99 | ret[name] = saved[0] 100 | return ret 101 | 102 | 103 | def serialize_response(response): 104 | if dj_version < (3, 2): 105 | headers = response._headers 106 | content_type = ( 107 | response._headers["content-type"][1] 108 | if "content-type" in response._headers 109 | else None 110 | ) 111 | else: 112 | headers = response.headers 113 | content_type = ( 114 | response.headers["content-type"] 115 | if "content-type" in response.headers 116 | else None 117 | ) 118 | data = { 119 | "status_code": response.status_code, 120 | "headers": headers, 121 | "data": response.data, 122 | "content_type": content_type, 123 | } 124 | return json.dumps(data, indent=4, cls=ResponseEncoder).encode("utf8") 125 | 126 | 127 | def load_response(file_or_stream): 128 | from rest_framework.response import Response 129 | 130 | context = json.loads(_read(file_or_stream)) 131 | response = Response( 132 | context["data"], 133 | status=context["status_code"], 134 | content_type=context["content_type"], 135 | ) 136 | response._is_rendered = True 137 | if dj_version < (3, 2): 138 | response._headers = context["headers"] 139 | else: 140 | response.headers = context["headers"] 141 | return response 142 | -------------------------------------------------------------------------------- /docs/_ext/github.py: -------------------------------------------------------------------------------- 1 | """Define text roles for GitHub 2 | 3 | * ghissue - Issue 4 | * ghpull - Pull Request 5 | * ghuser - User 6 | 7 | Adapted from bitbucket example here: 8 | https://bitbucket.org/birkenfeld/sphinx-contrib/src/tip/bitbucket/sphinxcontrib/bitbucket.py 9 | 10 | Authors 11 | ------- 12 | 13 | * Doug Hellmann 14 | * Min RK 15 | """ 16 | # 17 | # Original Copyright (c) 2010 Doug Hellmann. All rights reserved. 18 | # 19 | 20 | from docutils import nodes, utils 21 | from docutils.parsers.rst.roles import set_classes 22 | 23 | 24 | def make_link_node(rawtext, app, type, slug, options): 25 | """Create a link to a github resource. 26 | 27 | :param rawtext: Text being replaced with link node. 28 | :param app: Sphinx application context 29 | :param type: Link type (issues, changeset, etc.) 30 | :param slug: ID of the thing to link to 31 | :param options: Options dictionary passed to role func. 32 | """ 33 | 34 | try: 35 | base = app.config.github_project_url 36 | if not base: 37 | raise AttributeError 38 | if not base.endswith('/'): 39 | base += '/' 40 | except AttributeError as err: 41 | raise ValueError('github_project_url configuration value is not set (%s)' % str(err)) 42 | 43 | ref = base + type + '/' + slug + '/' 44 | set_classes(options) 45 | prefix = "#" 46 | if type == 'pull': 47 | prefix = "PR " + prefix 48 | node = nodes.reference(rawtext, prefix + utils.unescape(slug), refuri=ref, 49 | **options) 50 | return node 51 | 52 | 53 | def ghissue_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 54 | """Link to a GitHub issue. 55 | 56 | Returns 2 part tuple containing list of nodes to insert into the 57 | document and a list of system messages. Both are allowed to be 58 | empty. 59 | 60 | :param name: The role name used in the document. 61 | :param rawtext: The entire markup snippet, with role. 62 | :param text: The text marked with the role. 63 | :param lineno: The line number where rawtext appears in the input. 64 | :param inliner: The inliner instance that called us. 65 | :param options: Directive options for customization. 66 | :param content: The directive content for customization. 67 | """ 68 | 69 | try: 70 | issue_num = int(text) 71 | if issue_num <= 0: 72 | raise ValueError 73 | except ValueError: 74 | msg = inliner.reporter.error( 75 | 'GitHub issue number must be a number greater than or equal to 1; ' 76 | '"%s" is invalid.' % text, line=lineno) 77 | prb = inliner.problematic(rawtext, rawtext, msg) 78 | return [prb], [msg] 79 | app = inliner.document.settings.env.app 80 | #app.info('issue %r' % text) 81 | if 'pull' in name.lower(): 82 | category = 'pull' 83 | elif 'issue' in name.lower(): 84 | category = 'issues' 85 | else: 86 | msg = inliner.reporter.error( 87 | 'GitHub roles include "ghpull" and "ghissue", ' 88 | '"%s" is invalid.' % name, line=lineno) 89 | prb = inliner.problematic(rawtext, rawtext, msg) 90 | return [prb], [msg] 91 | node = make_link_node(rawtext, app, category, str(issue_num), options) 92 | return [node], [] 93 | 94 | 95 | def ghuser_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 96 | """Link to a GitHub user. 97 | 98 | Returns 2 part tuple containing list of nodes to insert into the 99 | document and a list of system messages. Both are allowed to be 100 | empty. 101 | 102 | :param name: The role name used in the document. 103 | :param rawtext: The entire markup snippet, with role. 104 | :param text: The text marked with the role. 105 | :param lineno: The line number where rawtext appears in the input. 106 | :param inliner: The inliner instance that called us. 107 | :param options: Directive options for customization. 108 | :param content: The directive content for customization. 109 | """ 110 | app = inliner.document.settings.env.app 111 | #app.info('user link %r' % text) 112 | ref = 'https://www.github.com/' + text 113 | node = nodes.reference(rawtext, text, refuri=ref, **options) 114 | return [node], [] 115 | 116 | 117 | def ghcommit_role(name, rawtext, text, lineno, inliner, options={}, content=[]): 118 | """Link to a GitHub commit. 119 | 120 | Returns 2 part tuple containing list of nodes to insert into the 121 | document and a list of system messages. Both are allowed to be 122 | empty. 123 | 124 | :param name: The role name used in the document. 125 | :param rawtext: The entire markup snippet, with role. 126 | :param text: The text marked with the role. 127 | :param lineno: The line number where rawtext appears in the input. 128 | :param inliner: The inliner instance that called us. 129 | :param options: Directive options for customization. 130 | :param content: The directive content for customization. 131 | """ 132 | app = inliner.document.settings.env.app 133 | #app.info('user link %r' % text) 134 | try: 135 | base = app.config.github_project_url 136 | if not base: 137 | raise AttributeError 138 | if not base.endswith('/'): 139 | base += '/' 140 | except AttributeError as err: 141 | raise ValueError('github_project_url configuration value is not set (%s)' % str(err)) 142 | 143 | ref = base + text 144 | node = nodes.reference(rawtext, text[:6], refuri=ref, **options) 145 | return [node], [] 146 | 147 | 148 | def setup(app): 149 | """Install the plugin. 150 | 151 | :param app: Sphinx application context. 152 | """ 153 | app.info('Initializing GitHub plugin') 154 | app.add_role('ghissue', ghissue_role) 155 | app.add_role('ghpull', ghissue_role) 156 | app.add_role('ghuser', ghuser_role) 157 | app.add_role('ghcommit', ghcommit_role) 158 | app.add_config_value('github_project_url', None, 'env') 159 | return 160 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DRF API Checker 2 | 3 | 4 | [![pypi-version]][pypi] 5 | [![travis-png-m]][travis-l-m] 6 | [![rtd-badge]][rtd-link] 7 | [![codecov-badge]][codecov] 8 | 9 | 10 | This module offers some utilities to avoid unwanted changes in Django Rest Framework responses, 11 | so to keep stable contracts 12 | 13 | The purpose is to guarantee that any code changes never introduce 'contract violations' 14 | changing the Serialization behaviour. 15 | 16 | 17 | Contract violations can happen when: 18 | 19 | - fields are removed from Serializer 20 | - field representation changes (ie. date/number format, ) 21 | - Response status code changes (optional check) 22 | - Response headers change (optional check) 23 | 24 | 25 | How it works: 26 | ------------- 27 | 28 | First time the test run, the response and model instances are serialized and 29 | saved on the disk; any further execution is checked against this first response. 30 | 31 | Test data are saved in the same directory where the test module lives, 32 | under `_api_checker//` 33 | 34 | Fields that cannot be checked by value (ie timestamps/last modified) can be tested writing 35 | custom `assert_` methods. 36 | 37 | In case of nested objects, method names must follow the field "path". 38 | (ie. `assert_permission_modified` vs `assert_modified`) 39 | 40 | This module can also intercept when a field is added, 41 | in this case it is mandatory recreate stored test data; simply delete them from the disk 42 | or set `API_CHECKER_RESET` environment variable and run the test again, 43 | 44 | 45 | in case something goes wrong the output will be 46 | 47 | **Field values mismatch** 48 | 49 | AssertionError: View `` breaks the contract. 50 | Field `name` does not match. 51 | - expected: `Partner 0` 52 | - received: `Partner 11` 53 | 54 | 55 | **Field removed** 56 | 57 | AssertionError: View `` breaks the contract. 58 | Field `id` is missing in the new response 59 | 60 | 61 | **Field added** 62 | 63 | AssertionError: View `` returned more field than expected. 64 | Action needed api_customers.response.json need rebuild. 65 | New fields are: 66 | `['country']` 67 | 68 | 69 | How To use it: 70 | -------------- 71 | 72 | **unittest** 73 | 74 | 75 | Using ApiCheckerMixin:: 76 | 77 | class TestAPIAgreements(ApiCheckerMixin, TestCase): 78 | def get_fixtures(self): 79 | return {'customer': CustomerFactory()} 80 | 81 | def test_customer_detail(self): 82 | url = reverse("customer-detail", args=[self.get_fixture('customer').pk]) 83 | self.assertGET(url) 84 | 85 | 86 | Using ApiCheckerBase metaclass 87 | 88 | 89 | class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase): 90 | URLS = [ 91 | reverse("intervention-list"), 92 | reverse("intervention-detail", args=[101]), 93 | ] 94 | 95 | def get_fixtures(cls): 96 | return {'intervention': InterventionFactory(id=101), 97 | 'result': ResultFactory(), 98 | } 99 | 100 | ApiCheckerBase can produce API test with minimum effort but it offers less flexibility 101 | than ApiCheckerMixin. 102 | 103 | **pytest** 104 | 105 | 106 | pytest integration is provided by two helpers `frozenfixture` and `contract`:: 107 | 108 | 109 | from django.urls import reverse 110 | from drf_api_checker.pytest import contract, frozenfixture 111 | 112 | 113 | @frozenfixture() 114 | def frozen_detail(db): 115 | from demo.factories import DetailFactory 116 | return DetailFactory() 117 | 118 | @contract() 119 | def test_url(frozen_detail): 120 | url = reverse("master-list") 121 | return url 122 | 123 | 124 | Custom checks: 125 | -------------- 126 | 127 | Sometimes it is not possible to check a field by value, but exists anyway a mechanism 128 | to check the contract (ie. `timestamp` field - _ignore for this example tools like [freezegun](https://github.com/spulec/freezegun)_) 129 | 130 | To handle these situations you can write custom `Recorder` with custom `asserters`: 131 | 132 | 133 | from drf_api_checker.recorder import Recorder 134 | 135 | class TimestampRecorder(Recorder): 136 | 137 | def assert_last_modify_date(self, response: Response, stored: Response, path: str): 138 | value = response['last_modify_date'] 139 | assert datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%fZ') 140 | 141 | custom asserter is a method named `assert_`, in case of nested serializers 142 | you can have more specific asserter using `assert__` 143 | 144 | 145 | Contributing 146 | ------------ 147 | 148 | This project uses [poetry](https://python-poetry.org/docs/versions/) as package manager. It does not contains `setup.py`. 149 | To setup the development environment and run tests you should: 150 | 151 | poetry install 152 | poetry run tox 153 | 154 | To activate 155 | 156 | 157 | Links 158 | ----- 159 | 160 | ||| 161 | |--------------------|------------------------------------------------------------| 162 | | Develop | [![travis-png-d]][travis-l-d]| 163 | | Master | [![travis-png-m]][travis-l-m]| 164 | | Project home page: | https://github.com/saxix/drf-api-checker | 165 | | Issue tracker: | https://github.com/saxix/drf-api-checker/issues?sort | 166 | | Download: | http://pypi.python.org/pypi/drf-api-checker/ | 167 | | Documentation: | https://drf-api-checker.readthedocs.org/en/develop/ | 168 | 169 | 170 | 171 | [travis-png-m]: https://secure.travis-ci.org/saxix/drf-api-checker.svg?branch=master 172 | [travis-l-m]: https://travis-ci.org/saxix/drf-api-checker?branch=master 173 | 174 | [travis-png-d]: https://secure.travis-ci.org/saxix/drf-api-checker.svg?branch=develop 175 | [travis-l-d]: https://travis-ci.org/saxix/drf-api-checker?branch=develop 176 | 177 | [rtd-badge]: https://readthedocs.org/projects/drf-api-checker/badge/?version=master&style=plastic 178 | [rtd-link]: https://drf-api-checker.readthedocs.org/en/master/ 179 | 180 | [codecov-badge]: https://codecov.io/gh/saxix/drf-api-checker/branch/develop/graph/badge.svg 181 | [codecov]: https://codecov.io/gh/saxix/drf-api-checker 182 | 183 | [pypi-version]: https://img.shields.io/pypi/v/drf-api-checker.svg 184 | [pypi]: https://pypi.org/project/drf-api-checker/ 185 | -------------------------------------------------------------------------------- /docs/_ext/djangodocs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Sphinx plugins for Django documentation. 3 | """ 4 | import json 5 | import os 6 | import re 7 | 8 | from sphinx import addnodes, __version__ as sphinx_ver 9 | from sphinx.builders.html import StandaloneHTMLBuilder 10 | from sphinx.writers.html import SmartyPantsHTMLTranslator 11 | from sphinx.util.console import bold 12 | from sphinx.util.compat import Directive 13 | 14 | # RE for option descriptions without a '--' prefix 15 | simple_option_desc_re = re.compile( 16 | r'([-_a-zA-Z0-9]+)(\s*.*?)(?=,\s+(?:/|-|--)|$)') 17 | 18 | def setup(app): 19 | app.add_crossref_type( 20 | directivename = "setting", 21 | rolename = "setting", 22 | indextemplate = "pair: %s; setting", 23 | ) 24 | app.add_crossref_type( 25 | directivename = "templatetag", 26 | rolename = "ttag", 27 | indextemplate = "pair: %s; template tag" 28 | ) 29 | app.add_crossref_type( 30 | directivename = "templatefilter", 31 | rolename = "tfilter", 32 | indextemplate = "pair: %s; template filter" 33 | ) 34 | app.add_crossref_type( 35 | directivename = "fieldlookup", 36 | rolename = "lookup", 37 | indextemplate = "pair: %s; field lookup type", 38 | ) 39 | app.add_description_unit( 40 | directivename = "django-admin", 41 | rolename = "djadmin", 42 | indextemplate = "pair: %s; django-admin command", 43 | parse_node = parse_django_admin_node, 44 | ) 45 | app.add_description_unit( 46 | directivename = "django-admin-option", 47 | rolename = "djadminopt", 48 | indextemplate = "pair: %s; django-admin command-line option", 49 | parse_node = parse_django_adminopt_node, 50 | ) 51 | app.add_config_value('django_next_version', '0.0', True) 52 | app.add_directive('versionadded', VersionDirective) 53 | app.add_directive('versionchanged', VersionDirective) 54 | app.add_builder(DjangoStandaloneHTMLBuilder) 55 | 56 | 57 | class VersionDirective(Directive): 58 | has_content = True 59 | required_arguments = 1 60 | optional_arguments = 1 61 | final_argument_whitespace = True 62 | option_spec = {} 63 | 64 | def run(self): 65 | if len(self.arguments) > 1: 66 | msg = """Only one argument accepted for directive '{directive_name}::'. 67 | Comments should be provided as content, 68 | not as an extra argument.""".format(directive_name=self.name) 69 | raise self.error(msg) 70 | 71 | env = self.state.document.settings.env 72 | ret = [] 73 | node = addnodes.versionmodified() 74 | ret.append(node) 75 | 76 | if self.arguments[0] == env.config.next_version: 77 | node['version'] = "Development version" 78 | else: 79 | node['version'] = self.arguments[0] 80 | 81 | node['type'] = self.name 82 | if self.content: 83 | self.state.nested_parse(self.content, self.content_offset, node) 84 | env.note_versionchange(node['type'], node['version'], node, self.lineno) 85 | return ret 86 | 87 | 88 | class DjangoHTMLTranslator(SmartyPantsHTMLTranslator): 89 | """ 90 | Django-specific reST to HTML tweaks. 91 | """ 92 | 93 | # Don't use border=1, which docutils does by default. 94 | def visit_table(self, node): 95 | self._table_row_index = 0 # Needed by Sphinx 96 | self.body.append(self.starttag(node, 'table', CLASS='docutils')) 97 | 98 | # ? Really? 99 | def visit_desc_parameterlist(self, node): 100 | self.body.append('(') 101 | self.first_param = 1 102 | self.param_separator = node.child_text_separator 103 | 104 | def depart_desc_parameterlist(self, node): 105 | self.body.append(')') 106 | 107 | if sphinx_ver < '1.0.8': 108 | # 109 | # Don't apply smartypants to literal blocks 110 | # 111 | def visit_literal_block(self, node): 112 | self.no_smarty += 1 113 | SmartyPantsHTMLTranslator.visit_literal_block(self, node) 114 | 115 | def depart_literal_block(self, node): 116 | SmartyPantsHTMLTranslator.depart_literal_block(self, node) 117 | self.no_smarty -= 1 118 | 119 | # 120 | # Turn the "new in version" stuff (versionadded/versionchanged) into a 121 | # better callout -- the Sphinx default is just a little span, 122 | # which is a bit less obvious that I'd like. 123 | # 124 | # FIXME: these messages are all hardcoded in English. We need to change 125 | # that to accommodate other language docs, but I can't work out how to make 126 | # that work. 127 | # 128 | version_text = { 129 | 'deprecated': 'Deprecated in Django %s', 130 | 'versionchanged': 'Changed in Django %s', 131 | 'versionadded': 'New in Django %s', 132 | } 133 | 134 | def visit_versionmodified(self, node): 135 | self.body.append( 136 | self.starttag(node, 'div', CLASS=node['type']) 137 | ) 138 | title = "%s%s" % ( 139 | self.version_text[node['type']] % node['version'], 140 | ":" if len(node) else "." 141 | ) 142 | self.body.append('%s ' % title) 143 | 144 | def depart_versionmodified(self, node): 145 | self.body.append("\n") 146 | 147 | # Give each section a unique ID -- nice for custom CSS hooks 148 | def visit_section(self, node): 149 | old_ids = node.get('ids', []) 150 | node['ids'] = ['s-' + i for i in old_ids] 151 | node['ids'].extend(old_ids) 152 | SmartyPantsHTMLTranslator.visit_section(self, node) 153 | node['ids'] = old_ids 154 | 155 | def parse_django_admin_node(env, sig, signode): 156 | command = sig.split(' ')[0] 157 | env._django_curr_admin_command = command 158 | title = "django-admin.py %s" % sig 159 | signode += addnodes.desc_name(title, title) 160 | return sig 161 | 162 | def parse_django_adminopt_node(env, sig, signode): 163 | """A copy of sphinx.directives.CmdoptionDesc.parse_signature()""" 164 | from sphinx.domains.std import option_desc_re 165 | count = 0 166 | firstname = '' 167 | for m in option_desc_re.finditer(sig): 168 | optname, args = m.groups() 169 | if count: 170 | signode += addnodes.desc_addname(', ', ', ') 171 | signode += addnodes.desc_name(optname, optname) 172 | signode += addnodes.desc_addname(args, args) 173 | if not count: 174 | firstname = optname 175 | count += 1 176 | if not count: 177 | for m in simple_option_desc_re.finditer(sig): 178 | optname, args = m.groups() 179 | if count: 180 | signode += addnodes.desc_addname(', ', ', ') 181 | signode += addnodes.desc_name(optname, optname) 182 | signode += addnodes.desc_addname(args, args) 183 | if not count: 184 | firstname = optname 185 | count += 1 186 | if not firstname: 187 | raise ValueError 188 | return firstname 189 | 190 | 191 | class DjangoStandaloneHTMLBuilder(StandaloneHTMLBuilder): 192 | """ 193 | Subclass to add some extra things we need. 194 | """ 195 | 196 | name = 'djangohtml' 197 | 198 | def finish(self): 199 | super().finish() 200 | self.info(bold("writing templatebuiltins.js...")) 201 | xrefs = self.env.domaindata["std"]["objects"] 202 | templatebuiltins = { 203 | "ttags": [n for ((t, n), (l, a)) in xrefs.items() 204 | if t == "templatetag" and l == "ref/templates/builtins"], 205 | "tfilters": [n for ((t, n), (l, a)) in xrefs.items() 206 | if t == "templatefilter" and l == "ref/templates/builtins"], 207 | } 208 | outfilename = os.path.join(self.outdir, "templatebuiltins.js") 209 | with open(outfilename, 'w') as fp: 210 | fp.write('var django_template_builtins = ') 211 | json.dump(templatebuiltins, fp) 212 | fp.write(';\n') 213 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Django Grappelli documentation build configuration file, created by 2 | # sphinx-quickstart on Sun Dec 5 19:11:46 2010. 3 | # 4 | # This file is execfile()d with the current directory set to its containing dir. 5 | # 6 | # Note that not all possible configuration values are present in this 7 | # autogenerated file. 8 | # 9 | # All configuration values have a default; values that are commented out 10 | # serve to show the default. 11 | 12 | import os 13 | import sys 14 | 15 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))) 16 | 17 | from django.conf import settings # noqa isort:skip 18 | 19 | settings.configure() 20 | 21 | import drf_api_checker # noqa isort:skip 22 | 23 | # os.environ['DJANGO_SETTINGS_MODULE']= 'django.conf.global_settings' 24 | 25 | # If extensions (or modules to document with autodoc) are in another directory, 26 | # add these directories to sys.path here. If the directory is relative to the 27 | # documentation root, use os.path.abspath to make it absolute, like shown here. 28 | # sys.path.insert(0, os.path.abspath('.')) 29 | 30 | # -- General configuration ----------------------------------------------------- 31 | 32 | # If your documentation needs a minimal Sphinx version, state it here. 33 | # needs_sphinx = '1.0' 34 | 35 | # Add any Sphinx extension module names here, as strings. They can be extensions 36 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 37 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "_ext"))) 38 | extensions = ['sphinx.ext.autodoc', 39 | 'sphinx.ext.todo', 40 | 'sphinx.ext.graphviz', 41 | 'sphinx.ext.intersphinx', 42 | 'sphinx.ext.doctest', 43 | 'sphinx.ext.extlinks', 44 | 'sphinx.ext.autosummary', 45 | 'sphinx.ext.coverage', 46 | 'sphinx.ext.viewcode', 47 | # 'github' 48 | ] 49 | 50 | intersphinx_mapping = { 51 | # 'python': ('http://python.readthedocs.org/en/v2.7.3/', None), 52 | 'django': ('http://django.readthedocs.org/en/latest/', None), 53 | 'sphinx': ('http://sphinx.readthedocs.org/en/latest/', None), 54 | } 55 | extlinks = {'issue': ('https://github.com/saxix/drf-api-checker/issues/%s', 'issue #'), 56 | 'django_issue': ('https://code.djangoproject.com/ticket/%s', 'issue #'), 57 | } 58 | 59 | github_project_url = 'https://github.com/saxix/drf-api-checker' 60 | 61 | todo_include_todos = True 62 | 63 | # Add any paths that contain templates here, relative to this directory. 64 | templates_path = ['_templates'] 65 | 66 | # The suffix of source filenames. 67 | source_suffix = '.rst' 68 | 69 | # The encoding of source files. 70 | # source_encoding = 'utf-8-sig' 71 | 72 | # The master toctree document. 73 | master_doc = 'index' 74 | 75 | # HTML translator class for the builder 76 | html_translator_class = "version.DjangoHTMLTranslator" 77 | 78 | # General information about the project. 79 | project = u'DRF API Checker' 80 | copyright = u'2018-2019, Stefano Apostolico' 81 | 82 | # The version info for the project you're documenting, acts as replacement for 83 | # |version| and |release|, also used in various other places throughout the 84 | # built documents. 85 | # 86 | # The short X.Y version. 87 | version = ".".join(drf_api_checker.VERSION.split('.')[0:2]) 88 | # The full version, including alpha/beta/rc tags. 89 | release = drf_api_checker.VERSION 90 | next_version = '0.8' 91 | 92 | # The language for content autogenerated by Sphinx. Refer to documentation 93 | # for a list of supported langua8ges. 94 | # language = None 95 | 96 | # There are two options for replacing |today|: either, you set today to some 97 | # non-false value, then it is used: 98 | # today = '' 99 | # Else, today_fmt is used as the format for a strftime call. 100 | # today_fmt = '%B %d, %Y' 101 | 102 | # List of patterns, relative to source directory, that match files and 103 | # directories to ignore when looking for source files. 104 | exclude_patterns = ['_build'] 105 | 106 | # The reST default role (used for this markup: `text`) to use for all documents. 107 | # default_role = None 108 | 109 | # If true, '()' will be appended to :func: etc. cross-reference text. 110 | # add_function_parentheses = True 111 | 112 | # If true, the current module name will be prepended to all description 113 | # unit titles (such as .. function::). 114 | # add_module_names = True 115 | 116 | # If true, sectionauthor and moduleauthor directives will be shown in the 117 | # output. They are ignored by default. 118 | # show_authors = False 119 | 120 | # The name of the Pygments (syntax highlighting) style to use. 121 | pygments_style = 'sphinx' 122 | 123 | # A list of ignored prefixes for module index sorting. 124 | # modindex_common_prefix = [] 125 | 126 | 127 | # -- Options for HTML output --------------------------------------------------- 128 | 129 | # The theme to use for HTML and HTML Help pages. See the documentation for 130 | # a list of builtin themes. 131 | html_theme = "default" 132 | # 133 | # Theme options are theme-specific and customize the look and feel of a theme 134 | # further. For a list of options available for each theme, see the 135 | # documentation. 136 | # html_theme_options = {} 137 | 138 | # Add any paths that contain custom themes here, relative to this directory. 139 | 140 | 141 | # The name for this set of Sphinx documents. If None, it defaults to 142 | # " v documentation". 143 | # html_title = None 144 | 145 | # A shorter title for the navigation bar. Default is the same as html_title. 146 | # html_short_title = None 147 | 148 | # The name of an image file (relative to this directory) to place at the top 149 | # of the sidebar. 150 | # html_logo = None 151 | 152 | # The name of an image file (within the static path) to use as favicon of the 153 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 154 | # pixels large. 155 | # html_favicon = None 156 | 157 | # Add any paths that contain custom static files (such as style sheets) here, 158 | # relative to this directory. They are copied after the builtin static files, 159 | # so a file named "default.css" will overwrite the builtin "default.css". 160 | # html_static_path = ['_static'] 161 | 162 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 163 | # using the given strftime format. 164 | # html_last_updated_fmt = '%b %d, %Y' 165 | 166 | # If true, SmartyPants will be used to convert quotes and dashes to 167 | # typographically correct entities. 168 | # html_use_smartypants = True 169 | 170 | # Custom sidebar templates, maps document names to template names. 171 | # html_sidebars = {} 172 | 173 | # Additional templates that should be rendered to pages, maps page names to 174 | # template names. 175 | # html_additional_pages = {} 176 | 177 | # If false, no module index is generated. 178 | # html_domain_indices = True 179 | 180 | # If false, no index is generated. 181 | # html_use_index = True 182 | 183 | # If true, the index is split into individual pages for each letter. 184 | # html_split_index = False 185 | 186 | # If true, links to the reST sources are added to the pages. 187 | # html_show_sourcelink = True 188 | 189 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 190 | # html_show_sphinx = True 191 | 192 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 193 | # html_show_copyright = True 194 | 195 | # If true, an OpenSearch description file will be output, and all pages will 196 | # contain a tag referring to it. The value of this option must be the 197 | # base URL from which the finished HTML is served. 198 | # html_use_opensearch = '' 199 | 200 | # This is the file name suffix for HTML files (e.. ".xhtml"). 201 | # html_file_suffix = None 202 | 203 | # Output file base name for HTML help builder. 204 | htmlhelp_basename = 'drf_api_checkerdoc' 205 | 206 | # -- Options for LaTeX output -------------------------------------------------- 207 | 208 | # The paper size ('letter' or 'a4'). 209 | # latex_paper_size = 'letter' 210 | 211 | # The font size ('10pt', '11pt' or '12pt'). 212 | # latex_font_size = '10pt' 213 | 214 | # Grouping the document tree into LaTeX files. List of tuples 215 | # (source start file, target name, title, author, documentclass [howto/manual]). 216 | latex_documents = [ 217 | ('index', 'drf_api_checker.tex', u'DRF API Checker Documentation', 218 | u'Stefano Apostolico', 'manual'), 219 | ] 220 | 221 | # The name of an image file (relative to this directory) to place at the top of 222 | # the title page. 223 | # latex_logo = None 224 | 225 | # For "manual" documents, if this is true, then toplevel headings are parts, 226 | # not chapters. 227 | # latex_use_parts = False 228 | 229 | # If true, show page references after internal links. 230 | # latex_show_pagerefs = False 231 | 232 | # If true, show URL addresses after external links. 233 | # latex_show_urls = False 234 | 235 | # Additional stuff for the LaTeX preamble. 236 | # latex_preamble = '' 237 | 238 | # Documents to append as an appendix to all manuals. 239 | # latex_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | # latex_domain_indices = True 243 | 244 | 245 | # -- Options for manual page output -------------------------------------------- 246 | 247 | # One entry per manual page. List of tuples 248 | # (source start file, name, description, authors, manual section). 249 | man_pages = [ 250 | ('index', 'drf_api_checker', u'DRF API Checker Documentation', 251 | [u'Stefano Apostolico'], 1) 252 | ] 253 | -------------------------------------------------------------------------------- /src/drf_api_checker/unittest.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | 4 | from rest_framework.test import APIClient 5 | 6 | from drf_api_checker.fs import clean_url, get_filename 7 | from drf_api_checker.recorder import BASE_DATADIR, DEFAULT_CHECKS, Recorder 8 | from drf_api_checker.utils import dump_fixtures, load_fixtures 9 | 10 | 11 | class ApiCheckerMixin: 12 | """ 13 | Mixin to enable API contract check 14 | 15 | How to use it: 16 | 17 | - implement get_fixtures() to create data for test. It should returns a dictionary 18 | - use self.assert(url) to check urls contract 19 | 20 | Example: 21 | 22 | class TestAPIAgreements(ApiCheckerMixin, TestCase): 23 | def get_fixtures(self): 24 | return {'customer': CustomerFactory()} 25 | 26 | def test_customer_detail(self): 27 | url = reverse("customer-detail", args=[self.get_fixture('customer').pk]) 28 | self.assertGET(url) 29 | 30 | """ 31 | 32 | recorder_class = Recorder 33 | client_class = APIClient 34 | expect_errors = False 35 | allow_empty = False 36 | checks = list(DEFAULT_CHECKS) 37 | 38 | def setUp(self): 39 | super().setUp() 40 | # self.client = self.client_class() if self.client_class else APIClient() 41 | self._process_fixtures() 42 | self.recorder = self.recorder_class(self.data_dir, self) 43 | if hasattr(self, "check_headers"): 44 | raise DeprecationWarning( 45 | "'check_headers' has been deprecated. Use 'checks' instead." 46 | ) 47 | if hasattr(self, "check_status"): 48 | raise DeprecationWarning( 49 | "'check_status' has been deprecated. Use 'checks' instead." 50 | ) 51 | 52 | @property 53 | def data_dir(self): 54 | cls = type(self) 55 | return os.path.join( 56 | os.path.dirname(inspect.getfile(cls)), 57 | BASE_DATADIR, 58 | cls.__module__, 59 | cls.__name__, 60 | ) 61 | 62 | def get_fixtures_filename(self, basename="fixtures"): 63 | return get_filename(self.data_dir, f"{basename}.json") 64 | 65 | def get_fixtures(self): 66 | """returns test fixtures. 67 | Should returns a dictionary where any key is a fixture name 68 | the value should be a Model instance (or a list). 69 | 70 | {'user' : UserFactory(username='user'), 71 | 'partners': [PartnerFactory(), PartnerFactory()], 72 | } 73 | 74 | fixtures can be accessed using `get_fixture()` 75 | """ 76 | return {} # pragma: no cover 77 | 78 | def get_fixture(self, name): 79 | """ 80 | returns fixture `name` loaded by `get_fixtures()` 81 | """ 82 | return self.__fixtures[name] # pragma: no cover 83 | 84 | def _process_fixtures(self): 85 | """store or retrieve test fixtures""" 86 | fname = self.get_fixtures_filename() 87 | if os.path.exists(fname) and not os.environ.get("API_CHECKER_RESET"): 88 | self.__fixtures = load_fixtures(fname) 89 | else: 90 | self.__fixtures = self.get_fixtures() 91 | if self.__fixtures: 92 | dump_fixtures(self.__fixtures, fname) 93 | 94 | def _assertCALL( 95 | self, 96 | url, 97 | *, 98 | allow_empty=None, 99 | checks=None, 100 | expect_errors=None, 101 | name=None, 102 | method="get", 103 | data=None, 104 | **kwargs, 105 | ): 106 | if "check_headers" in kwargs: 107 | raise DeprecationWarning( 108 | "'check_headers' has been deprecated. Use 'checks' instead." 109 | ) 110 | if "check_status" in kwargs: 111 | raise DeprecationWarning( 112 | "'check_status' has been deprecated. Use 'checks' instead." 113 | ) 114 | if kwargs: 115 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 116 | expect_errors = self.expect_errors if expect_errors is None else expect_errors 117 | allow_empty = self.allow_empty if allow_empty is None else allow_empty 118 | self.recorder.assertCALL( 119 | url, 120 | method=method, 121 | allow_empty=allow_empty, 122 | checks=checks, 123 | expect_errors=expect_errors, 124 | name=name, 125 | data=data, 126 | ) 127 | 128 | def assertGET( 129 | self, 130 | url, 131 | allow_empty=None, 132 | checks=None, 133 | expect_errors=None, 134 | name=None, 135 | data=None, 136 | **kwargs, 137 | ): 138 | if "check_headers" in kwargs: 139 | raise DeprecationWarning( 140 | "'check_headers' has been deprecated. Use 'checks' instead." 141 | ) 142 | if "check_status" in kwargs: 143 | raise DeprecationWarning( 144 | "'check_status' has been deprecated. Use 'checks' instead." 145 | ) 146 | if kwargs: 147 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 148 | self._assertCALL( 149 | url, 150 | method="get", 151 | allow_empty=allow_empty, 152 | checks=checks, 153 | expect_errors=expect_errors, 154 | name=name, 155 | data=data, 156 | ) 157 | 158 | def assertPUT( 159 | self, 160 | url, 161 | data, 162 | allow_empty=None, 163 | checks=None, 164 | expect_errors=None, 165 | name=None, 166 | **kwargs, 167 | ): 168 | if "check_headers" in kwargs: 169 | raise DeprecationWarning( 170 | "'check_headers' has been deprecated. Use 'checks' instead." 171 | ) 172 | if "check_status" in kwargs: 173 | raise DeprecationWarning( 174 | "'check_status' has been deprecated. Use 'checks' instead." 175 | ) 176 | if kwargs: 177 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 178 | self._assertCALL( 179 | url, 180 | method="put", 181 | allow_empty=allow_empty, 182 | checks=checks, 183 | expect_errors=expect_errors, 184 | name=name, 185 | data=data, 186 | ) 187 | 188 | def assertPOST( 189 | self, 190 | url, 191 | data, 192 | allow_empty=None, 193 | checks=None, 194 | expect_errors=None, 195 | name=None, 196 | **kwargs, 197 | ): 198 | if "check_headers" in kwargs: 199 | raise DeprecationWarning( 200 | "'check_headers' has been deprecated. Use 'checks' instead." 201 | ) 202 | if "check_status" in kwargs: 203 | raise DeprecationWarning( 204 | "'check_status' has been deprecated. Use 'checks' instead." 205 | ) 206 | if kwargs: 207 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 208 | self._assertCALL( 209 | url, 210 | data=data, 211 | method="post", 212 | allow_empty=allow_empty, 213 | checks=checks, 214 | expect_errors=expect_errors, 215 | name=name, 216 | ) 217 | 218 | def assertDELETE( 219 | self, 220 | url, 221 | allow_empty=None, 222 | checks=None, 223 | expect_errors=None, 224 | name=None, 225 | **kwargs, 226 | ): 227 | if "check_headers" in kwargs: 228 | raise DeprecationWarning( 229 | "'check_headers' has been deprecated. Use 'checks' instead." 230 | ) 231 | if "check_status" in kwargs: 232 | raise DeprecationWarning( 233 | "'check_status' has been deprecated. Use 'checks' instead." 234 | ) 235 | if kwargs: 236 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 237 | self._assertCALL( 238 | url, 239 | method="delete", 240 | allow_empty=allow_empty, 241 | checks=checks, 242 | expect_errors=expect_errors, 243 | name=name, 244 | ) 245 | 246 | 247 | class ApiCheckerBase(type): 248 | """ 249 | Custom _type_, intended to be used as metaclass. 250 | It will create a test for each url defined in URLS in the format 251 | ``test__``, if a method with the same name is found the 252 | creation is skipped reading this as an intention to have a custom test for that url. 253 | 254 | class TestAPIIntervention(TestCase, metaclass=ApiCheckerBase): 255 | URLS = [ 256 | reverse("intervention-list"), 257 | reverse("intervention-detail", args=[101]), 258 | ] 259 | def get_fixtures(cls): 260 | return {'intervention': InterventionFactory(id=101), 261 | 'result': ResultFactory(), 262 | } 263 | 264 | running this code will produce... 265 | 266 | ... 267 | test_url__api_v2_interventions (etools.applications.partners.tests.test_api.TestAPIIntervention) ... ok 268 | test_url__api_v2_interventions_101 (etools.applications.partners.tests.test_api.TestAPIIntervention) ... ok 269 | ... 270 | 271 | 272 | """ 273 | 274 | mixin = ApiCheckerMixin 275 | 276 | def __new__(cls, clsname, superclasses, attributedict): 277 | superclasses = (cls.mixin,) + superclasses 278 | clazz = type.__new__(cls, clsname, superclasses, attributedict) 279 | 280 | def check_url(url): 281 | if isinstance(url, (list, tuple)): 282 | url, data = url 283 | else: 284 | data = None 285 | 286 | def _inner(self): 287 | self.assertGET(url, data=data) 288 | 289 | _inner.__name__ = "test_url__" + clean_url("get", url, data) 290 | return _inner 291 | 292 | if "URLS" not in attributedict: # pragma: no cover 293 | raise ValueError( 294 | f"Error creating {clsname}. " f"ApiCheckerBase requires URLS attribute " 295 | ) 296 | 297 | for u in attributedict["URLS"]: 298 | m = check_url(u) 299 | if not hasattr(clazz, m.__name__): 300 | setattr(clazz, m.__name__, m) 301 | 302 | return clazz 303 | -------------------------------------------------------------------------------- /src/drf_api_checker/recorder.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import OrderedDict 3 | 4 | from django import VERSION as dj_version 5 | from django.conf import settings 6 | from django.urls import resolve 7 | 8 | from drf_api_checker.exceptions import (DictKeyAdded, DictKeyMissed, 9 | FieldAddedError, FieldMissedError, 10 | FieldValueError, HeaderError, 11 | StatusCodeError) 12 | from drf_api_checker.fs import clean_url, get_filename 13 | from drf_api_checker.utils import _write, load_response, serialize_response 14 | 15 | HEADERS_TO_CHECK = ["Content-Type", "Content-Length", "Allow"] 16 | 17 | 18 | def get_base_dir(): 19 | import django 20 | 21 | if hasattr(settings, "BY_DJANGO_VERSION") and settings.BY_DJANGO_VERSION: 22 | v = ".".join(map(str, django.VERSION[0:2])) 23 | return f"_api_checker_{v}" 24 | else: 25 | return "_api_checker" 26 | 27 | 28 | BASE_DATADIR = get_base_dir() 29 | STATUS_CODE = 1 30 | FIELDS = 2 31 | HEADERS = 3 32 | DEFAULT_CHECKS = [STATUS_CODE, FIELDS, HEADERS] 33 | 34 | 35 | class Recorder: 36 | expect_errors = False 37 | allow_empty = False 38 | headers = HEADERS_TO_CHECK 39 | checks = list(DEFAULT_CHECKS) 40 | 41 | def __init__( 42 | self, 43 | data_dir, 44 | owner=None, 45 | headers_to_check=None, 46 | fixture_file=None, 47 | as_user=None, 48 | ) -> None: 49 | self.data_dir = data_dir 50 | self.fixture_file = fixture_file or self.data_dir 51 | self.owner = owner 52 | self.user = as_user 53 | self.headers_to_check = headers_to_check or self.headers 54 | self.check_map = { 55 | FIELDS: self._assert_fields, 56 | STATUS_CODE: self._assert_status, 57 | HEADERS: self._assert_headers, 58 | } 59 | if hasattr(self, "check_headers"): 60 | raise DeprecationWarning( 61 | "'check_headers' has been deprecated. Use 'checks' instead." 62 | ) 63 | if hasattr(self, "check_status"): 64 | raise DeprecationWarning( 65 | "'check_status' has been deprecated. Use 'checks' instead." 66 | ) 67 | 68 | @property 69 | def client(self): 70 | if self.owner: 71 | client = self.owner.client 72 | else: 73 | from rest_framework.test import APIClient 74 | 75 | client = APIClient() 76 | 77 | if self.user: 78 | client.force_authenticate(self.user) 79 | return client 80 | 81 | def get_response_filename(self, method, url, data): 82 | return get_filename( 83 | self.data_dir, clean_url(method, url, data) + ".response.json" 84 | ) 85 | 86 | def _get_custom_asserter(self, path, field_name): 87 | for attr in [f"assert_{path}_{field_name}", f"assert_{field_name}"]: 88 | for target in [self, self.owner]: 89 | if hasattr(target, attr): 90 | return getattr(target, attr) 91 | return None 92 | 93 | def _compare_dict( 94 | self, response, stored, path=None, view="unknown", filename="unknown" 95 | ): 96 | try: 97 | self.check_dict_keys(response, stored) 98 | except DictKeyMissed as e: 99 | raise FieldMissedError(view, e.keys) 100 | except DictKeyAdded as e: 101 | raise FieldAddedError(view, e.keys, filename) 102 | path = path or [] 103 | 104 | for field_name, field_value in response.items(): 105 | if isinstance(field_value, (dict, OrderedDict)): 106 | path.append(field_name) 107 | self._compare_dict( 108 | field_value, stored[field_name], path, view=view, filename=filename 109 | ) 110 | else: 111 | asserter = self._get_custom_asserter(path, field_name) 112 | if asserter: 113 | asserter(response, stored, path) 114 | else: 115 | if isinstance(field_value, (set, list, tuple)): 116 | safe_field_value = list(field_value) 117 | stored_field_value = stored[field_name] 118 | if len(safe_field_value) != len(stored_field_value): 119 | raise FieldValueError( 120 | view=view, 121 | message="Field len `{0.field_name}` does not match.", 122 | expected=stored_field_value, 123 | received=safe_field_value, 124 | field_name=field_name, 125 | filename=self.fixture_file, 126 | ) 127 | 128 | for i, entry in enumerate(safe_field_value): 129 | if isinstance(entry, (dict, OrderedDict)): 130 | entry = dict(entry) 131 | path.append("%s[%s]" % (field_name, i)) 132 | self._compare_dict( 133 | entry, 134 | stored_field_value[i], 135 | path, 136 | view=view, 137 | filename=self.fixture_file, 138 | ) 139 | 140 | # if entry != stored_field_value[i]: 141 | # raise FieldValueError(view=view, 142 | # expected=stored_field_value[i], 143 | # received=entry, 144 | # field_name='%s[%s]' % (field_name, i), 145 | # filename=self.data_dir) 146 | 147 | elif field_name in stored and field_value != stored[field_name]: 148 | path.append(field_name) 149 | full_path_to_field = ".".join(path) 150 | raise FieldValueError( 151 | view=view, 152 | expected=stored[field_name], 153 | received=response[field_name], 154 | field_name=full_path_to_field, 155 | filename=self.fixture_file, 156 | ) 157 | 158 | def get_single_record(self, response, expected): 159 | if isinstance(response, (list, tuple)): 160 | response = response[0] 161 | expected = expected[0] 162 | return response, expected 163 | 164 | def check_dict_keys(self, response, expected): 165 | _recv = set(response.keys()) 166 | _expct = set(expected.keys()) 167 | added = _recv.difference(_expct) 168 | missed = _expct.difference(_recv) 169 | 170 | if missed: 171 | raise DictKeyMissed(", ".join(missed)) 172 | if added: 173 | raise DictKeyAdded(", ".join(added)) 174 | 175 | def compare( 176 | self, response, expected, filename="unknown", ignore_fields=None, view="unknown" 177 | ): 178 | if response: 179 | if isinstance(response, (list, tuple)): 180 | a = response[0] 181 | b = expected[0] 182 | else: 183 | a = response 184 | b = expected 185 | try: 186 | self.check_dict_keys(a, b) 187 | except DictKeyMissed as e: 188 | raise FieldMissedError(view, e.keys) 189 | except DictKeyAdded as e: 190 | raise FieldAddedError(view, e.keys, filename) 191 | 192 | response, expected = self.get_single_record(response, expected) 193 | self._compare_dict(response, expected, view=view, filename=filename) 194 | else: 195 | assert response == expected 196 | return True 197 | 198 | def assertGET( 199 | self, 200 | url, 201 | *, 202 | allow_empty=None, 203 | checks=None, 204 | expect_errors=None, 205 | name=None, 206 | data=None, 207 | **kwargs, 208 | ): 209 | if "check_headers" in kwargs: 210 | raise DeprecationWarning( 211 | "'check_headers' has been deprecated. Use 'checks' instead." 212 | ) 213 | if "check_status" in kwargs: 214 | raise DeprecationWarning( 215 | "'check_status' has been deprecated. Use 'checks' instead." 216 | ) 217 | if kwargs: 218 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 219 | return self.assertCALL( 220 | url, 221 | allow_empty=allow_empty, 222 | checks=checks, 223 | expect_errors=expect_errors, 224 | name=name, 225 | data=data, 226 | ) 227 | 228 | def assertPUT( 229 | self, 230 | url, 231 | data, 232 | *, 233 | allow_empty=None, 234 | expect_errors=None, 235 | name=None, 236 | checks=None, 237 | **kwargs, 238 | ): 239 | if "check_headers" in kwargs: 240 | raise DeprecationWarning( 241 | "'check_headers' has been deprecated. Use 'checks' instead." 242 | ) 243 | if "check_status" in kwargs: 244 | raise DeprecationWarning( 245 | "'check_status' has been deprecated. Use 'checks' instead." 246 | ) 247 | if kwargs: 248 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 249 | return self.assertCALL( 250 | url, 251 | data=data, 252 | method="put", 253 | allow_empty=allow_empty, 254 | checks=checks, 255 | expect_errors=expect_errors, 256 | name=name, 257 | ) 258 | 259 | def assertPOST( 260 | self, 261 | url, 262 | data, 263 | *, 264 | allow_empty=None, 265 | check_headers=None, 266 | check_status=None, 267 | expect_errors=None, 268 | name=None, 269 | checks=None, 270 | **kwargs, 271 | ): 272 | if "check_headers" in kwargs: 273 | raise DeprecationWarning( 274 | "'check_headers' has been deprecated. Use 'checks' instead." 275 | ) 276 | if "check_status" in kwargs: 277 | raise DeprecationWarning( 278 | "'check_status' has been deprecated. Use 'checks' instead." 279 | ) 280 | if kwargs: 281 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 282 | return self.assertCALL( 283 | url, 284 | data=data, 285 | method="post", 286 | allow_empty=allow_empty, 287 | checks=checks, 288 | expect_errors=expect_errors, 289 | name=name, 290 | ) 291 | 292 | def assertDELETE( 293 | self, 294 | url, 295 | *, 296 | allow_empty=None, 297 | checks=None, 298 | expect_errors=None, 299 | name=None, 300 | data=None, 301 | **kwargs, 302 | ): 303 | if "check_headers" in kwargs: 304 | raise DeprecationWarning( 305 | "'check_headers' has been deprecated. Use 'checks' instead." 306 | ) 307 | if "check_status" in kwargs: 308 | raise DeprecationWarning( 309 | "'check_status' has been deprecated. Use 'checks' instead." 310 | ) 311 | if kwargs: 312 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 313 | return self.assertCALL( 314 | url, 315 | method="delete", 316 | allow_empty=allow_empty, 317 | checks=checks, 318 | expect_errors=expect_errors, 319 | name=name, 320 | data=data, 321 | ) 322 | 323 | def assertCALL( 324 | self, 325 | url, 326 | *, 327 | allow_empty=None, 328 | expect_errors=None, 329 | name=None, 330 | method="get", 331 | data=None, 332 | checks=None, 333 | **kwargs, 334 | ): 335 | """ 336 | check url for response changes 337 | 338 | :param url: url to check 339 | :param allow_empty: if True ignore empty response and 404 errors 340 | :param checks: list and order checks. ie. `checks=[STATUS_CODE, FIELDS, HEADERS]` 341 | :param check_status: check response status code 342 | :raises: ValueError 343 | :raises: AssertionError 344 | """ 345 | if "check_headers" in kwargs: 346 | raise DeprecationWarning( 347 | "'check_headers' has been deprecated. Use 'checks' instead." 348 | ) 349 | if "check_status" in kwargs: 350 | raise DeprecationWarning( 351 | "'check_status' has been deprecated. Use 'checks' instead." 352 | ) 353 | if kwargs: 354 | raise AttributeError("Unknown arguments %s" % kwargs.keys()) 355 | 356 | expect_errors = self.expect_errors if expect_errors is None else expect_errors 357 | allow_empty = self.allow_empty if allow_empty is None else allow_empty 358 | 359 | self.view = resolve(url).func.cls 360 | m = getattr(self.client, method.lower()) 361 | self.filename = self.get_response_filename(method, name or url, data) 362 | response = m(url, data=data) 363 | assert response.accepted_renderer 364 | payload = response.data 365 | if not allow_empty and not payload: 366 | raise ValueError( 367 | f"View {self.view} returned and empty json. Check your test" 368 | ) 369 | 370 | if response.status_code > 299 and not expect_errors: 371 | raise ValueError( 372 | f"View {self.view} unexpected response. {response.status_code} - {response.content}" 373 | ) 374 | 375 | if not allow_empty and response.status_code == 404: 376 | raise ValueError( 377 | f"View {self.view} returned 404 status code. Check your test" 378 | ) 379 | 380 | if not os.path.exists(self.filename) or os.environ.get( 381 | "API_CHECKER_RESET", False 382 | ): 383 | _write(self.filename, serialize_response(response)) 384 | 385 | stored = load_response(self.filename) 386 | if checks is None: 387 | checks = self.checks 388 | for check_id in checks: 389 | check = self.check_map[check_id] 390 | check(response, stored) 391 | return response, stored 392 | 393 | def _assert_fields(self, response, stored): 394 | self.compare(response.data, stored.data, self.filename, view=self.view) 395 | 396 | def _assert_status(self, response, stored): 397 | if response.status_code != stored.status_code: 398 | raise StatusCodeError(self.view, response.status_code, stored.status_code) 399 | 400 | def _assert_headers(self, response, stored): 401 | for header in self.headers_to_check: 402 | stored_headers = stored if dj_version < (3, 2) else stored.headers 403 | _expected = stored_headers.get(header) 404 | _recv = response.get(header) 405 | if _expected != _recv: 406 | raise HeaderError( 407 | self.view, 408 | header, 409 | _expected, 410 | _recv, 411 | self.filename, 412 | f"{stored.content}/{response.content}", 413 | ) 414 | 415 | # if sorted(response.get('Allow')) != sorted(stored.get('Allow')): 416 | # raise HeaderError(self.view, h, stored.get(h), 417 | # response.get(h), 418 | # self.filename) 419 | # 420 | # assert response.get('Content-Type') == stored.get('Content-Type') 421 | # assert response.get('Content-Length') == stored.get('Content-Length'), response.content 422 | # assert sorted(response.get('Allow')) == sorted(stored.get('Allow')) 423 | --------------------------------------------------------------------------------