├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── docs ├── .gitignore ├── Makefile ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── example ├── README.md ├── companies │ ├── __init__.py │ ├── api_docs.py │ ├── factories.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── create_data.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20180314_1355.py │ │ └── __init__.py │ ├── models.py │ ├── serializers.py │ └── views.py ├── example │ ├── __init__.py │ ├── api_docs.py │ ├── settings.py │ ├── urls.py │ ├── urls_api.py │ ├── views.py │ └── wsgi.py ├── manage.py ├── pytest.ini ├── requirements.txt ├── templates │ ├── base.html │ └── home.html └── tests │ ├── __init__.py │ ├── conftest.py │ ├── test_company_views.py │ └── test_employment_views.py ├── pytest.ini ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── test_settings.py ├── tests └── test_importing.py ├── tg_apicore ├── __init__.py ├── apps.py ├── docs.py ├── pagination.py ├── parsers.py ├── renderers.py ├── routers.py ├── schemas.py ├── serializers.py ├── settings.py ├── static │ ├── css │ │ └── tg_apicore.css │ └── sass │ │ └── _tg_apicore.scss ├── templates │ └── tg_apicore │ │ ├── docs │ │ ├── base.html │ │ ├── index.html │ │ ├── method-python.html │ │ ├── method.html │ │ ├── section.html │ │ └── sidebar.html │ │ └── scopes.html ├── templatetags │ ├── __init__.py │ └── tg_apicore.py ├── test.py ├── transformers.py ├── views.py └── viewsets.py └── tox.ini /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Thorgate API Core version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | db.sqlite3 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # PyCharm 99 | .idea/ 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | python: 5 | - 3.6 6 | - 3.5 7 | 8 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 9 | install: pip install -U tox-travis 10 | 11 | # Command to run tests, e.g. python setup.py test 12 | script: tox 13 | 14 | # Assuming you have installed the travis-ci CLI tool, after you 15 | # create the Github repo and add it to Travis, run the 16 | # following command to finish PyPI deployment setup: 17 | # $ travis encrypt --add deploy.password 18 | deploy: 19 | provider: pypi 20 | distributions: sdist bdist_wheel 21 | user: thorgate 22 | password: 23 | secure: > 24 | cPHAt/keGNiwKieHN2EGWaXdiHdQqBjoC6YIOOArBd4AuvrJZN50wCHloyylt3tMDi0VQ8uUHyWda632+3Q7+bghYhyIX7ml7hhRzHMZzjNek 25 | 7Z+wZetjLUFFF+A2dq1kw6TKSlcErcdBRsiDsAAvTotnA+FmRIg/oqG0fJGk7kQof+8qYrx57Kj55GyD4xL6z4BTQ5WLYYojD46Zo7tD1fStF 26 | C5aG61apaairdzqdo6bw43vit7jyyIHwCsmCNADRS7sSb4mXn+HsjjlkoW/qVu1Z5njPvaRuMkrewMT3wBy8M7Yb2s8LY97p4trI/SzWBxAOI 27 | qnoJPd8S7r4i2mI4Eb05+3mjxm/wfMJDEQC/8Bo5+aZhl58dTZagUl1Ne/8JJIiE72408cZcu8O21LpnF5TL5MT+IqLIz3O90gRF65JAdDBB9 28 | ogVSIgjlOm6c3peaYi6UtmiWSzZQ1aPUqlGnkn3YGkQ3oQuZFiiRZfUXBNTI40d9lEK6vISmESUqq8gsJl6ZugC9indOTDDGgUPppDrqUnCDO 29 | KOr5AmwHmVKg2a9dDuf4aBG/5+GNpzdrULhN5S3TRvXvh465TJWApDi2AGi9oaaUEsaS5NvWqNVI+UCOVjVZDmfGTXWzqGshjk9i2eV48HLKH 30 | Y2Y1mbeyr3PWcbYaM3OXcy1xPMbsQ= 31 | on: 32 | tags: true 33 | repo: thorgate/tg-apicore 34 | python: 3.6 35 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Rivo Laks 9 | 10 | Contributors 11 | ------------ 12 | 13 | * Jürno Ader 14 | * Simon Schmidt 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/thorgate/tg-apicore/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Thorgate API Core could always use more documentation, whether as part of the 42 | official Thorgate API Core docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/thorgate/tg-apicore/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `tg-apicore` for local development. 61 | 62 | 1. Fork the `tg-apicore` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/tg-apicore.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv tg-apicore 70 | $ cd tg-apicore/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ make test 83 | $ make lint 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 3.5 and 3.6. Check 106 | https://travis-ci.org/thorgate/tg-apicore/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | $ py.test tests.test_importing 115 | 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.3.0 (2018-05-23) 6 | ------------------ 7 | 8 | * Add Usage section to README (to make starting up easier) 9 | * Most ``REST_FRAMEWORK`` settings are now automatically configured by Thorgate API Core. 10 | Users only need to specify ``ALLOWED_VERSIONS``, the rest is optional. 11 | 12 | 13 | 0.2.1 (2018-04-14) 14 | ------------------ 15 | 16 | * Fix packaging (tg_apicore subdirs weren't included) 17 | 18 | 19 | 0.2.0 (2018-04-14) 20 | ------------------ 21 | 22 | * Added PageNotFoundView (JSON-based 404 views) 23 | * Added DetailSerializerViewSet (different serializers and queryset for list/detail/edit views) 24 | * Added CreateOnlyFieldsSerializerMixin, ModelValidationSerializerMixin and BaseModelSerializer 25 | * Renamed APIDocumentationView.get_patterns() to .urlpatterns() 26 | * Improved example app a lot. It now also includes tests that partially test tg-apicore itself 27 | 28 | 29 | 0.1.0 (2018-03-08) 30 | ------------------ 31 | 32 | * First release on PyPI. 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2018, Thorgate 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-include tg_apicore *.py *.html *.css *.scss 9 | recursive-exclude * __pycache__ 10 | recursive-exclude * *.py[co] 11 | 12 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | 52 | lint: ## check style with flake8 53 | flake8 tg_apicore tests 54 | 55 | test: ## run tests quickly with the default Python 56 | pip install -r requirements_dev.txt 57 | pytest 58 | pytest example/ 59 | 60 | test-all: ## run tests on every Python version with tox 61 | tox 62 | 63 | coverage: ## check code coverage quickly with the default Python 64 | coverage run --source tg_apicore -m pytest 65 | coverage report -m 66 | coverage html 67 | $(BROWSER) htmlcov/index.html 68 | 69 | docs: ## generate Sphinx HTML documentation, including API docs 70 | rm -f docs/tg_apicore.rst 71 | rm -f docs/modules.rst 72 | sphinx-apidoc -o docs/ tg_apicore 73 | $(MAKE) -C docs clean 74 | $(MAKE) -C docs html 75 | $(BROWSER) docs/_build/html/index.html 76 | 77 | servedocs: docs ## compile the docs watching for changes 78 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 79 | 80 | release: clean ## package and upload a release 81 | python setup.py sdist upload 82 | python setup.py bdist_wheel upload 83 | 84 | dist: clean ## builds source and wheel package 85 | python setup.py sdist 86 | python setup.py bdist_wheel 87 | ls -l dist 88 | 89 | install: clean ## install the package to the active Python's site-packages 90 | python setup.py install 91 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Thorgate API Core 3 | ================= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/tg-apicore.svg 7 | :target: https://pypi.python.org/pypi/tg-apicore 8 | 9 | .. image:: https://img.shields.io/travis/thorgate/tg-apicore.svg 10 | :target: https://travis-ci.org/thorgate/tg-apicore 11 | 12 | .. image:: https://readthedocs.org/projects/tg-apicore/badge/?version=latest 13 | :target: https://tg-apicore.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | 17 | Opinionated API framework on top of Django REST framework 18 | 19 | 20 | * Free software: ISC license 21 | 22 | Supports Python 3.5+, Django 1.11+, Django REST framework 3.6+ 23 | 24 | 25 | Features 26 | -------- 27 | 28 | * API documentation automatically generated from your views 29 | * General intro can be added 30 | * You can add example request/response data 31 | * Autogenerated Python `requests`-based examples 32 | * Not interactive yet 33 | * Integrates `JSON API `_ 34 | * Cursor pagination with configurable page size 35 | * Viewset classes for using different serializers and querysets for list/detail/edit endpoints 36 | * API-specific 404 view 37 | * Test utilities, e.g. for response validation 38 | * Versioning (WIP) 39 | * Transformer-based approach, inspired by 40 | `djangorestframework-version-transforms `_ 41 | and `Stripe `_ 42 | 43 | 44 | Usage 45 | ----- 46 | 47 | - ``pip install tg-apicore`` 48 | - Add ``tg_apicore`` to ``INSTALLED_APPS`` 49 | - Ensure your ``REST_FRAMEWORK`` setting contains ``ALLOWED_VERSIONS``, e.g: 50 | 51 | .. code:: python 52 | 53 | # In your Django project settings: 54 | REST_FRAMEWORK = { 55 | 'ALLOWED_VERSIONS': ('2018-01-01',), 56 | } 57 | 58 | - Note that the default paginator requires that your models have ``created`` field 59 | 60 | - Create API documentation view by subclassing ``APIDocumentationView`` and making necessary modifications. 61 | See ``example/example/views.py`` for example. 62 | - Add main API urls plus 404 view (as fallback). 63 | 64 | 65 | Here's an example ``urls.py``: 66 | 67 | .. code:: python 68 | 69 | from tg_apicore.views import PageNotFoundView 70 | 71 | from myproject.views import MyProjectAPIDocumentationView 72 | 73 | urlpatterns = [ 74 | # The documentation view 75 | url(r'^api-docs/', MyProjectAPIDocumentationView.as_view(), name='api-docs'), 76 | 77 | # myproject.urls_api should contain your API urls patterns 78 | url(r'^api/(?P(\d{4}-\d{2}-\d{2}))/', include('myproject.urls_api')), 79 | 80 | # API-specific 404 for everything under api/ prefix 81 | url(r'^api/', include(PageNotFoundView.urlpatterns())), 82 | ] 83 | 84 | See ``example`` directory for a more in-depth demo. 85 | 86 | 87 | Credits 88 | ------- 89 | 90 | This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. 91 | 92 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 93 | .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage 94 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | /tg_apicore.rst 2 | /tg_apicore.*.rst 3 | /modules.rst 4 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = tg_apicore 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # tg_apicore documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | 24 | from django.conf import settings 25 | 26 | sys.path.insert(0, os.path.abspath('..')) 27 | 28 | # Make sure django is configured to avoid issues with importing settings 29 | # in code while generating the documentation. 30 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_settings') 31 | settings.configure() 32 | 33 | import tg_apicore 34 | 35 | # -- General configuration --------------------------------------------- 36 | 37 | # If your documentation needs a minimal Sphinx version, state it here. 38 | # 39 | # needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be 42 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 43 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix(es) of source filenames. 49 | # You can specify multiple suffix as a list of string: 50 | # 51 | # source_suffix = ['.rst', '.md'] 52 | source_suffix = '.rst' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = u'Thorgate API Core' 59 | copyright = u"2018, Thorgate" 60 | author = u"Thorgate" 61 | 62 | # The version info for the project you're documenting, acts as replacement 63 | # for |version| and |release|, also used in various other places throughout 64 | # the built documents. 65 | # 66 | # The short X.Y version. 67 | version = tg_apicore.__version__ 68 | # The full version, including alpha/beta/rc tags. 69 | release = tg_apicore.__version__ 70 | 71 | # The language for content autogenerated by Sphinx. Refer to documentation 72 | # for a list of supported languages. 73 | # 74 | # This is also used if you do content translation via gettext catalogs. 75 | # Usually you set "language" from the command line for these cases. 76 | language = None 77 | 78 | # List of patterns, relative to source directory, that match files and 79 | # directories to ignore when looking for source files. 80 | # This patterns also effect to html_static_path and html_extra_path 81 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # If true, `todo` and `todoList` produce output, else they produce nothing. 87 | todo_include_todos = False 88 | 89 | 90 | # -- Options for HTML output ------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | # 95 | html_theme = 'alabaster' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a 98 | # theme further. For a list of options available for each theme, see the 99 | # documentation. 100 | # 101 | # html_theme_options = {} 102 | 103 | # Add any paths that contain custom static files (such as style sheets) here, 104 | # relative to this directory. They are copied after the builtin static files, 105 | # so a file named "default.css" will overwrite the builtin "default.css". 106 | html_static_path = ['_static'] 107 | 108 | 109 | # -- Options for HTMLHelp output --------------------------------------- 110 | 111 | # Output file base name for HTML help builder. 112 | htmlhelp_basename = 'tg_apicoredoc' 113 | 114 | 115 | # -- Options for LaTeX output ------------------------------------------ 116 | 117 | latex_elements = { 118 | # The paper size ('letterpaper' or 'a4paper'). 119 | # 120 | # 'papersize': 'letterpaper', 121 | 122 | # The font size ('10pt', '11pt' or '12pt'). 123 | # 124 | # 'pointsize': '10pt', 125 | 126 | # Additional stuff for the LaTeX preamble. 127 | # 128 | # 'preamble': '', 129 | 130 | # Latex figure (float) alignment 131 | # 132 | # 'figure_align': 'htbp', 133 | } 134 | 135 | # Grouping the document tree into LaTeX files. List of tuples 136 | # (source start file, target name, title, author, documentclass 137 | # [howto, manual, or own class]). 138 | latex_documents = [ 139 | (master_doc, 'tg_apicore.tex', 140 | u'Thorgate API Core Documentation', 141 | u'Thorgate', 'manual'), 142 | ] 143 | 144 | 145 | # -- Options for manual page output ------------------------------------ 146 | 147 | # One entry per manual page. List of tuples 148 | # (source start file, name, description, authors, manual section). 149 | man_pages = [ 150 | (master_doc, 'tg_apicore', 151 | u'Thorgate API Core Documentation', 152 | [author], 1) 153 | ] 154 | 155 | 156 | # -- Options for Texinfo output ---------------------------------------- 157 | 158 | # Grouping the document tree into Texinfo files. List of tuples 159 | # (source start file, target name, title, author, 160 | # dir menu entry, description, category) 161 | texinfo_documents = [ 162 | (master_doc, 'tg_apicore', 163 | u'Thorgate API Core Documentation', 164 | author, 165 | 'tg_apicore', 166 | 'One line description of project.', 167 | 'Miscellaneous'), 168 | ] 169 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to Thorgate API Core's documentation! 2 | ============================================= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | :caption: Contents: 7 | 8 | readme 9 | installation 10 | usage 11 | modules 12 | contributing 13 | authors 14 | history 15 | 16 | Indices and tables 17 | ================== 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install Thorgate API Core, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install tg-apicore 16 | 17 | This is the preferred method to install Thorgate API Core, as it will always install the most recent stable release. 18 | 19 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 20 | you through the process. 21 | 22 | .. _pip: https://pip.pypa.io 23 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 24 | 25 | 26 | From sources 27 | ------------ 28 | 29 | The sources for Thorgate API Core can be downloaded from the `Github repo`_. 30 | 31 | You can either clone the public repository: 32 | 33 | .. code-block:: console 34 | 35 | $ git clone git://github.com/thorgate/tg-apicore 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/thorgate/tg-apicore/tarball/master 42 | 43 | Once you have a copy of the source, you can install it with: 44 | 45 | .. code-block:: console 46 | 47 | $ python setup.py install 48 | 49 | 50 | .. _Github repo: https://github.com/thorgate/tg-apicore 51 | .. _tarball: https://github.com/thorgate/tg-apicore/tarball/master 52 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=tg_apicore 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | * For the quickstart, see the Usage section of the `introduction`_ page. 6 | * For a real-world example, see the ``example/`` directory in the `Github repo`_. 7 | 8 | .. _introduction: readme.html#usage 9 | .. _Github repo: https://github.com/thorgate/tg-apicore 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Example Project for tg-apicore 2 | 3 | This example is provided as a convenience feature to allow potential users to try the app straight from the app repo without having to create a django project. 4 | 5 | It can also be used to develop the app in place. 6 | 7 | To run this example, follow these instructions: 8 | 9 | 1. Navigate to the `example` directory 10 | 2. Install the requirements for the package: 11 | 12 | pip install -r requirements.txt 13 | 14 | 3. Make and apply migrations 15 | 16 | python manage.py makemigrations 17 | 18 | python manage.py migrate 19 | 20 | 4. Generate some test data 21 | 22 | python manage.py create_data 23 | 24 | 5. Run the server 25 | 26 | python manage.py runserver 27 | 28 | 6. Access from the browser at `http://127.0.0.1:8000` 29 | -------------------------------------------------------------------------------- /example/companies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/companies/__init__.py -------------------------------------------------------------------------------- /example/companies/api_docs.py: -------------------------------------------------------------------------------- 1 | COMPANIES_DATA = { 2 | "type": "company", 3 | "id": "12", 4 | "attributes": { 5 | "created": "2018-03-16T09:38:01.531816Z", 6 | "updated": "2018-03-16T09:38:01.531879Z", 7 | "reg_code": "287-0513", 8 | "name": "Turner and Sons", 9 | "email": "turner@sons.com" 10 | }, 11 | "relationships": { 12 | "employees": { 13 | "meta": { 14 | "count": 2 15 | }, 16 | "data": [ 17 | { 18 | "type": "employment", 19 | "id": "91" 20 | }, 21 | { 22 | "type": "employment", 23 | "id": "162" 24 | } 25 | ] 26 | } 27 | }, 28 | "links": { 29 | "self": "%(API_ROOT)s/companies/12/" 30 | } 31 | } 32 | 33 | COMPANIES_LIST_RESPONSE = { 34 | "links": { 35 | "next": "%(API_ROOT)s/companies/?cursor=cD0yMDE3LTA5LTIyKzA4JTNBMzklM0EyNS4wMDIxNTIlMkIwMCUzQTAw", 36 | "prev": None 37 | }, 38 | "data": [ 39 | { 40 | "type": "company", 41 | "id": "12", 42 | "attributes": { 43 | "created": "2018-03-16T09:38:01.531816Z", 44 | "updated": "2018-03-16T09:38:01.531879Z", 45 | "reg_code": "287-0513", 46 | "name": "Turner and Sons", 47 | "email": "turner@sons.com" 48 | }, 49 | "relationships": { 50 | "employees": { 51 | "meta": { 52 | "count": 2 53 | }, 54 | "data": [ 55 | { 56 | "type": "employment", 57 | "id": "91" 58 | }, 59 | { 60 | "type": "employment", 61 | "id": "162" 62 | } 63 | ] 64 | } 65 | }, 66 | "links": { 67 | "self": "%(API_ROOT)s/companies/12/" 68 | } 69 | } 70 | ], 71 | "included": [ 72 | { 73 | "type": "employment", 74 | "id": "162", 75 | "attributes": { 76 | "created": "2018-03-16T09:48:11.528352Z", 77 | "updated": "2018-03-16T09:48:11.528494Z", 78 | "name": "Linda Burgess", 79 | "email": "carloswoods@griffin.com", 80 | "role": 1 81 | }, 82 | "links": { 83 | "self": "%(API_ROOT)s/employments/162/" 84 | } 85 | }, 86 | { 87 | "type": "employment", 88 | "id": "91", 89 | "attributes": { 90 | "created": "2018-03-16T09:48:11.528352Z", 91 | "updated": "2018-03-16T09:48:11.528494Z", 92 | "name": "Crystal Turner", 93 | "email": "collinsheather@mendoza.biz", 94 | "role": 1 95 | }, 96 | "links": { 97 | "self": "%(API_ROOT)s/employments/91/" 98 | } 99 | } 100 | ] 101 | } 102 | 103 | COMPANIES_CREATE_REQUEST = { 104 | "data": { 105 | "type": "company", 106 | "attributes": { 107 | "reg_code": "123-4567", 108 | "name": "Turner and Sons", 109 | "email": "turner@sons.com" 110 | } 111 | } 112 | } 113 | 114 | COMPANIES_CREATE_RESPONSE = { 115 | "data": { 116 | "type": "company", 117 | "id": "12", 118 | "attributes": { 119 | "created": "2018-03-16T09:38:01.531816Z", 120 | "updated": "2018-03-16T09:38:01.531879Z", 121 | "name": "Turner and Sons", 122 | "email": "turner@sons.com" 123 | }, 124 | "links": { 125 | "self": "%(API_ROOT)s/companies/12/" 126 | } 127 | }, 128 | } 129 | 130 | COMPANIES_CREATE_RESPONSES = [ 131 | (201, COMPANIES_CREATE_RESPONSE), 132 | (400, { 133 | "errors": [ 134 | { 135 | "detail": "Company with this reg_code already exists.", 136 | "source": { 137 | "pointer": "/data/attributes/reg_code" 138 | }, 139 | "status": "400" 140 | } 141 | ] 142 | }), 143 | ] 144 | 145 | COMPANIES_READ_RESPONSE = { 146 | "data": { 147 | "type": "company", 148 | "id": "12", 149 | "attributes": { 150 | "created": "2018-03-16T09:38:01.531816Z", 151 | "updated": "2018-03-16T09:38:01.531879Z", 152 | "reg_code": "287-0513", 153 | "name": "Turner and Sons", 154 | "email": "turner@sons.com" 155 | }, 156 | "relationships": { 157 | "employees": { 158 | "meta": { 159 | "count": 2 160 | }, 161 | "data": [ 162 | { 163 | "type": "employment", 164 | "id": "91" 165 | }, 166 | { 167 | "type": "employment", 168 | "id": "162" 169 | } 170 | ] 171 | } 172 | }, 173 | "links": { 174 | "self": "%(API_ROOT)s/companies/12/" 175 | } 176 | }, 177 | "included": [ 178 | { 179 | "type": "employment", 180 | "id": "162", 181 | "attributes": { 182 | "created": "2018-03-16T09:48:11.528352Z", 183 | "updated": "2018-03-16T09:48:11.528494Z", 184 | "name": "Linda Burgess", 185 | "email": "carloswoods@griffin.com", 186 | "role": 1 187 | }, 188 | "links": { 189 | "self": "%(API_ROOT)s/employments/162/" 190 | } 191 | }, 192 | { 193 | "type": "employment", 194 | "id": "91", 195 | "attributes": { 196 | "created": "2018-03-16T09:48:11.528352Z", 197 | "updated": "2018-03-16T09:48:11.528494Z", 198 | "name": "Crystal Turner", 199 | "email": "collinsheather@mendoza.biz", 200 | "role": 1 201 | }, 202 | "links": { 203 | "self": "%(API_ROOT)s/employments/91/" 204 | } 205 | } 206 | ] 207 | } 208 | 209 | COMPANIES_UPDATE_REQUEST = { 210 | "data": { 211 | "type": "company", 212 | "id": "12", 213 | "attributes": { 214 | "name": "Turner and Sons" 215 | } 216 | } 217 | } 218 | 219 | COMPANIES_DELETE_RESPONSES = [ 220 | (204, None), 221 | (400, { 222 | "errors": [ 223 | { 224 | "detail": "This company cannot be deleted", 225 | "source": { 226 | "pointer": "/data" 227 | }, 228 | "status": "400" 229 | } 230 | ] 231 | }), 232 | ] 233 | 234 | 235 | EMPLOYMENTS_DATA = { 236 | "type": "employment", 237 | "id": "162", 238 | "attributes": { 239 | "created": "2018-03-16T09:48:11.528352Z", 240 | "updated": "2018-03-16T09:48:11.528494Z", 241 | "name": "Linda Burgess", 242 | "email": "carloswoods@griffin.com", 243 | "role": 1 244 | }, 245 | "relationships": { 246 | "company": { 247 | "data": { 248 | "type": "company", 249 | "id": "12" 250 | } 251 | } 252 | }, 253 | "links": { 254 | "self": "%(API_ROOT)s/employments/162/" 255 | } 256 | } 257 | 258 | EMPLOYMENTS_CREATE_REQUEST = { 259 | "data": { 260 | "type": "employment", 261 | "attributes": { 262 | "email": "carloswoods@griffin.com", 263 | }, 264 | "relationships": { 265 | "company": { 266 | "data": {"type": "company", "id": "12"} 267 | } 268 | } 269 | } 270 | } 271 | 272 | EMPLOYMENTS_CREATE_RESPONSE = { 273 | "data": { 274 | "type": "employment", 275 | "id": "162", 276 | "attributes": { 277 | "created": "2018-03-16T09:48:11.528352Z", 278 | "updated": "2018-03-16T09:48:11.528494Z", 279 | "name": "Linda Burgess", 280 | "email": "carloswoods@griffin.com", 281 | "role": 1 282 | }, 283 | "relationships": { 284 | "company": { 285 | "data": { 286 | "type": "company", 287 | "id": "12" 288 | } 289 | } 290 | }, 291 | "links": { 292 | "self": "%(API_ROOT)s/employments/162/" 293 | } 294 | }, 295 | "included": [ 296 | { 297 | "type": "company", 298 | "id": "12", 299 | "attributes": { 300 | "created": "2018-03-16T09:38:01.531816Z", 301 | "updated": "2018-03-16T09:38:01.531879Z", 302 | "reg_code": "287-0513", 303 | "name": "Turner and Sons", 304 | "email": "turner@sons.com" 305 | }, 306 | "links": { 307 | "self": "http://localhost:8330/api/2018-02-21/companies/12" 308 | } 309 | } 310 | ] 311 | } 312 | 313 | EMPLOYMENTS_CREATE_RESPONSES = [ 314 | (201, EMPLOYMENTS_CREATE_RESPONSE), 315 | (400, { 316 | "errors": [ 317 | { 318 | "detail": "You are not admin in the specified company", 319 | "source": { 320 | "pointer": "/data/attributes/company" 321 | }, 322 | "status": "400" 323 | } 324 | ] 325 | }), 326 | ] 327 | -------------------------------------------------------------------------------- /example/companies/factories.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import factory 4 | from factory.django import DjangoModelFactory 5 | 6 | from companies.models import User, Company, Employment 7 | 8 | 9 | class UserFactory(DjangoModelFactory): 10 | class Meta: 11 | model = User 12 | 13 | password = factory.PostGenerationMethodCall('set_password', 'test') 14 | username = factory.Faker('user_name') 15 | email = factory.Faker('email') 16 | first_name = factory.Faker('first_name') 17 | last_name = factory.Faker('last_name') 18 | 19 | 20 | class CompanyFactory(DjangoModelFactory): 21 | class Meta: 22 | model = Company 23 | 24 | name = factory.Faker('company') 25 | email = factory.Faker('email') 26 | reg_code = factory.Faker('numerify', text='%##-####') 27 | 28 | 29 | def create_full_example_data(): 30 | UserFactory.create_batch(100) 31 | CompanyFactory.create_batch(70) 32 | 33 | users = list(User.objects.all()) 34 | companies = list(Company.objects.all()) 35 | 36 | # Generate 300 unique user-company pairs 37 | user_company_pairs = set() 38 | while len(user_company_pairs): 39 | user_company_pairs.add((random.choice(users), random.choice(companies))) 40 | 41 | Employment.objects.bulk_create([ 42 | Employment(user=user, company=company, 43 | role=Employment.ROLE_ADMIN if random.random() < 0.25 else Employment.ROLE_NORMAL) 44 | for user, company in user_company_pairs 45 | ]) 46 | -------------------------------------------------------------------------------- /example/companies/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/companies/management/__init__.py -------------------------------------------------------------------------------- /example/companies/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/companies/management/commands/__init__.py -------------------------------------------------------------------------------- /example/companies/management/commands/create_data.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from companies.factories import create_full_example_data 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Create test data" 8 | 9 | def handle(self, *args, **options): 10 | create_full_example_data() 11 | -------------------------------------------------------------------------------- /example/companies/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-02-21 14:54 2 | 3 | from django.conf import settings 4 | import django.contrib.auth.models 5 | import django.contrib.auth.validators 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('auth', '0009_alter_user_last_name_max_length'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='User', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('password', models.CharField(max_length=128, verbose_name='password')), 25 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), 26 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), 27 | ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), 28 | ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), 29 | ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), 30 | ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), 31 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), 32 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), 33 | ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), 34 | ('created', models.DateTimeField(auto_now_add=True)), 35 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), 36 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), 37 | ], 38 | options={ 39 | 'verbose_name_plural': 'users', 40 | 'verbose_name': 'user', 41 | 'abstract': False, 42 | }, 43 | managers=[ 44 | ('objects', django.contrib.auth.models.UserManager()), 45 | ], 46 | ), 47 | migrations.CreateModel( 48 | name='Company', 49 | fields=[ 50 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 51 | ('name', models.CharField(max_length=64)), 52 | ('email', models.EmailField(blank=True, max_length=254)), 53 | ('created', models.DateTimeField(auto_now_add=True)), 54 | ], 55 | ), 56 | migrations.CreateModel( 57 | name='Employment', 58 | fields=[ 59 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 60 | ('role', models.PositiveSmallIntegerField(choices=[(1, 'normal'), (2, 'manager')], default=1)), 61 | ('created', models.DateTimeField(auto_now_add=True)), 62 | ('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employees', to='companies.Company')), 63 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='employments', to=settings.AUTH_USER_MODEL)), 64 | ], 65 | ), 66 | ] 67 | -------------------------------------------------------------------------------- /example/companies/migrations/0002_auto_20180314_1355.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-03-14 13:55 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('companies', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='company', 15 | name='reg_code', 16 | field=models.CharField(default=1234, max_length=20, unique=True), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name='company', 21 | name='updated', 22 | field=models.DateTimeField(auto_now=True), 23 | ), 24 | migrations.AddField( 25 | model_name='employment', 26 | name='updated', 27 | field=models.DateTimeField(auto_now=True), 28 | ), 29 | migrations.AddField( 30 | model_name='user', 31 | name='updated', 32 | field=models.DateTimeField(auto_now=True), 33 | ), 34 | migrations.AlterField( 35 | model_name='employment', 36 | name='role', 37 | field=models.PositiveSmallIntegerField(choices=[(1, 'normal'), (2, 'admin')], default=1), 38 | ), 39 | migrations.AlterUniqueTogether( 40 | name='employment', 41 | unique_together={('user', 'company')}, 42 | ), 43 | ] 44 | -------------------------------------------------------------------------------- /example/companies/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/companies/migrations/__init__.py -------------------------------------------------------------------------------- /example/companies/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import AbstractUser 2 | from django.db import models 3 | 4 | 5 | class BaseModel(models.Model): 6 | created = models.DateTimeField(auto_now_add=True) 7 | updated = models.DateTimeField(auto_now=True) 8 | 9 | class Meta: 10 | abstract = True 11 | 12 | 13 | class User(AbstractUser, BaseModel): 14 | pass 15 | 16 | 17 | class Company(BaseModel): 18 | reg_code = models.CharField(max_length=20, unique=True) 19 | name = models.CharField(max_length=64) 20 | email = models.EmailField(blank=True) 21 | 22 | 23 | class Employment(BaseModel): 24 | ROLE_NORMAL = 1 25 | ROLE_ADMIN = 2 26 | 27 | ROLE_CHOICES = ( 28 | (ROLE_NORMAL, 'normal'), 29 | (ROLE_ADMIN, 'admin'), 30 | ) 31 | 32 | user = models.ForeignKey(User, related_name='employments', on_delete=models.CASCADE) 33 | company = models.ForeignKey(Company, related_name='employees', on_delete=models.CASCADE) 34 | role = models.PositiveSmallIntegerField(choices=ROLE_CHOICES, default=ROLE_NORMAL) 35 | 36 | class Meta: 37 | unique_together = (('user', 'company'),) 38 | -------------------------------------------------------------------------------- /example/companies/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework_json_api import serializers 2 | 3 | from tg_apicore.serializers import BaseModelSerializer 4 | 5 | from companies.models import Company, Employment, User 6 | 7 | 8 | class EmploymentSummarySerializer(BaseModelSerializer): 9 | class Meta: 10 | model = Employment 11 | fields = ['id', 'url', 'created', 'updated', 'name', 'email', 'role'] 12 | create_only_fields = ['email'] 13 | 14 | name = serializers.CharField(source='user.get_full_name', read_only=True) 15 | email = serializers.EmailField(source='user.email') 16 | 17 | 18 | class CompanySummarySerializer(BaseModelSerializer): 19 | class Meta: 20 | model = Company 21 | fields = ['id', 'url', 'created', 'updated', 'reg_code', 'name', 'email'] 22 | create_only_fields = ['reg_code'] 23 | 24 | 25 | class EmploymentSerializer(EmploymentSummarySerializer): 26 | class Meta(EmploymentSummarySerializer.Meta): 27 | fields = EmploymentSummarySerializer.Meta.fields + ['company'] 28 | 29 | class JSONAPIMeta: 30 | included_resources = ['company'] 31 | 32 | included_serializers = { 33 | 'company': CompanySummarySerializer, 34 | } 35 | 36 | def validate_company(self, value): 37 | user = self.context['request'].user 38 | if not Employment.objects.filter(company=value, user=user, role=Employment.ROLE_ADMIN).exists(): 39 | raise serializers.ValidationError("You are not admin in the specified company", code='user_not_admin') 40 | 41 | return value 42 | 43 | def validate(self, attrs): 44 | # If user's email was given, use it to look up the actual object (creating it if necessary) 45 | if attrs.get('user', {}).get('email'): 46 | attrs['user'], _ = User.objects.get_or_create(email=attrs.pop('user')['email']) 47 | 48 | return super().validate(attrs) 49 | 50 | 51 | class CompanySerializer(CompanySummarySerializer): 52 | class Meta(CompanySummarySerializer.Meta): 53 | fields = CompanySummarySerializer.Meta.fields + ['employees'] 54 | read_only_fields = ['employees'] 55 | 56 | class JSONAPIMeta: 57 | included_resources = ['employees'] 58 | 59 | included_serializers = { 60 | 'employees': EmploymentSummarySerializer, 61 | } 62 | -------------------------------------------------------------------------------- /example/companies/views.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import IsAuthenticatedOrReadOnly, SAFE_METHODS, IsAuthenticated 2 | from rest_framework.viewsets import ModelViewSet 3 | 4 | from tg_apicore.docs import add_api_docs, api_section_docs, api_method_docs 5 | from tg_apicore.viewsets import DetailSerializerViewSet 6 | 7 | from companies import api_docs 8 | from companies.models import Company, Employment 9 | from companies.serializers import CompanySerializer, EmploymentSerializer, CompanySummarySerializer, \ 10 | EmploymentSummarySerializer 11 | 12 | 13 | @add_api_docs( 14 | api_section_docs( 15 | data=api_docs.COMPANIES_DATA, 16 | ), 17 | api_method_docs( 18 | 'list', 19 | response_data=api_docs.COMPANIES_LIST_RESPONSE, 20 | ), 21 | api_method_docs( 22 | 'create', 23 | request_data=api_docs.COMPANIES_CREATE_REQUEST, 24 | responses=api_docs.COMPANIES_CREATE_RESPONSES, 25 | ), 26 | api_method_docs( 27 | 'retrieve', 28 | doc="Retrieve details of a specific company. If you're an employee of that company, it will also include " 29 | "employees info.", 30 | response_data=api_docs.COMPANIES_READ_RESPONSE, 31 | ), 32 | api_method_docs( 33 | 'partial_update', 34 | doc="Updates company data. You need to be admin employee to do that.", 35 | request_data=api_docs.COMPANIES_UPDATE_REQUEST, 36 | responses=api_docs.COMPANIES_READ_RESPONSE, 37 | ), 38 | api_method_docs( 39 | 'destroy', 40 | responses=api_docs.COMPANIES_DELETE_RESPONSES, 41 | ), 42 | ) 43 | class CompanyViewSet(ModelViewSet, DetailSerializerViewSet): 44 | """ Companies API - provides CRUD functionality for companies. 45 | 46 | If a user creates a company, they'll automatically become employee of that company, in admin role. 47 | 48 | Basic information about companies can be viewed by everyone. 49 | Employee info can be seen only by employees, in detail responses. 50 | Changes can be made only by admins. 51 | """ 52 | 53 | permission_classes = (IsAuthenticatedOrReadOnly,) 54 | queryset = Company.objects.all() 55 | serializer_class = CompanySummarySerializer 56 | serializer_detail_class = CompanySerializer # only for employees, see get_detail_serializer_class() 57 | serializer_modify_class = CompanySerializer 58 | 59 | def get_detail_serializer_class(self): 60 | # Detail serializer is only for employees 61 | company = self.get_object() 62 | user = self.request.user 63 | if company is not None and user.is_authenticated and \ 64 | Employment.objects.filter(company=company, user=user).exists(): 65 | return self.serializer_detail_class 66 | 67 | return self.get_list_serializer_class() 68 | 69 | def check_object_permissions(self, request, obj): 70 | super().check_object_permissions(request, obj) 71 | 72 | # Unsafe methods (= editing) can be used only by managers 73 | if request.method not in SAFE_METHODS: 74 | if not Employment.objects.filter(company=obj, user=request.user, role=Employment.ROLE_ADMIN).exists(): 75 | self.permission_denied(request) 76 | 77 | # pylint: disable=useless-super-delegation 78 | def list(self, request, *args, **kwargs): 79 | """ List all companies. 80 | """ 81 | 82 | return super().list(request, *args, **kwargs) 83 | 84 | def create(self, request, *args, **kwargs): 85 | """ Creates a new company. 86 | """ 87 | 88 | return super().create(request, *args, **kwargs) 89 | 90 | # pylint: disable=useless-super-delegation 91 | def destroy(self, request, *args, **kwargs): 92 | """ Deletes the given company. 93 | """ 94 | return super().destroy(request, *args, **kwargs) 95 | 96 | def perform_create(self, serializer): 97 | """ Adds current user as admin of the created company. 98 | """ 99 | 100 | super().perform_create(serializer) 101 | 102 | company = serializer.instance 103 | Employment.objects.create(company=company, user=self.request.user, role=Employment.ROLE_ADMIN) 104 | 105 | 106 | @add_api_docs( 107 | api_section_docs( 108 | data=api_docs.EMPLOYMENTS_DATA, 109 | ), 110 | api_method_docs( 111 | 'list', 112 | ), 113 | api_method_docs( 114 | 'create', 115 | request_data=api_docs.EMPLOYMENTS_CREATE_REQUEST, 116 | responses=api_docs.EMPLOYMENTS_CREATE_RESPONSES, 117 | ), 118 | ) 119 | class EmploymentViewSet(ModelViewSet, DetailSerializerViewSet): 120 | """ Employee management API. 121 | 122 | Employees can only be changed by admins of a company, and can be viewed by all employees of a company. 123 | """ 124 | 125 | permission_classes = (IsAuthenticated,) 126 | queryset = Employment.objects.all() 127 | serializer_class = EmploymentSummarySerializer 128 | serializer_detail_class = EmploymentSerializer 129 | 130 | def check_object_permissions(self, request, obj): 131 | super().check_object_permissions(request, obj) 132 | 133 | # Unsafe methods (= editing) can be used only by managers 134 | if request.method not in SAFE_METHODS: 135 | if not Employment.objects.filter(company_id=obj.company_id, user=request.user, role=Employment.ROLE_ADMIN) \ 136 | .exists(): 137 | self.permission_denied(request) 138 | 139 | def get_list_queryset(self): 140 | # If user isn't authenticated, do a quick bailout. This is a workaround for 141 | # https://github.com/encode/django-rest-framework/issues/5127 - DRF calling get_queryset() when rendering 142 | # browsable API response, even when user didn't have permissions. 143 | if not self.request.user.is_authenticated: 144 | return Employment.objects.none() 145 | 146 | user_companies = Employment.objects.filter(user=self.request.user).values_list('company_id', flat=True) 147 | return super().get_list_queryset().filter(company__in=user_companies) 148 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/example/__init__.py -------------------------------------------------------------------------------- /example/example/api_docs.py: -------------------------------------------------------------------------------- 1 | # API docs, shown on interactive docs page 2 | 3 | """ 4 | # Example API 5 | 6 | The Example API is organized around REST. 7 | Our API has predictable, resource-oriented URLs, and uses HTTP response codes to indicate API errors. 8 | We use built-in HTTP features, like HTTP authentication and HTTP verbs, 9 | which are understood by off-the-shelf HTTP clients. 10 | JSON is returned by all API responses, including errors. 11 | 12 | All request data must use UTF-8 encoding. 13 | 14 | All dates and timestamps must be in extended ISO-8601 format and response data uses UTC timezone. 15 | Examples of valid inputs: 16 | 17 | - `2017-10-01T12:34:56.123456Z` 18 | - `2017-10-01T12:34:56Z` 19 | - `2017-10-01T14:34Z` 20 | - `2017-10-01T14:34:56+02:00` 21 | - `2017-10-01T14:34:56` 22 | 23 | If timezone isn't given, it defaults to UTC. 24 | Response data is always in extended ISO-8601 format, e.g. `2017-10-01T12:34:56.123456Z` or `2017-10-01T12:34:56Z`. 25 | 26 | 27 | ## Access & endpoints 28 | 29 | All current Example API endpoints have `%(API_ROOT)s/` URL prefix. 30 | 31 | 32 | ## JSON API 33 | 34 | Example API is built using principles of [JSON API standard](http://jsonapi.org/format/). 35 | We chose JSON API because it provides a standard data exchange format that is real-world tested and fits our usecase. 36 | As an example, it provides a standardized way of including subresources, such as employments, 37 | when data of e.g. a company object is requested. 38 | 39 | In short, every response body is a JSON object, containing at least either `data` (for successful requests) 40 | or `errors` (for failed ones). 41 | `data` is either a single resource object (in case a single resource was requested) or 42 | a list of resource objects (in case a listing was requested). 43 | 44 | Each resource has `id` and `type` and usually `attributes` which is a JSON object containing resource's data (fields). 45 | 46 | Response can also contain `included` top-level key, which is a list of related resources. This is used to e.g. include 47 | all `employment` resources when a `company` is requested, so that the client wouldn't have to request the employments 48 | separately. 49 | 50 | An example response to a request for a single `company` follows: 51 | 52 | :::json 53 | { 54 | "data": { 55 | "type": "company", 56 | "id": "113", 57 | "attributes": { 58 | "name": "Turner and Sons", 59 | "email": "turner@sons.com" 60 | }, 61 | "links": { 62 | "self": "%(API_ROOT)s/companies/113/" 63 | } 64 | } 65 | } 66 | 67 | 68 | ## Pagination 69 | 70 | List responses are paginated using cursor-paged pagination. The response include `next` and `previous` keys which are 71 | links to the next/previous page of results, or `null` if there is no next/previous page. 72 | 73 | 20 results are returned per page by default but you shouldn't rely on this. 74 | The number of results per page can be changed via `page_size` query parameter. The maximum allowed page size ATM is 100. 75 | 76 | 77 | ## Versioning 78 | 79 | Example API uses date-based versioning, where version is given as part of the path in URL. 80 | We release new version whenever a backwards-incompatible changes are made to the API. 81 | 82 | E.g. if the client wants to use version `2018-02-21` then all API requests must use `/api/2018-02-21/` as path prefix. 83 | 84 | We consider the following changes to be backwards-compatible: 85 | 86 | - Adding new API resources. 87 | - Adding new optional request parameters to existing API methods. 88 | - Adding new properties to existing API responses. 89 | 90 | Old versions might be removed approximately one year after they become deprecated. 91 | 92 | 93 | ### Version History 94 | 95 | - **2018-02-21** - initial version 96 | 97 | 98 | ## Response codes and errors 99 | 100 | Example API uses standard HTTP response codes to indicate the success or failure of an API request. 101 | In general, codes in the 2xx range indicate success, 102 | codes in the 4xx range indicate an error that failed given the information provided 103 | (e.g., a required parameter was omitted, waybill couldn't be created, etc.), 104 | and codes in the 5xx range indicate server-side errors (these are rare). 105 | 106 | Error responses include body in JSON format, according to the JSON API spec. 107 | Error response body always has `errors` key, containing information about the problems encountered. 108 | 109 | 110 | ### 400 Bad Request 111 | 112 | Invalid data was submitted, e.g. when trying to create / update an object. 113 | 114 | Example response body: 115 | 116 | :::json 117 | { 118 | "errors": [ 119 | { 120 | "detail": "Company with this waybill prefix already exists.", 121 | "source": { 122 | "pointer": "/data/attributes/waybill_prefix" 123 | }, 124 | "status": "400" 125 | } 126 | ] 127 | } 128 | 129 | 130 | ### 401 Unauthorized 131 | 132 | Requested resource requires authenticated user. 133 | 134 | Example response body: 135 | 136 | :::json 137 | { 138 | "errors": [ 139 | { 140 | "detail": "Authentication credentials were not provided.", 141 | "source": { 142 | "pointer": "/data" 143 | }, 144 | "status": "401" 145 | } 146 | ] 147 | } 148 | 149 | 150 | ### 403 Forbidden 151 | 152 | Authenticated user does not have permission to access the given resource 153 | 154 | Example response body: 155 | 156 | :::json 157 | { 158 | "errors": [ 159 | { 160 | "detail": "You do not have permission to perform this action.", 161 | "source": { 162 | "pointer": "/data" 163 | }, 164 | "status": "403" 165 | } 166 | ] 167 | } 168 | 169 | 170 | ### 404 Not Found 171 | 172 | Request object does not exist. 173 | 174 | Example response body: 175 | 176 | :::json 177 | { 178 | "errors": [ 179 | { 180 | "detail": "Not found.", 181 | "source": { 182 | "pointer": "/data/attributes/detail" 183 | }, 184 | "status": "404" 185 | } 186 | ] 187 | } 188 | 189 | """ 190 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for example project. 3 | 4 | Generated by Cookiecutter Django Package 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.9/ref/settings/ 11 | """ 12 | 13 | import os 14 | 15 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 16 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | 18 | # Quick-start development settings - unsuitable for production 19 | # See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ 20 | 21 | # SECURITY WARNING: keep the secret key used in production secret! 22 | SECRET_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 23 | 24 | # SECURITY WARNING: don't run with debug turned on in production! 25 | DEBUG = True 26 | 27 | ALLOWED_HOSTS = [] 28 | 29 | # Application definition 30 | 31 | INSTALLED_APPS = [ 32 | 'companies', 33 | 34 | 'rest_framework', 35 | 'tg_apicore', 36 | 37 | 'django.contrib.admin', 38 | 'django.contrib.auth', 39 | 'django.contrib.contenttypes', 40 | 'django.contrib.sessions', 41 | 'django.contrib.messages', 42 | 'django.contrib.staticfiles', 43 | ] 44 | 45 | MIDDLEWARE = [ 46 | 'django.middleware.security.SecurityMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'django.middleware.common.CommonMiddleware', 49 | 'django.middleware.csrf.CsrfViewMiddleware', 50 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 51 | 'django.contrib.messages.middleware.MessageMiddleware', 52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 53 | ] 54 | 55 | ROOT_URLCONF = 'example.urls' 56 | 57 | TEMPLATES = [ 58 | { 59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 60 | 'DIRS': [os.path.join(BASE_DIR, 'templates'), ], 61 | 'APP_DIRS': True, 62 | 'OPTIONS': { 63 | 'context_processors': [ 64 | 'django.template.context_processors.debug', 65 | 'django.template.context_processors.request', 66 | 'django.contrib.auth.context_processors.auth', 67 | 'django.contrib.messages.context_processors.messages', 68 | ], 69 | }, 70 | }, 71 | ] 72 | 73 | WSGI_APPLICATION = 'example.wsgi.application' 74 | 75 | # Database 76 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 77 | 78 | DATABASES = { 79 | 'default': { 80 | 'ENGINE': 'django.db.backends.sqlite3', 81 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 82 | } 83 | } 84 | 85 | 86 | AUTH_USER_MODEL = 'companies.User' 87 | 88 | 89 | # Internationalization 90 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 91 | 92 | LANGUAGE_CODE = 'en-us' 93 | 94 | TIME_ZONE = 'UTC' 95 | 96 | USE_I18N = True 97 | 98 | USE_L10N = True 99 | 100 | USE_TZ = True 101 | 102 | # Static files (CSS, JavaScript, Images) 103 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 104 | 105 | STATIC_URL = '/static/' 106 | 107 | SITE_URL = 'http://127.0.0.1:8000' 108 | 109 | 110 | REST_FRAMEWORK = { 111 | 'ALLOWED_VERSIONS': ('2018-02-21',), 112 | } 113 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | from django.views.generic import TemplateView 4 | 5 | from tg_apicore.views import PageNotFoundView 6 | 7 | from example.views import ExampleAPIDocumentationView 8 | 9 | 10 | urlpatterns = [ 11 | url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'), 12 | 13 | url(r'^api-docs/', ExampleAPIDocumentationView.as_view(), name='api-docs'), 14 | url(r'^api/(?P(\d{4}-\d{2}-\d{2}))/', include('example.urls_api')), 15 | 16 | # API-specific 404 for everything under api/ prefix 17 | url(r'^api/', include(PageNotFoundView.urlpatterns())), 18 | 19 | url(r'^admin/', admin.site.urls), 20 | ] 21 | -------------------------------------------------------------------------------- /example/example/urls_api.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import include, url 2 | 3 | from companies.views import CompanyViewSet, EmploymentViewSet 4 | from tg_apicore.routers import Router 5 | 6 | 7 | router = Router() 8 | 9 | router.register('companies', CompanyViewSet) 10 | router.register('employments', EmploymentViewSet) 11 | 12 | urlpatterns = [ 13 | url(r'^', include(router.urls)), 14 | ] 15 | -------------------------------------------------------------------------------- /example/example/views.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import url 3 | from django.urls import include 4 | 5 | from tg_apicore.views import APIDocumentationView 6 | 7 | 8 | class ExampleAPIDocumentationView(APIDocumentationView): 9 | title = "Example API" 10 | 11 | def get_description(self): 12 | from example import api_docs 13 | 14 | return api_docs.__doc__.replace('# NOQA', '') 15 | 16 | def get_site_url(self) -> str: 17 | return settings.SITE_URL 18 | 19 | def get_base_path(self) -> str: 20 | docs_version = settings.API_VERSION_LATEST 21 | return '/api/%s/' % docs_version 22 | 23 | def urlpatterns(self) -> list: 24 | from example import urls_api 25 | 26 | return [ 27 | url(r'^%s' % self.get_base_path(), include(urls_api)), 28 | ] 29 | -------------------------------------------------------------------------------- /example/example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /example/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /example/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=example.settings 3 | python_files=test_*.py tests/*.py tests.py 4 | -------------------------------------------------------------------------------- /example/requirements.txt: -------------------------------------------------------------------------------- 1 | -r ../requirements_dev.txt 2 | -e ../ 3 | 4 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Thorgate API Core - Example 8 | 9 | 10 | 11 | {% block head_extra %} 12 | {% endblock head_extra %} 13 | 14 | 15 | 16 | {% block body_content %}{% endblock %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /example/templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block body_content %} 4 |
5 |

Thorgate API Core

6 |

7 | Opinionated API framework on top of 8 | Django REST framework 9 | and 10 | JSON API. 11 |

12 |

13 | API documentation 14 | Browsable API 15 |

16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /example/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/example/tests/__init__.py -------------------------------------------------------------------------------- /example/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from companies.factories import CompanyFactory 4 | from companies.models import User, Employment 5 | 6 | 7 | @pytest.fixture(scope='function') 8 | def user(): 9 | return User.objects.create_user( 10 | username='testuser', email='asd@asd.asd', password='test', first_name='Test', last_name='User', 11 | ) 12 | 13 | 14 | @pytest.fixture(scope='function') 15 | def other_user(): 16 | return User.objects.create_user( 17 | username='otheruser', email='other@asd.asd', password='test', first_name='Other', last_name='Person', 18 | ) 19 | 20 | 21 | @pytest.fixture(scope='function') 22 | def company(): 23 | return CompanyFactory.create() 24 | 25 | 26 | @pytest.fixture(scope='function') 27 | def other_company(): 28 | return CompanyFactory.create() 29 | 30 | 31 | @pytest.fixture(scope='function') 32 | def employment(user, company): 33 | return Employment.objects.create(user=user, company=company, role=Employment.ROLE_ADMIN) 34 | 35 | 36 | @pytest.fixture(scope='function') 37 | def other_employment(other_user, other_company): 38 | return Employment.objects.create(user=other_user, company=other_company, role=Employment.ROLE_ADMIN) 39 | -------------------------------------------------------------------------------- /example/tests/test_company_views.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pytest 4 | 5 | from tg_apicore.test import APIClient, validate_jsonapi_detail_response, validate_jsonapi_list_response, \ 6 | validate_jsonapi_error_response, validate_response_status_code 7 | 8 | from companies.api_docs import COMPANIES_CREATE_REQUEST 9 | from companies.factories import CompanyFactory 10 | from companies.models import Company, Employment, User 11 | 12 | 13 | ATTRIBUTES_LIST = {'created', 'updated', 'reg_code', 'name', 'email'} 14 | RELATIONSHIPS_LIST = set() 15 | ATTRIBUTES_PUBLIC = ATTRIBUTES_LIST 16 | RELATIONSHIPS_PUBLIC = RELATIONSHIPS_LIST 17 | ATTRIBUTES_FULL = ATTRIBUTES_LIST 18 | RELATIONSHIPS_FULL = {'employees'} 19 | 20 | 21 | def do_test_company_listing(client: APIClient, batch_size=5): 22 | CompanyFactory.create_batch(batch_size) 23 | 24 | resp = client.get(client.reverse('company-list')) 25 | validate_jsonapi_list_response( 26 | resp, expected_count=batch_size, expected_attributes=ATTRIBUTES_LIST, 27 | expected_relationships=RELATIONSHIPS_LIST, 28 | ) 29 | 30 | 31 | @pytest.mark.django_db 32 | def test_create_company(user: User): 33 | """ Users should be able to create companies. They should become admin of the created company. 34 | """ 35 | 36 | client = APIClient() 37 | client.force_authenticate(user) 38 | 39 | resp = client.post(client.reverse('company-list'), data=COMPANIES_CREATE_REQUEST) 40 | data = validate_jsonapi_detail_response(resp, expected_status_code=201) 41 | 42 | assert Employment.objects.filter(user=user, company_id=data['data']['id'], role=Employment.ROLE_ADMIN).exists() 43 | 44 | 45 | @pytest.mark.django_db 46 | def test_create_company_public(): 47 | """ Companies cannot be created by anonymous users. 48 | """ 49 | 50 | client = APIClient() 51 | 52 | resp = client.post(client.reverse('company-list'), data=COMPANIES_CREATE_REQUEST) 53 | validate_jsonapi_error_response(resp, expected_status_code=403) 54 | 55 | 56 | @pytest.mark.django_db 57 | def test_companies_list(user: User): 58 | """ Companies can be listed by a user. 59 | """ 60 | 61 | client = APIClient() 62 | client.force_authenticate(user) 63 | 64 | do_test_company_listing(client) 65 | 66 | 67 | @pytest.mark.django_db 68 | def test_companies_list_public(): 69 | """ Companies can also be listed anonymously. 70 | """ 71 | 72 | client = APIClient() 73 | do_test_company_listing(client) 74 | 75 | 76 | @pytest.mark.django_db 77 | def test_companies_details_employee(employment: Employment): 78 | """ Company details can be viewed by an employee, and full information is returned. 79 | """ 80 | 81 | client = APIClient() 82 | client.force_authenticate(employment.user) 83 | 84 | resp = client.get(client.reverse('company-detail', pk=employment.company.pk)) 85 | validate_jsonapi_detail_response( 86 | resp, 87 | expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, 88 | ) 89 | 90 | 91 | @pytest.mark.django_db 92 | def test_companies_details_unrelated(user: User, other_company: Company): 93 | """ Company details can be viewed by an unrelated user (non-employee), but only basic information is returned. 94 | """ 95 | 96 | client = APIClient() 97 | client.force_authenticate(user) 98 | 99 | resp = client.get(client.reverse('company-detail', pk=other_company.pk)) 100 | validate_jsonapi_detail_response( 101 | resp, 102 | expected_attributes=ATTRIBUTES_PUBLIC, expected_relationships=RELATIONSHIPS_PUBLIC, 103 | ) 104 | 105 | 106 | @pytest.mark.django_db 107 | def test_companies_details_public(company: Company): 108 | """ Company details can also be viewed anonymously, only basic information is returned. 109 | """ 110 | 111 | client = APIClient() 112 | 113 | resp = client.get(client.reverse('company-detail', pk=company.pk)) 114 | validate_jsonapi_detail_response( 115 | resp, 116 | expected_attributes=ATTRIBUTES_PUBLIC, expected_relationships=RELATIONSHIPS_PUBLIC, 117 | ) 118 | 119 | 120 | @pytest.mark.django_db 121 | def test_companies_update(employment: Employment): 122 | assert employment.role == Employment.ROLE_ADMIN 123 | user = employment.user 124 | company = employment.company 125 | 126 | client = APIClient() 127 | client.force_authenticate(user) 128 | 129 | other_company = CompanyFactory.create() 130 | 131 | patch_data = { 132 | "data": { 133 | "type": "company", 134 | "id": str(company.id), 135 | "attributes": {}, 136 | }, 137 | } 138 | 139 | # Part one - update the company where the user is admin 140 | new_name = 'new name' 141 | updated = company.updated 142 | assert company.name != new_name 143 | patch_data['data']['attributes'] = {'name': new_name} 144 | resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) 145 | validate_jsonapi_detail_response( 146 | resp, 147 | expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, 148 | ) 149 | refreshed_company = Company.objects.get(id=company.id) 150 | assert refreshed_company.name == new_name 151 | assert refreshed_company.updated > updated 152 | 153 | # Part two - PUT should not be allowed 154 | resp = client.put(client.reverse('company-detail', pk=company.pk), patch_data) 155 | validate_jsonapi_error_response(resp, expected_status_code=405) 156 | 157 | # Part three - updating is only allowed for admins, so it should fail after user is demoted to non-admin 158 | employment.role = Employment.ROLE_NORMAL 159 | employment.save() 160 | resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) 161 | validate_jsonapi_error_response(resp, expected_status_code=403) 162 | 163 | # Part four - try to patch company where we don't have permissions 164 | patch_data['data']['id'] = str(other_company.id) 165 | resp = client.patch(client.reverse('company-detail', pk=other_company.pk)) 166 | validate_jsonapi_error_response(resp, expected_status_code=403) 167 | 168 | 169 | @pytest.mark.django_db 170 | def test_companies_create_only_fields(user: User): 171 | """ Ensures that create-only fields cannot be updated for existing instances. 172 | 173 | It also acts as general test for the create-only fields functionality. 174 | """ 175 | 176 | client = APIClient() 177 | client.force_authenticate(user) 178 | 179 | # Part one - try creating a company without reg_code (required and create-only field) - this should fail 180 | req_data = deepcopy(COMPANIES_CREATE_REQUEST) 181 | del req_data['data']['attributes']['reg_code'] 182 | resp = client.post(client.reverse('company-list'), data=req_data) 183 | validate_jsonapi_error_response(resp, expected_status_code=400) 184 | 185 | # Part two - create a company with all the necessary fields 186 | req_data = deepcopy(COMPANIES_CREATE_REQUEST) 187 | resp = client.post(client.reverse('company-list'), data=req_data) 188 | resp_data = validate_jsonapi_detail_response(resp, expected_status_code=201) 189 | 190 | # Ensure everything is as intended 191 | req_data_attributes = req_data['data']['attributes'] 192 | company = Company.objects.get(id=resp_data['data']['id']) 193 | for attr_name in req_data_attributes: 194 | assert getattr(company, attr_name) == req_data_attributes[attr_name] 195 | 196 | # Next, try updating the reg_code, which should be read-only 197 | new_reg_code = 123456 198 | assert company.reg_code != new_reg_code 199 | patch_data = { 200 | "data": { 201 | "type": "company", 202 | "id": str(company.id), 203 | "attributes": { 204 | 'reg_code': new_reg_code, 205 | }, 206 | }, 207 | } 208 | 209 | # Try to update the value - it should be no-op 210 | resp = client.patch(client.reverse('company-detail', pk=company.pk), patch_data) 211 | validate_jsonapi_detail_response( 212 | resp, 213 | expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, 214 | ) 215 | # Ensure the value in database hasn't been changed 216 | refreshed_company = Company.objects.get(id=company.id) 217 | assert refreshed_company.reg_code == company.reg_code 218 | 219 | 220 | @pytest.mark.django_db 221 | def test_companies_delete(employment: Employment, other_company: Company): 222 | """ Ensures admins can delete companies but non-admin employees cannot. 223 | """ 224 | 225 | assert employment.role == Employment.ROLE_ADMIN 226 | user = employment.user 227 | company = employment.company 228 | 229 | client = APIClient() 230 | client.force_authenticate(user) 231 | 232 | # Part one - delete the company where the user is admin 233 | resp = client.delete(client.reverse('company-detail', pk=company.id)) 234 | validate_response_status_code(resp, 204) 235 | assert not Company.objects.filter(id=company.id).exists() 236 | 237 | # Part two - try to delete an unrelated company - this should not be allowed 238 | resp = client.delete(client.reverse('company-detail', pk=other_company.id)) 239 | validate_jsonapi_error_response(resp, expected_status_code=403) 240 | assert Company.objects.filter(id=other_company.id).exists() 241 | -------------------------------------------------------------------------------- /example/tests/test_employment_views.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | import pytest 4 | 5 | from tg_apicore.test import APIClient, validate_jsonapi_detail_response, validate_jsonapi_list_response, \ 6 | validate_jsonapi_error_response, validate_response_status_code 7 | 8 | from companies.api_docs import EMPLOYMENTS_CREATE_REQUEST 9 | from companies.models import Company, Employment, User 10 | 11 | 12 | ATTRIBUTES_LIST = {'created', 'updated', 'name', 'email', 'role'} 13 | RELATIONSHIPS_LIST = set() 14 | ATTRIBUTES_FULL = ATTRIBUTES_LIST 15 | RELATIONSHIPS_FULL = {'company'} 16 | 17 | 18 | def get_employment_create_data_for(company: Company, email: str): 19 | req_data = deepcopy(EMPLOYMENTS_CREATE_REQUEST) 20 | req_data['data']['relationships']['company']['data']['id'] = str(company.id) 21 | req_data['data']['attributes']['email'] = email 22 | 23 | return req_data 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_create_employment(employment: Employment): 28 | """ Admin users should be able to create employments in the same company. 29 | """ 30 | 31 | company = employment.company 32 | user = employment.user 33 | assert employment.role == Employment.ROLE_ADMIN 34 | 35 | email = 'anotheruser@foo.bar' 36 | assert not User.objects.filter(email=email).exists() 37 | 38 | client = APIClient() 39 | client.force_authenticate(user) 40 | 41 | req_data = get_employment_create_data_for(company, email) 42 | resp = client.post(client.reverse('employment-list'), data=req_data) 43 | validate_jsonapi_detail_response(resp, expected_status_code=201) 44 | 45 | assert User.objects.filter(email=email).exists() 46 | assert Employment.objects.filter(user=user, company=company, role=Employment.ROLE_ADMIN).exists() 47 | 48 | 49 | @pytest.mark.django_db 50 | def test_create_employment_nonadmin(employment: Employment): 51 | """ Users who are not admin in a company cannot create employments for that company. 52 | """ 53 | 54 | company = employment.company 55 | user = employment.user 56 | employment.role = Employment.ROLE_NORMAL 57 | employment.save() 58 | 59 | client = APIClient() 60 | client.force_authenticate(user) 61 | 62 | req_data = get_employment_create_data_for(company, 'anotheruser@foo.bar') 63 | 64 | resp = client.post(client.reverse('employment-list'), data=req_data) 65 | validate_jsonapi_error_response(resp, expected_status_code=400) 66 | 67 | 68 | @pytest.mark.django_db 69 | def test_create_employment_unrelated(user: User, other_company: Company): 70 | """ Users who are not employees of a company cannot create employments for that company. 71 | """ 72 | 73 | client = APIClient() 74 | client.force_authenticate(user) 75 | 76 | req_data = get_employment_create_data_for(other_company, 'anotheruser@foo.bar') 77 | 78 | resp = client.post(client.reverse('employment-list'), data=req_data) 79 | validate_jsonapi_error_response(resp, expected_status_code=400) 80 | 81 | 82 | @pytest.mark.django_db 83 | def test_create_employment_public(company: Company): 84 | """ Employments cannot be created by anonymous users. 85 | """ 86 | 87 | client = APIClient() 88 | 89 | req_data = get_employment_create_data_for(company, 'anotheruser@foo.bar') 90 | 91 | resp = client.post(client.reverse('employment-list'), data=req_data) 92 | validate_jsonapi_error_response(resp, expected_status_code=403) 93 | 94 | 95 | @pytest.mark.django_db 96 | def test_employments_list(employment: Employment, other_user: User, other_company: Company): 97 | """ Employments can be listed by existing employees of a company. 98 | Users should see only employees of companies they themselves belong to. 99 | """ 100 | 101 | user = employment.user 102 | 103 | client = APIClient() 104 | client.force_authenticate(user) 105 | 106 | Employment.objects.create(company=other_company, user=other_user, role=Employment.ROLE_ADMIN) 107 | assert Employment.objects.count() == 2 108 | 109 | # Ensure we only get a single employment back - the one belonging to the company we're in. 110 | resp = client.get(client.reverse('employment-list')) 111 | resp_data = validate_jsonapi_list_response( 112 | resp, expected_count=1, 113 | expected_attributes=ATTRIBUTES_LIST, expected_relationships=RELATIONSHIPS_LIST, 114 | ) 115 | assert set(item['id'] for item in resp_data['data']) == {str(employment.company_id)} 116 | 117 | 118 | @pytest.mark.django_db 119 | def test_employments_list_public(): 120 | """ Employees cannot be listed anonymously. 121 | """ 122 | 123 | client = APIClient() 124 | 125 | resp = client.get(client.reverse('employment-list')) 126 | validate_jsonapi_error_response(resp, 403) 127 | 128 | 129 | @pytest.mark.django_db 130 | def test_employments_details_employee(employment: Employment, other_user: User): 131 | """ Employment details can be viewed by an employee, and full information is returned. 132 | """ 133 | 134 | company = employment.company 135 | other_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) 136 | 137 | client = APIClient() 138 | client.force_authenticate(employment.user) 139 | 140 | resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) 141 | validate_jsonapi_detail_response( 142 | resp, 143 | expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, 144 | ) 145 | 146 | 147 | @pytest.mark.django_db 148 | def test_employments_details_unrelated(user: User, other_employment: Employment): 149 | """ Employment details cannot be viewed by an unrelated user (non-employee). 150 | """ 151 | 152 | client = APIClient() 153 | client.force_authenticate(user) 154 | 155 | resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) 156 | validate_jsonapi_error_response(resp, 404) 157 | 158 | 159 | @pytest.mark.django_db 160 | def test_employments_details_public(other_employment: Employment): 161 | """ Employment details cannot be viewed anonymously. 162 | """ 163 | 164 | client = APIClient() 165 | 166 | resp = client.get(client.reverse('employment-detail', pk=other_employment.pk)) 167 | validate_jsonapi_error_response(resp, 403) 168 | 169 | 170 | @pytest.mark.django_db 171 | def test_employments_update(employment: Employment, other_user: User): 172 | """ Admins should be able to update employment info (= role) of companies where they are admins. 173 | """ 174 | 175 | assert employment.role == Employment.ROLE_ADMIN 176 | user = employment.user 177 | company = employment.company 178 | other_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) 179 | 180 | client = APIClient() 181 | client.force_authenticate(user) 182 | 183 | patch_data = { 184 | "data": { 185 | "type": "employment", 186 | "id": str(other_employment.id), 187 | "attributes": {}, 188 | }, 189 | } 190 | 191 | # Part one - update the employment, changing role to admin 192 | updated = other_employment.updated 193 | patch_data['data']['attributes'] = {'role': Employment.ROLE_ADMIN} 194 | resp = client.patch(client.reverse('employment-detail', pk=other_employment.pk), patch_data) 195 | validate_jsonapi_detail_response( 196 | resp, 197 | expected_attributes=ATTRIBUTES_FULL, expected_relationships=RELATIONSHIPS_FULL, 198 | ) 199 | refreshed_employment = Employment.objects.get(id=other_employment.id) 200 | assert refreshed_employment.role == Employment.ROLE_ADMIN 201 | assert refreshed_employment.updated > updated 202 | 203 | # Part two - PUT should not be allowed 204 | resp = client.put(client.reverse('employment-detail', pk=other_employment.pk), patch_data) 205 | validate_jsonapi_error_response(resp, expected_status_code=405) 206 | 207 | # Part three - updating is only allowed for admins, so it should fail after user is demoted to non-admin 208 | employment.role = Employment.ROLE_NORMAL 209 | employment.save() 210 | resp = client.patch(client.reverse('employment-detail', pk=other_employment.pk), patch_data) 211 | validate_jsonapi_error_response(resp, expected_status_code=403) 212 | 213 | 214 | @pytest.mark.django_db 215 | def test_employments_delete(employment: Employment, other_user: User, other_employment: Employment): 216 | """ Ensures admins can delete employments but non-admin employees cannot. 217 | """ 218 | 219 | assert employment.role == Employment.ROLE_ADMIN 220 | user = employment.user 221 | company = employment.company 222 | target_employment = Employment.objects.create(company=company, user=other_user, role=Employment.ROLE_NORMAL) 223 | 224 | client = APIClient() 225 | client.force_authenticate(user) 226 | 227 | # Part one - delete the company where the user is admin 228 | resp = client.delete(client.reverse('employment-detail', pk=target_employment.id)) 229 | validate_response_status_code(resp, 204) 230 | assert not Employment.objects.filter(id=target_employment.id).exists() 231 | 232 | # Part two - try to delete an unrelated company - this should not be allowed 233 | resp = client.delete(client.reverse('employment-detail', pk=other_employment.id)) 234 | validate_jsonapi_error_response(resp, expected_status_code=404) 235 | assert Employment.objects.filter(id=other_employment.id).exists() 236 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=test_settings 3 | python_files=tests/*.py 4 | norecursedirs=example 5 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==9.0.1 2 | bumpversion==0.5.3 3 | wheel==0.30.0 4 | watchdog==0.8.3 5 | flake8==3.5.0 6 | tox==2.9.1 7 | coverage==4.5.1 8 | Sphinx==1.6.5 9 | twine==1.9.1 10 | cryptography==2.1.4 11 | PyYAML==3.11 12 | pytest==3.3.2 13 | pytest-runner==2.11.1 14 | pytest-django==3.1.2 15 | factory_boy==2.10.0 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.0 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:tg_apicore/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [bdist_wheel] 15 | universal = 1 16 | 17 | [flake8] 18 | exclude = docs 19 | max-line-length = 120 20 | 21 | [aliases] 22 | test = pytest 23 | 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open('HISTORY.rst') as history_file: 12 | history = history_file.read() 13 | 14 | requirements = [ 15 | 'Django>=1.11,<2.1', 16 | 'djangorestframework>=3.6,<4', 17 | 'djangorestframework-jsonapi>=2.4,<3', 18 | 'attrs>=17.2.0', 19 | 'coreapi>=2.3', 20 | 'Markdown>=2.6', 21 | 'Pygments>=2.2', 22 | ] 23 | 24 | setup_requirements = ['pytest-runner', ] 25 | 26 | test_requirements = [ 27 | 'pytest', 28 | 'pytest-django', 29 | ] 30 | 31 | setup( 32 | author="Thorgate", 33 | author_email='code@thorgate.eu', 34 | classifiers=[ 35 | 'Development Status :: 2 - Pre-Alpha', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: ISC License (ISCL)', 38 | 'Natural Language :: English', 39 | 'Programming Language :: Python :: 3', 40 | 'Programming Language :: Python :: 3.5', 41 | 'Programming Language :: Python :: 3.6', 42 | ], 43 | description="Opinionated API framework on top of Django REST framework", 44 | install_requires=requirements, 45 | license="ISC license", 46 | long_description=readme + '\n\n' + history, 47 | include_package_data=True, 48 | keywords='tg-apicore django djangorestframework', 49 | name='tg-apicore', 50 | packages=find_packages(include=['tg_apicore']), 51 | setup_requires=setup_requirements, 52 | test_suite='tests', 53 | tests_require=test_requirements, 54 | url='https://github.com/thorgate/tg-apicore', 55 | version='0.3.0', 56 | zip_safe=False, 57 | ) 58 | -------------------------------------------------------------------------------- /test_settings.py: -------------------------------------------------------------------------------- 1 | DEBUG = True 2 | 3 | SECRET_KEY = 'test' 4 | 5 | DATABASES = { 6 | 'default': { 7 | 'ENGINE': 'django.db.backends.sqlite3', 8 | } 9 | } 10 | 11 | MIDDLEWARE_CLASSES = [] 12 | 13 | INSTALLED_APPS = [ 14 | 'rest_framework', 15 | 'tg_apicore', 16 | 17 | 'django.contrib.admin', 18 | 'django.contrib.auth', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 'django.contrib.messages', 22 | 'django.contrib.staticfiles', 23 | ] 24 | 25 | 26 | SITE_URL = 'http://127.0.0.1:8000' 27 | 28 | 29 | REST_FRAMEWORK = { 30 | 'ALLOWED_VERSIONS': ('2018-02-21',), 31 | } 32 | -------------------------------------------------------------------------------- /tests/test_importing.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | 4 | def test_imports(): 5 | """ Ensures all submodules are importable 6 | Acts as a simple smoketest. 7 | """ 8 | 9 | from tg_apicore import apps 10 | from tg_apicore import docs 11 | from tg_apicore import pagination 12 | from tg_apicore import parsers 13 | from tg_apicore import renderers 14 | from tg_apicore import routers 15 | from tg_apicore import schemas 16 | from tg_apicore import test 17 | from tg_apicore import transformers 18 | from tg_apicore import views 19 | import tg_apicore 20 | -------------------------------------------------------------------------------- /tg_apicore/__init__.py: -------------------------------------------------------------------------------- 1 | """Top-level package for Thorgate API Core.""" 2 | 3 | __author__ = """Thorgate""" 4 | __email__ = 'code@thorgate.eu' 5 | __version__ = '0.3.0' 6 | 7 | default_app_config = 'tg_apicore.apps.TgApicoreConfig' 8 | -------------------------------------------------------------------------------- /tg_apicore/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | from tg_apicore import settings 4 | 5 | 6 | class TgApicoreConfig(AppConfig): 7 | name = 'tg_apicore' 8 | 9 | def ready(self): 10 | super().ready() 11 | 12 | settings.patch_django_settings() 13 | settings.verify_settings() 14 | -------------------------------------------------------------------------------- /tg_apicore/docs.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import List # NOQA 4 | 5 | from django.utils.safestring import mark_safe 6 | 7 | import attr 8 | from rest_framework.utils import encoders 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | def jsonize(data): 15 | return mark_safe(json.dumps(data, cls=encoders.JSONEncoder, indent=2)) 16 | 17 | 18 | @attr.s 19 | class FieldDocs: 20 | """ Information about a single field of an object (gathered from viewset's serializer) """ 21 | 22 | name = attr.ib() 23 | description = attr.ib() 24 | is_required = attr.ib() 25 | is_read_only = attr.ib() 26 | is_create_only = attr.ib() 27 | 28 | 29 | @attr.s 30 | class MethodDocs: 31 | """ Docs for a single API endpoint """ 32 | 33 | action = attr.ib() 34 | request_data = attr.ib(default=None) 35 | # List of (name, data) tuples 36 | responses = attr.ib(default=attr.Factory(list)) 37 | docstring = attr.ib(default=None) 38 | # Relative to API base, filled when generating APIDocs 39 | path = attr.ib(default=None) 40 | method = attr.ib(default=None) 41 | 42 | def __attrs_post_init__(self): 43 | self.docstring = self.docstring or '' 44 | 45 | @property 46 | def request_data_json(self): 47 | return jsonize(self.request_data) 48 | 49 | @property 50 | def responses_items(self): 51 | # Disable invalid pylint error, this might be fixed in upcoming 1.8.0 52 | # pylint: disable=not-an-iterable 53 | return [(name, jsonize(data) if data is not None else None) for name, data in self.responses] 54 | 55 | 56 | @attr.s 57 | class SectionDocs: 58 | """ Docs for a section of the API, usually meaning a viewset """ 59 | 60 | data = attr.ib(default=None) 61 | changelog = attr.ib(default=None) 62 | docstring = attr.ib(default=None) 63 | 64 | name = attr.ib(default=None) # type: str 65 | methods = attr.ib(default=attr.Factory(list)) # type: List[MethodDocs] 66 | hidden_methods = attr.ib(default=attr.Factory(list)) # type: List[str] 67 | 68 | fields = attr.ib(default=attr.Factory(list)) # type: list 69 | 70 | def __attrs_post_init__(self): 71 | self.docstring = self.docstring or '' 72 | 73 | @property 74 | def data_json(self): 75 | return jsonize(self.data) 76 | 77 | @property 78 | def changelog_items(self): 79 | return sorted(self.changelog.items(), reverse=True) 80 | 81 | 82 | @attr.s 83 | class APIDocs: 84 | """ Docs for the entire API """ 85 | 86 | title = attr.ib() # type: str 87 | # Longer intro / description text, markdown 88 | description = attr.ib() # type: str 89 | 90 | site_url = attr.ib() # type: str 91 | base_path = attr.ib() # type: str 92 | 93 | sections = attr.ib(default=attr.Factory(list)) # type: List[SectionDocs] 94 | 95 | 96 | def add_api_docs(*docs, hidden_methods=None): 97 | """ Decorator that adds given list of docs to the class """ 98 | 99 | def decorator(cls): 100 | # Find the section doc and all method docs 101 | section_doc = None 102 | method_docs = [] 103 | for doc in docs: 104 | if isinstance(doc, SectionDocs): 105 | if section_doc is not None: 106 | raise RuntimeError("Only a single SectionDocs instance can be present") 107 | section_doc = doc 108 | elif isinstance(doc, MethodDocs): 109 | method_docs.append(doc) 110 | else: 111 | raise RuntimeError("add_api_docs() parameters must be SectionDocs or MethodDocs instances") 112 | 113 | # Create empty section if it wasn't specified 114 | if section_doc is None: 115 | section_doc = SectionDocs() 116 | 117 | # Add all found methods to the section and attach the section to the viewset 118 | section_doc.methods = method_docs 119 | 120 | # Remember hidden methods 121 | section_doc.hidden_methods = hidden_methods or [] 122 | 123 | cls.api_core_docs = section_doc 124 | return cls 125 | 126 | return decorator 127 | 128 | 129 | def api_section_docs(*, data=None, changelog=None): 130 | return SectionDocs(data=data, changelog=changelog) 131 | 132 | 133 | def api_method_docs(action, *, request_data=None, response_data=None, responses=None, doc=None): 134 | assert response_data is None or responses is None, "Give either 'response_data' or 'responses', not both" 135 | 136 | responses = responses or [] # type: list 137 | # Handle convenience inputs. Our canonical format is list of (name, data) tuples, but we also support dicts, 138 | # as well as just numeric status codes as keys. All of those will be converted into canonical format here. 139 | if isinstance(responses, dict): 140 | responses = list(responses.items()) 141 | if response_data is not None: 142 | responses.append((200, response_data)) 143 | responses = [ 144 | ("Response (status %d)" % response_name if isinstance(response_name, int) else response_name, response_data) 145 | for response_name, response_data in responses 146 | ] 147 | 148 | return MethodDocs(action=action, request_data=request_data, responses=responses, docstring=doc) 149 | -------------------------------------------------------------------------------- /tg_apicore/pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.pagination import CursorPagination as DRFCursorPagination 2 | from rest_framework.pagination import _positive_int 3 | from rest_framework.response import Response 4 | 5 | 6 | class CursorPagination(DRFCursorPagination): 7 | """ A json-api compatible cursor pagination format 8 | 9 | Also supports customizable page_size via GET parameter. 10 | """ 11 | 12 | page_size_query_param = 'page_size' 13 | max_page_size = 100 14 | 15 | def get_page_size(self, request): 16 | if self.page_size_query_param: 17 | try: 18 | return _positive_int( 19 | request.query_params[self.page_size_query_param], 20 | strict=True, 21 | cutoff=self.max_page_size 22 | ) 23 | except (KeyError, ValueError): 24 | pass 25 | 26 | return self.page_size 27 | 28 | def get_paginated_response(self, data): 29 | return Response({ 30 | 'results': data, 31 | 'links': { 32 | 'next': self.get_next_link(), 33 | 'prev': self.get_previous_link(), 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /tg_apicore/parsers.py: -------------------------------------------------------------------------------- 1 | from rest_framework_json_api.parsers import JSONParser as JSONAPIParser 2 | 3 | from tg_apicore.renderers import JSONRenderer 4 | 5 | 6 | class JSONParser(JSONAPIParser): 7 | media_type = 'application/json' 8 | renderer_class = JSONRenderer 9 | -------------------------------------------------------------------------------- /tg_apicore/renderers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from rest_framework.renderers import JSONRenderer as DRFJSONRenderer 4 | from rest_framework_json_api.renderers import JSONRenderer as JSONAPIRenderer 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class TransformerAwareJSONRenderer(DRFJSONRenderer): 11 | def render(self, data, accepted_media_type=None, renderer_context=None): 12 | assert renderer_context 13 | version_transformers = getattr(renderer_context.get("request"), 'version_transformers', []) 14 | for transformer in version_transformers: 15 | data = transformer.output_backwards(data) 16 | 17 | return super().render(data, accepted_media_type, renderer_context) 18 | 19 | 20 | class JSONRenderer(JSONAPIRenderer, TransformerAwareJSONRenderer): 21 | """ JSON-API renderer that uses plain application/json mimetype 22 | 23 | This is intended for easier debugging since browsers don't recognize the custom application/vnd.api+json mimetype. 24 | """ 25 | 26 | media_type = 'application/json' 27 | format = 'json' 28 | 29 | 30 | class PureJSONRenderer(DRFJSONRenderer): 31 | media_type = 'application/json' 32 | format = 'pure-json' 33 | -------------------------------------------------------------------------------- /tg_apicore/routers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import routers 2 | 3 | 4 | class APIRootView(routers.APIRootView): 5 | required_scopes = [] 6 | 7 | 8 | class Router(routers.DefaultRouter): 9 | """ Slightly more JSON-API compatible router that disables PUT method. 10 | 11 | JSON-API always uses PATCH for updating objects, so PUT is superfluous. 12 | """ 13 | 14 | APIRootView = APIRootView 15 | 16 | def get_method_map(self, viewset, method_map): 17 | method_map = super().get_method_map(viewset, method_map) 18 | if 'put' in method_map: 19 | del method_map['put'] 20 | 21 | return method_map 22 | -------------------------------------------------------------------------------- /tg_apicore/schemas.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | 4 | import attr 5 | from rest_framework import serializers 6 | from rest_framework.relations import ManyRelatedField 7 | from rest_framework.schemas import SchemaGenerator 8 | from rest_framework.utils.formatting import dedent 9 | 10 | from tg_apicore.docs import APIDocs, FieldDocs, MethodDocs, SectionDocs 11 | 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class ApiDocsGenerator(SchemaGenerator): 17 | """ Schema generator used for api docs 18 | 19 | Generate APIDocs object, not an ordinary schema. Use `get_docs()` method. 20 | """ 21 | 22 | # pylint: disable=too-many-arguments 23 | def __init__(self, title, description, patterns, site_url, base_path, urlconf=None): 24 | super().__init__(title=title, description=description, patterns=patterns, urlconf=urlconf) 25 | 26 | if site_url.endswith('/') and base_path.startswith('/'): 27 | site_url = site_url[:-1] 28 | self.site_url = site_url 29 | self.base_path = base_path 30 | 31 | def get_schema(self, request=None, public=False): 32 | raise NotImplementedError("Use ApiDocsGenerator.get_docs() instead") 33 | 34 | def get_docs(self): 35 | if self.endpoints is None: 36 | inspector = self.endpoint_inspector_cls(self.patterns, self.urlconf) 37 | self.endpoints = inspector.get_api_endpoints() 38 | 39 | sections = self.get_sections() 40 | if not sections: 41 | return None 42 | 43 | return APIDocs( 44 | title=self.title, description=self.replace_variables(self.description), 45 | site_url=self.site_url, base_path=self.base_path, sections=sections, 46 | ) 47 | 48 | def get_sections(self, request=None): 49 | """ Returns list of SectionDocs objects 50 | 51 | Based on SchemaGenerator.get_links() 52 | """ 53 | 54 | # pylint: disable=too-many-locals 55 | 56 | # Generate (path, method, view) given (path, method, callback). 57 | paths = [] 58 | view_endpoints = [] 59 | for path, method, callback in self.endpoints: 60 | view = self.create_view(callback, method, request) 61 | if getattr(view, 'schema', True) is None: 62 | continue 63 | path = self.coerce_path(path, method, view) 64 | paths.append(path) 65 | view_endpoints.append((path, method, view)) 66 | 67 | # Only generate the path prefix for paths that will be included 68 | if not paths: 69 | return None 70 | prefix = self.determine_path_prefix(paths) 71 | 72 | sections = {} 73 | section_methods = defaultdict(list) 74 | for path, method, view in view_endpoints: 75 | subpath = path[len(prefix):] 76 | keys = self.get_keys(subpath, method, view) 77 | 78 | if subpath.startswith('/'): 79 | subpath = subpath[1:] 80 | 81 | view_cls = view.__class__ 82 | section_name = keys[0] 83 | 84 | # Get or create section doc 85 | section_doc = sections.get(section_name) 86 | if section_doc is None: 87 | section_doc = getattr(view_cls, 'api_core_docs', None) 88 | if section_doc is None: 89 | section_doc = SectionDocs() 90 | else: 91 | # Ensure that we don't change the original 92 | section_doc = attr.evolve(section_doc) 93 | 94 | section_doc.fields = self.get_generic_serializer_fields(view) 95 | if not section_doc.docstring: 96 | section_doc.docstring = view_cls.__doc__ or '' 97 | section_doc.docstring = dedent(section_doc.docstring) 98 | section_doc.docstring = self.replace_variables(section_doc.docstring) 99 | section_doc.data = self.replace_variables(section_doc.data) 100 | if not section_doc.name: 101 | section_doc.name = section_name 102 | 103 | sections[section_name] = section_doc 104 | 105 | # Create method doc 106 | action = keys[1] 107 | 108 | # Skip hidden methods 109 | if action in section_doc.hidden_methods: 110 | continue 111 | 112 | method_doc = next((m for m in section_doc.methods if m.action == action), None) 113 | view_doc = getattr(getattr(view_cls, action, None), '__doc__', '') or '' 114 | if method_doc is None: 115 | method_doc = MethodDocs(action=action) 116 | 117 | method_doc = attr.evolve(method_doc, path=subpath, method=method) 118 | if not method_doc.docstring: 119 | method_doc.docstring = view_doc 120 | method_doc.docstring = dedent(method_doc.docstring) 121 | method_doc.docstring = self.replace_variables(method_doc.docstring) 122 | method_doc.request_data = self.replace_variables(method_doc.request_data) 123 | method_doc.responses = self.replace_variables(method_doc.responses) 124 | 125 | section_methods[section_name].append(method_doc) 126 | 127 | # Add all gathered methods to their respective sections 128 | for section_name in sections: 129 | sections[section_name].methods = section_methods[section_name] 130 | 131 | return list(sections.values()) 132 | 133 | def get_serializer(self, view): 134 | method = getattr(view, 'get_docs_serializer', None) 135 | if method is not None: 136 | return view.get_docs_serializer() 137 | 138 | serializer_class = getattr(view, 'serializer_docs_class', None) or \ 139 | getattr(view, 'serializer_detail_class', None) or getattr(view, 'serializer_class', None) 140 | if serializer_class is None: 141 | return None 142 | 143 | return serializer_class(context=view.get_serializer_context()) 144 | 145 | def get_generic_serializer_fields(self, view): 146 | """ 147 | Return a list of `FieldDocs` instances corresponding to any 148 | request body input, as determined by the serializer class. 149 | """ 150 | 151 | serializer = self.get_serializer(view) 152 | if serializer is None: 153 | return None 154 | if isinstance(serializer, serializers.ListSerializer): 155 | # TODO? 156 | return None 157 | 158 | if not isinstance(serializer, serializers.Serializer): 159 | return [] 160 | 161 | create_only_fields = getattr(serializer.Meta, 'create_only_fields', []) 162 | fields = [] 163 | for field in serializer.fields.values(): 164 | if field.field_name in {'id', 'url'}: 165 | continue 166 | 167 | # str cast resolves lazy translations 168 | description = '. '.join([str(x) for x in filter(None, (field.label, field.help_text))]) 169 | is_required = field.required and not isinstance(field, ManyRelatedField) 170 | is_create_only = field.field_name in create_only_fields 171 | fields.append(FieldDocs( 172 | name=field.field_name, description=description, 173 | is_required=is_required, is_read_only=field.read_only, is_create_only=is_create_only, 174 | )) 175 | 176 | return fields 177 | 178 | def replace_variables(self, data, **extra_substitutions): 179 | return replace_variables(data, self.site_url, self.base_path, **extra_substitutions) 180 | 181 | 182 | def replace_variables(data, site_url, base_path, **extra_substitutions): 183 | substitutions = { 184 | 'SITE_URL': site_url.rstrip('/'), 185 | 'API_ROOT': (site_url + base_path).rstrip('/'), 186 | } 187 | substitutions.update(extra_substitutions) 188 | return replace_variables_inner(data, substitutions) 189 | 190 | 191 | def replace_variables_inner(data, substitutions): 192 | if isinstance(data, str): 193 | return data % substitutions 194 | elif isinstance(data, (list, tuple)): 195 | return [replace_variables_inner(v, substitutions) for v in data] 196 | elif isinstance(data, dict): 197 | return {k: replace_variables_inner(v, substitutions) for k, v in data.items()} 198 | 199 | return data 200 | 201 | 202 | def generate_api_docs(title, description, site_url, base_path, patterns) -> APIDocs: 203 | generator = ApiDocsGenerator( 204 | title=title, description=description, 205 | site_url=site_url, base_path=base_path, patterns=patterns, 206 | ) 207 | return generator.get_docs() 208 | -------------------------------------------------------------------------------- /tg_apicore/serializers.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Model 2 | 3 | from rest_framework.utils import model_meta 4 | from rest_framework_json_api import serializers 5 | 6 | 7 | class CreateOnlyFieldsSerializerMixin: 8 | """ Adds support for fields that can only be specified at object creation time. 9 | 10 | Serializer fields can be marked as create-only by listing them in Meta.create_only_fields - these are 11 | read-only for existing instances but can be specified at object creation time. 12 | """ 13 | 14 | def __init__(self, *args, **kwargs): 15 | super().__init__(*args, **kwargs) 16 | 17 | # If we have an existing instance, mark the create-only fields as read-only. 18 | if not getattr(self, 'many', False) and isinstance(self.instance, Model) and self.instance.pk is not None: 19 | create_only_fields = getattr(self.Meta, 'create_only_fields', []) 20 | for field_name in create_only_fields: 21 | self.fields[field_name].read_only = True 22 | 23 | 24 | class ModelValidationSerializerMixin: 25 | """ Uses validation logic defined in the model class 26 | 27 | By default, DRF has it's own validation logic, separate from the one defined in the model. 28 | This tries to bridge the two, ensuring that's model's full_clean() also gets called. 29 | 30 | It's quite hacky as there doesn't seem to be a very straightforward way of accomplishing this. 31 | 32 | Also note that it makes an extra database query when validate() is called for an existing instance. 33 | """ 34 | 35 | def validate(self, attrs): 36 | attrs = super().validate(attrs) 37 | 38 | self.run_model_validation(attrs) 39 | 40 | return attrs 41 | 42 | def run_model_validation(self, attrs: dict): 43 | ModelClass = self.Meta.model 44 | 45 | # Remove many-to-many relationships from validated_data. 46 | # They are not valid arguments to the model's `.__init__()` method, 47 | # as they require that the instance has already been saved. 48 | attrs = attrs.copy() 49 | info = model_meta.get_field_info(ModelClass) 50 | for field_name, relation_info in info.relations.items(): 51 | if relation_info.to_many and (field_name in attrs): 52 | attrs.pop(field_name) 53 | 54 | # We don't want to modify self.instance, so either create new instance or fetch the existing one from DB 55 | if self.instance is None: 56 | obj = ModelClass(**attrs) 57 | else: 58 | try: 59 | obj = ModelClass.objects.get(pk=self.instance.pk) 60 | except ModelClass.DoesNotExist: 61 | # If the model cannot be retrieved, just skip the extra validation step 62 | return 63 | 64 | # Update the fetched object with values from attrs 65 | for k, v in attrs.items(): 66 | setattr(obj, k, v) 67 | 68 | # Run model validation logic 69 | obj.full_clean() 70 | 71 | 72 | class BaseModelSerializer(CreateOnlyFieldsSerializerMixin, ModelValidationSerializerMixin, serializers.ModelSerializer): 73 | """ Combines JSON-API model serializer with create-only fields and model validation. 74 | """ 75 | -------------------------------------------------------------------------------- /tg_apicore/settings.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from rest_framework.settings import api_settings 4 | 5 | 6 | DEFAULTS = { 7 | 'REST_FRAMEWORK': { 8 | 'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler', 9 | 10 | 'DEFAULT_PAGINATION_CLASS': 'tg_apicore.pagination.CursorPagination', 11 | 'PAGE_SIZE': 50, 12 | 13 | 'DEFAULT_PARSER_CLASSES': ( 14 | 'tg_apicore.parsers.JSONParser', 15 | 'rest_framework_json_api.parsers.JSONParser', 16 | 'rest_framework.parsers.FormParser', 17 | 'rest_framework.parsers.MultiPartParser' 18 | ), 19 | 'DEFAULT_RENDERER_CLASSES': ( 20 | 'tg_apicore.renderers.JSONRenderer', 21 | 'rest_framework_json_api.renderers.JSONRenderer', 22 | 'rest_framework.renderers.BrowsableAPIRenderer', 23 | ), 24 | 'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata', 25 | 26 | 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning', 27 | 28 | 'SCHEMA_COERCE_METHOD_NAMES': {}, 29 | 30 | 'TEST_REQUEST_DEFAULT_FORMAT': 'json', 31 | 'TEST_REQUEST_RENDERER_CLASSES': ( 32 | 'tg_apicore.renderers.JSONRenderer', 33 | 'rest_framework.renderers.MultiPartRenderer', 34 | ), 35 | }, 36 | 37 | 'JSON_API_FORMAT_TYPES': 'underscore', 38 | } 39 | 40 | INVALID_DRF_CONFIG_MSG = """You must define %(name)s setting in REST_FRAMEWORK settings! 41 | e.g in your settings.py: 42 | 43 | REST_FRAMEWORK = { 44 | # other settings... 45 | %(example)s, 46 | } 47 | """ 48 | 49 | INVALID_DJANGO_CONFIG_MSG = """You must define %(name)s setting in Django settings! 50 | e.g in your settings.py: 51 | 52 | # other settings... 53 | %(example)s 54 | """ 55 | 56 | 57 | def patch_django_settings(): 58 | if not getattr(settings, 'TG_APICORE_PATCH_DRF_SETTINGS', True): 59 | return 60 | 61 | for k, v in DEFAULTS.items(): 62 | current = getattr(settings, k, None) 63 | 64 | if current is None: 65 | setattr(settings, k, v) 66 | continue 67 | 68 | if isinstance(current, dict) and isinstance(v, dict): 69 | for subk, subv in v.items(): 70 | if subk not in current: 71 | current[subk] = subv 72 | 73 | 74 | def invalid_setting_error(name, example_config, msg_template): 75 | return msg_template % { 76 | 'name': name, 77 | 'example': example_config, 78 | } 79 | 80 | 81 | def invalid_drf_setting_error(name, example_config): 82 | return invalid_setting_error(name, example_config, INVALID_DRF_CONFIG_MSG) 83 | 84 | 85 | def invalid_django_setting_error(name, example_config): 86 | return invalid_setting_error(name, example_config, INVALID_DJANGO_CONFIG_MSG) 87 | 88 | 89 | def verify_settings(): 90 | assert api_settings.ALLOWED_VERSIONS is not None, \ 91 | invalid_drf_setting_error('ALLOWED_VERSIONS', "'ALLOWED_VERSIONS': ('2018-01-01',)") 92 | assert len(api_settings.ALLOWED_VERSIONS) >= 1 93 | 94 | assert get_latest_version() in api_settings.ALLOWED_VERSIONS, \ 95 | "Value of API_VERSION_LATEST setting is not among REST_FRAMEWORK's ALLOWED_VERSIONS" 96 | 97 | # If the API_VERSION_LATEST setting isn't defined, do it now to make it easier to access via Django settings. 98 | if not hasattr(settings, 'API_VERSION_LATEST'): 99 | settings.API_VERSION_LATEST = get_latest_version() 100 | 101 | 102 | def get_latest_version() -> str: 103 | return getattr(settings, 'API_VERSION_LATEST', None) or api_settings.ALLOWED_VERSIONS[-1] 104 | -------------------------------------------------------------------------------- /tg_apicore/static/css/tg_apicore.css: -------------------------------------------------------------------------------- 1 | .api-docs-sidebar { 2 | position: fixed; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | width: 225px; 7 | z-index: 1000; 8 | padding: 15px; 9 | overflow-x: hidden; 10 | overflow-y: auto; 11 | /* Scrollable contents if viewport is shorter than content. */ } 12 | .api-docs-sidebar .api-brand { 13 | line-height: 43px; 14 | font-size: 24px; 15 | margin-bottom: 1rem; 16 | font-weight: 600; } 17 | .api-docs-sidebar .toc a { 18 | color: #f8f9fa; } 19 | .api-docs-sidebar .toc ul { 20 | padding-left: 0; 21 | list-style: none; } 22 | .api-docs-sidebar .toc > ul > li > a { 23 | display: none; } 24 | .api-docs-sidebar .toc > ul > li > ul > li > ul { 25 | display: none; } 26 | 27 | .api-docs-main { 28 | margin-left: 225px; } 29 | .api-docs-main .highlight { 30 | margin-bottom: 1rem; 31 | padding: 2px; 32 | font-size: 13px; 33 | background: #EEEEEE; 34 | border: 1px solid #DDDDDD; 35 | border-radius: 3px; } 36 | .api-docs-main a.toclink { 37 | color: inherit; } 38 | 39 | .section-heading { 40 | padding: 15px; 41 | background: #343a40; 42 | color: #f8f9fa; 43 | margin: 2rem 0 1rem; } 44 | .section-heading h2 { 45 | margin-bottom: 0; } 46 | 47 | .section-subheading { 48 | padding: 12px 15px; 49 | background: #868e96; 50 | color: #f8f9fa; 51 | margin-bottom: 1rem; } 52 | .section-subheading h3 { 53 | margin-bottom: 0; } 54 | 55 | .inline-p p { 56 | display: inline; } 57 | 58 | .blockquote { 59 | border-left: 0.25rem solid #ECEEEF; 60 | padding: 0 1rem; } 61 | -------------------------------------------------------------------------------- /tg_apicore/static/sass/_tg_apicore.scss: -------------------------------------------------------------------------------- 1 | $sidebar-width: 225px; 2 | 3 | $gray-100: #f8f9fa !default; 4 | $gray-600: #868e96 !default; 5 | $gray-800: #343a40 !default; 6 | 7 | 8 | .api-docs-sidebar { 9 | position: fixed; 10 | top: 0; 11 | bottom: 0; 12 | left: 0; 13 | width: $sidebar-width; 14 | z-index: 1000; 15 | padding: 15px; 16 | overflow-x: hidden; 17 | overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ 18 | 19 | .api-brand { 20 | line-height: 43px; 21 | font-size: 24px; 22 | margin-bottom: 1rem; 23 | font-weight: 600; 24 | } 25 | 26 | // Table of Contents hacks - this HTML comes from Markdown generator, so we don't have much control over the HTML 27 | // itself. 28 | .toc { 29 | a { 30 | color: $gray-100; 31 | } 32 | 33 | ul { 34 | padding-left: 0; 35 | list-style: none; 36 | } 37 | 38 | // Hide the toplevel link - we have the brand link already. 39 | > ul > li > a { 40 | display: none; 41 | } 42 | 43 | // Hide third level and below as well. This effectively only keeps the second level of heading visible. 44 | > ul > li > ul > li > ul { 45 | display: none; 46 | } 47 | } 48 | } 49 | 50 | .api-docs-main { 51 | margin-left: $sidebar-width; 52 | 53 | .highlight { 54 | margin-bottom: 1rem; 55 | padding: 2px; 56 | font-size: 13px; 57 | background: #EEEEEE; 58 | border: 1px solid #DDDDDD; 59 | border-radius: 3px; 60 | } 61 | 62 | a.toclink { 63 | color: inherit; 64 | } 65 | } 66 | 67 | .section-heading { 68 | padding: 15px; 69 | background: $gray-800; 70 | color: $gray-100; 71 | margin: 2rem 0 1rem; 72 | 73 | h2 { 74 | margin-bottom: 0; 75 | } 76 | } 77 | 78 | .section-subheading { 79 | padding: 12px 15px; 80 | background: $gray-600; 81 | color: $gray-100; 82 | margin-bottom: 1rem; 83 | 84 | h3 { 85 | margin-bottom: 0; 86 | } 87 | } 88 | 89 | .inline-p p { 90 | display: inline; 91 | } 92 | 93 | .blockquote { 94 | border-left: 0.25rem solid #ECEEEF; 95 | padding: 0 1rem; 96 | } 97 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load tg_apicore static %} 4 | 5 | 6 | {% block head_extra %}{{ block.super }} 7 | 8 | 9 | {% endblock head_extra %} 10 | 11 | {% block body_content %} 12 | {% include "tg_apicore/docs/sidebar.html" %} 13 | 14 |
15 |
16 | {% render_markdown api.description %} 17 |
18 | 19 | {% for section in api.sections %} 20 | {% include "tg_apicore/docs/section.html" with section=section %} 21 | 22 | {% for method in section.methods %} 23 | {% include "tg_apicore/docs/method.html" with section=section method=method %} 24 | {% endfor %} 25 | {% endfor %} 26 |
27 | {% endblock body_content %} 28 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/method-python.html: -------------------------------------------------------------------------------- 1 | {% load tg_apicore %} 2 | 3 | 4 | Example request: 5 | 6 |
{% code python %}import requests
 7 | 
 8 | api_root = '{{ api.site_url }}{{ api.base_path }}'
 9 | headers = {
10 |     'Authorization': 'Bearer MY_ACCESS_TOKEN',
11 | }
12 | {% if method.request_data %}
13 | # data to be included in the request body
14 | data = {{ method.request_data_json }}{% endif %}
15 | r = requests.{{ method.method|lower }}(api_root + '{{ method.path }}', headers=headers{% if method.request_data %}, json=data{% endif %})
16 | {% endcode %}
17 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/method.html: -------------------------------------------------------------------------------- 1 | {% load tg_apicore %} 2 | 3 | 4 |
5 |

6 | {{ section.name|capfirst }} 7 | › 8 | {{ method.action }} 9 |

10 |
11 | 12 |
13 |
14 |

15 | {{ method.method }} {{ api.base_path }}{{ method.path }} 16 |

17 | {% render_markdown method.docstring %} 18 |
19 | 20 |
21 | {% include "tg_apicore/docs/method-python.html" with section=section method=method %} 22 | 23 | {% for response_name, response_json in method.responses_items %} 24 | {{ response_name }}: 25 | 26 | {% if response_json is None %} 27 |
No content
28 | {% else %} 29 |
{% code json %}{{ response_json }}{% endcode %}
30 | {% endif %} 31 | {% endfor %} 32 |
33 |
34 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/section.html: -------------------------------------------------------------------------------- 1 | {% load tg_apicore %} 2 | 3 | 4 |
5 |

{{ section.name|capfirst }}

6 |
7 | 8 |
9 |
10 |
11 | {% render_markdown section.docstring %} 12 | 13 | {% if section.fields %} 14 |
Attributes
15 |
    16 | {% for field in section.fields %} 17 |
  • 18 | {{ field.name }} 19 | {% if field.is_create_only %} 20 | Create-only 21 | {% elif field.is_read_only %} 22 | Read-only 23 | {% endif %} 24 | {% if field.is_required %} 25 | Required 26 | {% endif %} 27 |
    28 | {{ field.description }} 29 |
  • 30 | {% endfor %} 31 |
32 | {% endif %} 33 | 34 | {% if section.changelog %} 35 |
Changelog
36 |
    37 | {% for version, changes in section.changelog_items %} 38 |
  • 39 | {{ version }} – 40 | {% render_markdown changes %} 41 |
  • 42 | {% endfor %} 43 |
44 | {% endif %} 45 |
46 | 47 |
48 | {% if section.data %} 49 | Example data: 50 |
{% code json %}{{ section.data_json }}{% endcode %}
51 | {% endif %} 52 |
53 |
54 |
55 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/docs/sidebar.html: -------------------------------------------------------------------------------- 1 | {% load tg_apicore %} 2 | 3 | 4 | 24 | -------------------------------------------------------------------------------- /tg_apicore/templates/tg_apicore/scopes.html: -------------------------------------------------------------------------------- 1 | {% for scope_name, description in scopes %}- **{{ scope_name }}:** {{ description }} 2 | {% endfor %} 3 | -------------------------------------------------------------------------------- /tg_apicore/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thorgate/tg-apicore/5d93ae89efe6537ef0b762dfbc4eafdfdab383cb/tg_apicore/templatetags/__init__.py -------------------------------------------------------------------------------- /tg_apicore/templatetags/tg_apicore.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.safestring import mark_safe 3 | 4 | import markdown 5 | from rest_framework.templatetags.rest_framework import highlight_code 6 | 7 | 8 | register = template.Library() 9 | 10 | register.tag('code', highlight_code) 11 | 12 | 13 | def get_markdown_renderer(): 14 | return markdown.Markdown( 15 | extensions=[ 16 | 'markdown.extensions.codehilite', 17 | 'markdown.extensions.toc', 18 | ], 19 | extension_configs={ 20 | 'markdown.extensions.codehilite': { 21 | 'css_class': 'highlight', 22 | }, 23 | 'markdown.extensions.toc': { 24 | 'anchorlink': True, 25 | }, 26 | }, 27 | ) 28 | 29 | 30 | @register.simple_tag 31 | def render_markdown(markdown_text): 32 | html = get_markdown_renderer().convert(markdown_text) 33 | return mark_safe(html) 34 | 35 | 36 | @register.simple_tag 37 | def render_markdown_toc(markdown_text): 38 | md = get_markdown_renderer() 39 | md.convert(markdown_text) 40 | return mark_safe(md.toc) 41 | -------------------------------------------------------------------------------- /tg_apicore/test.py: -------------------------------------------------------------------------------- 1 | from typing import Set 2 | 3 | from django.conf import settings 4 | from django.utils.encoding import force_bytes 5 | from django.urls import reverse 6 | 7 | from rest_framework.renderers import JSONRenderer as RestJSONRenderer 8 | from rest_framework.response import Response 9 | from rest_framework.test import (APIClient, APILiveServerTestCase, APIRequestFactory, APISimpleTestCase, APITestCase, 10 | APITransactionTestCase) 11 | from rest_framework_json_api.renderers import JSONRenderer as JSONAPIRenderer 12 | 13 | from tg_apicore.schemas import replace_variables 14 | 15 | 16 | class JsonAPIMixin(object): 17 | """Provide custom rest_framework.test.APIRequestFactory._encode_data 18 | 19 | Customization: 20 | If selected renderer is JSONAPIRenderer use rest_framework.renderers.JSONRenderer 21 | to encode input data (and assume it's been correctly structured by the tests) 22 | """ 23 | 24 | def _encode_data(self, data, format=None, content_type=None): # pylint: disable=redefined-builtin 25 | """ 26 | Encode the data returning a two tuple of (bytes, content_type) 27 | """ 28 | 29 | if data is None: 30 | return ('', content_type) 31 | 32 | assert format is None or content_type is None, ( 33 | 'You may not set both `format` and `content_type`.' 34 | ) 35 | 36 | if content_type: 37 | # Content type specified explicitly, treat data as a raw bytestring 38 | ret = force_bytes(data, settings.DEFAULT_CHARSET) 39 | 40 | else: 41 | format = format or self.default_format 42 | 43 | assert format in self.renderer_classes, ( 44 | "Invalid format '{0}'. Available formats are {1}. " 45 | "Set TEST_REQUEST_RENDERER_CLASSES to enable " 46 | "extra request formats.".format( 47 | format, 48 | ', '.join(["'" + fmt + "'" for fmt in self.renderer_classes.keys()]) 49 | ) 50 | ) 51 | 52 | # Use format and render the data into a bytestring 53 | renderer = self.renderer_classes[format]() 54 | 55 | # Customization: assume tests provide input data in correct format 56 | # and just use the standard JSONRenderer to serialize it 57 | if isinstance(renderer, JSONAPIRenderer): 58 | ret = RestJSONRenderer().render(data) 59 | 60 | else: 61 | ret = renderer.render(data) 62 | 63 | # Determine the content-type header from the renderer 64 | content_type = "{0}; charset={1}".format( 65 | renderer.media_type, renderer.charset 66 | ) 67 | 68 | # Coerce text to bytes if required. 69 | if isinstance(ret, str): 70 | ret = bytes(ret.encode(renderer.charset)) 71 | 72 | return ret, content_type 73 | 74 | 75 | class JsonAPIRequestFactory(JsonAPIMixin, APIRequestFactory): 76 | pass 77 | 78 | 79 | class JsonAPIClient(JsonAPIMixin, APIClient): 80 | pass 81 | 82 | 83 | class JsonAPITransactionTestCase(APITransactionTestCase): 84 | client_class = JsonAPIClient 85 | 86 | 87 | class JsonAPITestCase(APITestCase): 88 | client_class = JsonAPIClient 89 | 90 | 91 | class JsonAPISimpleTestCase(APISimpleTestCase): 92 | client_class = JsonAPIClient 93 | 94 | 95 | class JsonAPILiveServerTestCase(APILiveServerTestCase): 96 | client_class = JsonAPIClient 97 | 98 | 99 | class APIClient(JsonAPIClient): 100 | """ Slightly customized DRF APIClient 101 | 102 | - Can force authentication using OAuth2 token. 103 | - Uses json as default format (no need for settings overrides). 104 | - Is somewhat api-version-aware (defaulting to settings.API_VERSION_LATEST). 105 | - Provides version-aware reverse() url helper. 106 | """ 107 | 108 | default_format = 'json' 109 | 110 | def __init__(self, token=None, api_version=None, enforce_csrf_checks=False, **defaults): 111 | super().__init__(enforce_csrf_checks, **defaults) 112 | 113 | if token is not None: 114 | # force_authenticate() doesn't work with OAuth2 115 | self.credentials(HTTP_AUTHORIZATION='Bearer ' + token.token) 116 | 117 | self.api_version = api_version or settings.API_VERSION_LATEST 118 | 119 | def reverse(self, viewname, **kwargs): 120 | reverse_kwargs = {'version': self.api_version} 121 | reverse_kwargs.update(kwargs) 122 | return reverse(viewname, kwargs=reverse_kwargs) 123 | 124 | def replace_variables(self, data): 125 | site_url = 'http://testserver' 126 | docs_version = self.api_version 127 | base_path = '/api/%s/' % docs_version 128 | 129 | return replace_variables(data, site_url, base_path) 130 | 131 | 132 | def validate_keys(obj: dict, required_keys: Set[str], optional_keys: Set[str] = None) -> None: 133 | """ Asserts that obj contains all keys in the required set and nothing that isn't in required + optional. 134 | """ 135 | 136 | obj_keys = set(obj.keys()) 137 | if optional_keys is None: 138 | assert obj_keys == required_keys 139 | else: 140 | assert required_keys.issubset(obj_keys), "Required keys missing: %s" % (required_keys - obj_keys) 141 | allowed_keys = required_keys | optional_keys 142 | assert obj_keys.issubset(allowed_keys), "Extra keys present: %s" % (obj_keys - allowed_keys) 143 | 144 | 145 | def validate_response_status_code(resp: Response, expected_status_code: int = 200): 146 | """ Asserts that response has the given status code. 147 | """ 148 | 149 | assert resp.status_code == expected_status_code, \ 150 | "Unexpected status %d (expected %d): %s" % (resp.status_code, expected_status_code, 151 | getattr(resp, 'data', resp.content)) 152 | 153 | 154 | def validate_jsonapi_error_response(resp: Response, expected_status_code: int): 155 | validate_response_status_code(resp, expected_status_code) 156 | data = resp.json() 157 | 158 | validate_keys(data, {'errors'}) 159 | 160 | 161 | def validate_jsonapi_list_response( 162 | resp: Response, *, expected_count: int = None, 163 | expected_attributes: Set[str] = None, expected_relationships: Set[str] = None 164 | ) -> dict: 165 | """ Asserts that the given response is valid JSON API list response. 166 | """ 167 | 168 | validate_response_status_code(resp, 200) 169 | data = resp.json() 170 | 171 | validate_keys(data, {'data', 'links'}, {'included'}) 172 | items = data['data'] 173 | assert isinstance(items, list) 174 | 175 | if expected_count is not None: 176 | assert len(items) == expected_count 177 | 178 | if expected_attributes is not None: 179 | for item in items: 180 | assert set(item['attributes'].keys()) == expected_attributes 181 | if expected_relationships is not None: 182 | for item in items: 183 | if expected_relationships: 184 | assert set(item['relationships'].keys()) == expected_relationships 185 | else: 186 | assert 'relationships' not in item 187 | 188 | return data 189 | 190 | 191 | def validate_jsonapi_detail_response( 192 | resp: Response, *, expected_status_code: int = 200, 193 | expected_attributes: Set[str] = None, expected_relationships: Set[str] = None 194 | ) -> dict: 195 | """ Asserts that the given response is valid JSON API detail response. 196 | """ 197 | 198 | validate_response_status_code(resp, expected_status_code) 199 | data = resp.json() 200 | 201 | # Response must contain 'data' and may contain 'included' 202 | validate_keys(data, {'data'}, {'included'}) 203 | 204 | object_data = data['data'] 205 | assert isinstance(object_data, dict) 206 | validate_keys(object_data, {'type', 'id', 'attributes', 'links'}, {'relationships'}) 207 | if 'included' in data: 208 | assert isinstance(data['included'], list) 209 | 210 | if expected_attributes is not None: 211 | assert set(object_data['attributes'].keys()) == expected_attributes 212 | if expected_relationships is not None: 213 | if expected_relationships: 214 | assert set(object_data['relationships'].keys()) == expected_relationships 215 | else: 216 | assert 'relationships' not in object_data 217 | 218 | return data 219 | -------------------------------------------------------------------------------- /tg_apicore/transformers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | class VersionTransformer: 8 | """ Base class for transforming API requests/responses between versions 9 | 10 | Each transformer is responsible for converting input (request data) from one version to the next one, 11 | and for converting output (response data) from the next version to this one. 12 | 13 | This way API views/serializers/etc can be written to always target only the latest version, and each earlier version 14 | has one or more transformers that convert data between the two versions. 15 | When the version history gets longer, requests and responses can be transformed by multiple transformers in 16 | sequence. 17 | 18 | Inspired by https://github.com/mrhwick/django-rest-framework-version-transforms and 19 | https://stripe.com/blog/api-versioning 20 | """ 21 | 22 | @classmethod 23 | def is_applicable(cls, model, action) -> bool: 24 | return True 25 | 26 | def __init__(self, model, action) -> None: 27 | super().__init__() 28 | self.model = model 29 | self.action = action 30 | 31 | def input_forwards(self, data): 32 | return data 33 | 34 | def output_backwards(self, data): 35 | return data 36 | 37 | def convert_output_object(self, obj_type, obj_id, fields): 38 | raise NotImplementedError() 39 | 40 | def convert_output_object_container(self, data): 41 | if 'type' not in data or 'id' not in data or 'attributes' not in data: 42 | return data 43 | res = self.convert_output_object(data['type'], data['id'], data['attributes']) 44 | 45 | if res is None: 46 | return data 47 | 48 | data['attributes'] = res 49 | return data 50 | 51 | def convert_all_output_objects(self, response): 52 | data = response.get('data') 53 | 54 | if isinstance(data, dict): 55 | response['data'] = self.convert_output_object_container(data) 56 | elif isinstance(data, (list, tuple)): 57 | response['data'] = [self.convert_output_object_container(obj) for obj in data] 58 | else: 59 | return response 60 | 61 | if 'included' in response: 62 | response['included'] = [self.convert_output_object_container(obj) for obj in response['included']] 63 | 64 | return response 65 | 66 | 67 | # TODO: move out of here 68 | TRANSFORMS = [ 69 | ] 70 | 71 | 72 | def get_transformers(request_version, model, action): 73 | transformers = [] 74 | for version, version_transformers in TRANSFORMS: 75 | if version <= request_version: 76 | break 77 | for t in version_transformers: 78 | if t.is_applicable(model, action): 79 | transformers.append(t) 80 | 81 | return [t(model, action) for t in transformers] 82 | -------------------------------------------------------------------------------- /tg_apicore/views.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.views.generic.base import TemplateView 3 | 4 | from rest_framework.compat import pygments_css 5 | from rest_framework.exceptions import NotFound 6 | from rest_framework.views import APIView 7 | 8 | from tg_apicore.schemas import generate_api_docs 9 | 10 | 11 | class APIDocumentationView(TemplateView): 12 | """ API documentation view 13 | 14 | Subclass it, set title and description attributes and implement the three get_*() methods. 15 | """ 16 | 17 | template_name = 'tg_apicore/docs/index.html' 18 | # Pygments code style to use. Go to http://pygments.org/demo/ , select an example an 19 | # you'll have a dropdown of style options on the right. 20 | code_style = 'emacs' 21 | 22 | title = "API" 23 | description = "" 24 | 25 | def generate_docs(self): 26 | return generate_api_docs( 27 | title=self.title, description=self.get_description(), 28 | site_url=self.get_site_url(), base_path=self.get_base_path(), patterns=self.urlpatterns(), 29 | ) 30 | 31 | def get_context_data(self, **kwargs): 32 | context = super().get_context_data(**kwargs) 33 | 34 | docs = self.generate_docs() 35 | context.update({ 36 | 'api': docs, 37 | 'code_style': pygments_css(self.code_style), 38 | }) 39 | 40 | return context 41 | 42 | def get_description(self) -> str: 43 | return self.description 44 | 45 | def get_site_url(self) -> str: 46 | """ Should return your site's url without path, e.g. https://example.com/ """ 47 | raise NotImplementedError() 48 | 49 | def get_base_path(self) -> str: 50 | """ Should return your API's base path (path prefix), e.g. /api/v1/ """ 51 | raise NotImplementedError() 52 | 53 | def urlpatterns(self) -> list: 54 | """ Should return urlpatterns of your API """ 55 | raise NotImplementedError() 56 | 57 | 58 | class PageNotFoundView(APIView): 59 | """ 404 view for API urls. 60 | 61 | Django's standard 404 page returns HTML. We want everything under API url prefix to return 404 as JSON. 62 | """ 63 | 64 | authentication_classes = () 65 | permission_classes = () 66 | 67 | @classmethod 68 | def urlpatterns(cls): 69 | return [ 70 | # This one is for when the version is valid 71 | url(r'^(?P(\d{4}-\d{2}-\d{2}))/', cls.as_view()), 72 | # This one is catch-all for everything else, including invalid versions 73 | url(r'^', cls.as_view()), 74 | ] 75 | 76 | def initial(self, request, *args, **kwargs): 77 | # Overriding initial() seems to be like the easiest way that still keeps most of DRF's logic ala renderers. 78 | super().initial(request, *args, **kwargs) 79 | 80 | raise NotFound() 81 | -------------------------------------------------------------------------------- /tg_apicore/viewsets.py: -------------------------------------------------------------------------------- 1 | from django.db.models import QuerySet 2 | 3 | from rest_framework.generics import GenericAPIView 4 | from rest_framework.permissions import SAFE_METHODS 5 | 6 | 7 | class DetailSerializerViewSet(GenericAPIView): 8 | """ Use different serializers and querysets for list / detail / modify views. 9 | 10 | This is basically extended variant of DetailSerializerMixin from drf-extensions. 11 | 12 | It provides additional queryset/serializer options for unsafe methods (`*_modify`) and makes it easy to override 13 | methods that return serializer classes / querysets so that you can add your own logic with minimal effort. 14 | 15 | The detail / modify variants of queryset / serializer are optional and fall back to each other in 16 | modify -> detail -> list order. 17 | """ 18 | 19 | ENDPOINT_TYPE_LIST = 1 20 | ENDPOINT_TYPE_DETAIL = 2 21 | ENDPOINT_TYPE_MODIFY = 3 22 | 23 | serializer_detail_class = None 24 | serializer_modify_class = None 25 | queryset_detail = None 26 | queryset_modify = None 27 | 28 | def get_endpoint_type(self): 29 | """ Selects endpoint type of the current request - this will be used to select serializer and queryset. 30 | """ 31 | 32 | if self.request and self.request.method not in SAFE_METHODS: 33 | return self.ENDPOINT_TYPE_MODIFY 34 | 35 | if hasattr(self, 'lookup_url_kwarg'): 36 | lookup = self.lookup_url_kwarg or self.lookup_field 37 | if lookup and lookup in self.kwargs: 38 | return self.ENDPOINT_TYPE_DETAIL 39 | 40 | return self.ENDPOINT_TYPE_LIST 41 | 42 | def get_serializer_class(self): 43 | """ Selects serializer class, based on current request's endpoint type. 44 | """ 45 | 46 | endpoint_type = self.get_endpoint_type() 47 | 48 | if endpoint_type == self.ENDPOINT_TYPE_MODIFY: 49 | return self.get_modify_serializer_class() 50 | elif endpoint_type == self.ENDPOINT_TYPE_DETAIL: 51 | return self.get_detail_serializer_class() 52 | 53 | return self.get_list_serializer_class() 54 | 55 | def get_docs_serializer(self): 56 | """ Returns serializer instance used to generate documentation. 57 | 58 | This defaults to the modify-serializer. 59 | """ 60 | 61 | serializer_cls = self.get_modify_serializer_class() 62 | return serializer_cls(context=self.get_serializer_context()) 63 | 64 | def get_queryset(self): 65 | """ Selects queryset, based on current request's endpoint type. 66 | """ 67 | 68 | assert self.queryset is not None, ( 69 | "'%s' should either include a `queryset` attribute, " 70 | "or override the `get_queryset()` method." 71 | % self.__class__.__name__ 72 | ) 73 | 74 | endpoint_type = self.get_endpoint_type() 75 | 76 | if endpoint_type == self.ENDPOINT_TYPE_MODIFY: 77 | queryset = self.get_modify_queryset() 78 | elif endpoint_type == self.ENDPOINT_TYPE_DETAIL: 79 | queryset = self.get_detail_queryset() 80 | else: 81 | queryset = self.get_list_queryset() 82 | 83 | if isinstance(queryset, QuerySet): 84 | # Ensure queryset is re-evaluated on each request. 85 | queryset = queryset.all() 86 | return queryset 87 | 88 | def get_list_serializer_class(self): 89 | return self.serializer_class 90 | 91 | def get_detail_serializer_class(self): 92 | return self.serializer_detail_class or self.get_list_serializer_class() 93 | 94 | def get_modify_serializer_class(self): 95 | return self.serializer_modify_class or self.get_detail_serializer_class() 96 | 97 | def get_list_queryset(self): 98 | return self.queryset 99 | 100 | def get_detail_queryset(self): 101 | return self.queryset_detail or self.get_list_queryset() 102 | 103 | def get_modify_queryset(self): 104 | return self.queryset_modify or self.get_detail_queryset() 105 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, flake8 3 | 4 | [travis] 5 | python = 6 | 3.6: py36 7 | 3.5: py35 8 | 9 | [testenv:flake8] 10 | basepython = python 11 | deps = flake8 12 | commands = flake8 tg_apicore 13 | 14 | [testenv] 15 | setenv = 16 | PYTHONPATH = {toxinidir} 17 | deps = 18 | -r{toxinidir}/requirements_dev.txt 19 | ; If you want to make tox run the tests with the same versions, create a 20 | ; requirements.txt with the pinned versions and uncomment the following line: 21 | ; -r{toxinidir}/requirements.txt 22 | commands = 23 | pip install -U pip 24 | pytest --basetemp={envtmpdir} 25 | pytest --basetemp={envtmpdir} example/ 26 | --------------------------------------------------------------------------------