├── .editorconfig ├── .github └── ISSUE_TEMPLATE.md ├── .gitignore ├── .gitmodules ├── .idea ├── aiohttp_json_api.iml ├── misc.xml ├── modules.xml └── vcs.xml ├── .readthedocs.yml ├── .travis.yml ├── .vscode └── settings.json ├── AUTHORS.rst ├── CONTRIBUTING.rst ├── HISTORY.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── Pipfile ├── README.rst ├── aiohttp_json_api ├── __init__.py ├── abc │ ├── __init__.py │ ├── contoller.py │ ├── field.py │ ├── processors.py │ └── schema.py ├── common.py ├── context.py ├── controller.py ├── encoder.py ├── errors.py ├── fields │ ├── __init__.py │ ├── attributes.py │ ├── base.py │ ├── decorators.py │ ├── relationships.py │ └── trafarets.py ├── handlers.py ├── helpers.py ├── jsonpointer.py ├── middleware.py ├── pagination.py ├── registry.py ├── schema.py ├── typings.py └── utils.py ├── docs ├── Makefile ├── _static │ ├── logo-1024x1024.png │ └── logo.svg ├── aiohttp_json_api.abc.rst ├── aiohttp_json_api.fields.rst ├── aiohttp_json_api.rst ├── api.rst ├── authors.rst ├── conf.py ├── contributing.rst ├── history.rst ├── index.rst ├── installation.rst ├── make.bat ├── readme.rst └── usage.rst ├── examples ├── fantasy │ ├── __init__.py │ ├── controllers.py │ ├── docker-compose.yml │ ├── main.py │ ├── models.py │ ├── schemas.py │ ├── tables.py │ └── tasks.py └── simple │ ├── __init__.py │ ├── controllers.py │ ├── main.py │ ├── models.py │ └── schemas.py ├── punch_config.py ├── punch_version.py ├── requirements.txt ├── requirements_dev.txt ├── setup.cfg ├── setup.py ├── tests ├── conftest.py ├── integration │ ├── index.md │ ├── normative-statements.json │ ├── schema.dms │ ├── test_content_negotiation.py │ ├── test_creating.py │ ├── test_deleting.py │ ├── test_document_structure.py │ ├── test_errors.py │ ├── test_query_parameters.py │ ├── test_reading.py │ └── test_updating.py ├── schema │ ├── test_base_fields.py │ └── test_trafarets.py └── spec │ └── test_spec_schema.py ├── tox.ini └── travis_pypi_setup.py /.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 | [*.yml,*.yaml] 18 | indent_size = 2 19 | 20 | [LICENSE] 21 | insert_final_newline = false 22 | 23 | [Makefile] 24 | indent_style = tab 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * aiohttp JSON API 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 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | .pytest_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 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # pyenv python configuration file 63 | .python-version 64 | 65 | Pipfile.lock 66 | 67 | .mypy_cache/ 68 | 69 | ### VisualStudioCode template 70 | .vscode/* 71 | !.vscode/settings.json 72 | !.vscode/tasks.json 73 | !.vscode/launch.json 74 | !.vscode/extensions.json 75 | ### JetBrains template 76 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 77 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 78 | 79 | # User-specific stuff: 80 | .idea/**/workspace.xml 81 | .idea/**/tasks.xml 82 | .idea/dictionaries 83 | 84 | # Sensitive or high-churn files: 85 | .idea/**/dataSources/ 86 | .idea/**/dataSources.ids 87 | .idea/**/dataSources.xml 88 | .idea/**/dataSources.local.xml 89 | .idea/**/sqlDataSources.xml 90 | .idea/**/dynamic.xml 91 | .idea/**/uiDesigner.xml 92 | 93 | # Gradle: 94 | .idea/**/gradle.xml 95 | .idea/**/libraries 96 | 97 | # CMake 98 | cmake-build-debug/ 99 | cmake-build-release/ 100 | 101 | # Mongo Explorer plugin: 102 | .idea/**/mongoSettings.xml 103 | 104 | ## File-based project format: 105 | *.iws 106 | 107 | ## Plugin-specific files: 108 | 109 | # IntelliJ 110 | out/ 111 | 112 | # mpeltonen/sbt-idea plugin 113 | .idea_modules/ 114 | 115 | # JIRA plugin 116 | atlassian-ide-plugin.xml 117 | 118 | # Cursive Clojure plugin 119 | .idea/replstate.xml 120 | 121 | # Crashlytics plugin (for Android Studio and IntelliJ) 122 | com_crashlytics_export_strings.xml 123 | crashlytics.properties 124 | crashlytics-build.properties 125 | fabric.properties 126 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples/fantasy/fantasy-database"] 2 | path = examples/fantasy/fantasy-database 3 | url = https://github.com/endpoints/fantasy-database.git 4 | -------------------------------------------------------------------------------- /.idea/aiohttp_json_api.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | formats: 2 | - htmlzip 3 | - pdf 4 | - epub 5 | 6 | requirements_file: requirements_dev.txt 7 | 8 | build: 9 | image: latest 10 | 11 | python: 12 | version: 3.6 13 | pip_install: true 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # This file was autogenerated and will overwrite each time you run travis_pypi_setup.py 2 | deploy: 3 | provider: pypi 4 | distributions: sdist bdist_wheel 5 | user: vovanbo 6 | password: 7 | secure: !!binary | 8 | RktKL3h5bjVsL2Y3elVXRk1wdDZwWFdyN3pOblh0bDNmbjQ5YzBuWkF3NEV4QmRNczJ1SlJFbklq 9 | SDRqLytBZUdRMUdmM3hCTjBNTjlTUW1sdzBoZXFUVW12eEtDWkJrTW9CQnFiMzFuVlJJUXpxMUFj 10 | NmxGQ0oxVXduRXFrclN5ME15SmJzNHVEbGdyTHRNaGNDQ05LOXBxN3Z5cXFoZ3MxY1VqVTRLMG03 11 | VWtRMHJCWXZweXIwSy9TRDdLUURJUVpsL3p1dlQySTMvTHJJWjBjN1RBSkZUZ1R6UFVXSTQ1RDFC 12 | NC9YQnQyYjlxZVgwZ2pteVQ1LzJSQU9NU0s1MEJzQ1RIc1YzZ0lCdXMwcWpmVkloZmI5VHBUYmhr 13 | TDZWK2Rja1NOUWJ1TkhpcEJlaE56M1FSVWlaeGJjbHVjZXR3ODVoRTZBRFY4dzF1Zklpa0FkWUEr 14 | RXpFTUVXVlJoMkYrRjE2bS9NSVF3cjVIdW1wMk1rUkN3RE4zd21jUWh3TDZ6VC9HNlJxbmxxOHFJ 15 | QVR0Vm13VE1TZ3NkdGUyRktPLzVQVTYrR3JOUDBnZXpJdHJ5dHRFZ0dzUlNTY0xrSGxIRUg1eEZS 16 | cDVHRDdXN0gyeDVXaWRNdkR4ODRGelhFTTQvazk5T3JhL0svclJEblBxSktpUHNrbS9RSXl5YnZT 17 | ZEZWWlc3dkUxWUVuWnNvL3RTR2xYV2pVMUZoL0duMmxhaElMaXJQTk9qT0g3VW1NUWZnWUwxMysv 18 | VFRJV2JXSXhrTGF4eFNWNENIMDI4ZmZGWTFZc3FGaXJ2cW10VG1tOWdSQ3I5bHZGb05oTzUxeEJL 19 | ZUZYL2ZYdkUwdTFtZUJwckRCTk9kMllGNDB4a2pLTTFCTUVQOW1VVGFVaUgwTGxqTnlOclpMNmc9 20 | true: 21 | condition: $TOXENV == py36 22 | repo: vovanbo/aiohttp_json_api 23 | tags: true 24 | env: 25 | - TOXENV=py36 26 | install: pip install -U tox 27 | language: python 28 | python: 3.6 29 | script: tox -e ${TOXENV} 30 | 31 | services: 32 | - docker 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/Users/bo/.pyenv/versions/aiohttp_json_api/bin/python", 3 | "search.exclude": { 4 | "**/node_modules": true, 5 | "**/bower_components": true, 6 | "**/.mypy_cache": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * `Vladimir Bolshakov`_ 9 | 10 | Author of core idea 11 | ------------------- 12 | 13 | * `Benedikt Schmitt`_ 14 | 15 | Contributors 16 | ------------ 17 | 18 | None yet. Why not be the first? 19 | 20 | 21 | .. _`Vladimir Bolshakov`: https://github.com/vovanbo 22 | .. _`Benedikt Schmitt`: https://github.com/benediktschmitt 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every 8 | little bit 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/vovanbo/aiohttp_json_api/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" 30 | and "help 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 | aiohttp JSON API could always use more documentation, whether as part of the 42 | official aiohttp JSON API 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/vovanbo/aiohttp_json_api/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 `aiohttp_json_api` for local development. 61 | 62 | 1. Fork the `aiohttp_json_api` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/aiohttp_json_api.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 aiohttp_json_api 70 | $ cd aiohttp_json_api/ 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 tests, including testing other Python versions with tox:: 80 | 81 | $ flake8 aiohttp_json_api tests 82 | $ python setup.py test or py.test 83 | $ tox 84 | 85 | To get flake8 and tox, just pip install them into your virtualenv. 86 | 87 | 6. Commit your changes and push your branch to GitHub:: 88 | 89 | $ git add . 90 | $ git commit -m "Your detailed description of your changes." 91 | $ git push origin name-of-your-bugfix-or-feature 92 | 93 | 7. Submit a pull request through the GitHub website. 94 | 95 | Pull Request Guidelines 96 | ----------------------- 97 | 98 | Before you submit a pull request, check that it meets these guidelines: 99 | 100 | 1. The pull request should include tests. 101 | 2. If the pull request adds functionality, the docs should be updated. Put 102 | your new functionality into a function with a docstring, and add the 103 | feature to the list in README.rst. 104 | 3. The pull request should work for Python 3.6 and later. Check 105 | https://travis-ci.org/vovanbo/aiohttp_json_api/pull_requests 106 | and make sure that the tests pass for all supported Python versions. 107 | 108 | Tips 109 | ---- 110 | 111 | To run a subset of tests:: 112 | 113 | $ py.test tests.test_aiohttp_json_api 114 | 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2017, Vladimir Bolshakov 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | 2 | include AUTHORS.rst 3 | 4 | include CONTRIBUTING.rst 5 | include HISTORY.rst 6 | include LICENSE 7 | include README.rst 8 | 9 | recursive-include tests * 10 | recursive-exclude * __pycache__ 11 | recursive-exclude * *.py[co] 12 | 13 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | define BROWSER_PYSCRIPT 4 | import os, webbrowser, sys 5 | from urllib.request import pathname2url 6 | 7 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 8 | endef 9 | export BROWSER_PYSCRIPT 10 | 11 | define PRINT_HELP_PYSCRIPT 12 | import re, sys 13 | 14 | for line in sys.stdin: 15 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 16 | if match: 17 | target, help = match.groups() 18 | print("%-20s %s" % (target, help)) 19 | endef 20 | export PRINT_HELP_PYSCRIPT 21 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 22 | 23 | help: 24 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 25 | 26 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 27 | 28 | 29 | clean-build: ## remove build artifacts 30 | rm -fr build/ 31 | rm -fr dist/ 32 | rm -fr .eggs/ 33 | find . -name '*.egg-info' -exec rm -fr {} + 34 | find . -name '*.egg' -exec rm -f {} + 35 | 36 | clean-pyc: ## remove Python file artifacts 37 | find . -name '*.pyc' -exec rm -f {} + 38 | find . -name '*.pyo' -exec rm -f {} + 39 | find . -name '*~' -exec rm -f {} + 40 | find . -name '__pycache__' -exec rm -fr {} + 41 | 42 | clean-test: ## remove test and coverage artifacts 43 | rm -fr .tox/ 44 | rm -f .coverage 45 | rm -fr htmlcov/ 46 | 47 | lint: ## check style with flake8 48 | flake8 aiohttp_json_api tests 49 | 50 | test: ## run tests quickly with the default Python 51 | py.test 52 | 53 | 54 | test-all: ## run tests on every Python version with tox 55 | tox 56 | 57 | coverage: ## check code coverage quickly with the default Python 58 | coverage run --source aiohttp_json_api -m pytest 59 | 60 | coverage report -m 61 | coverage html 62 | $(BROWSER) htmlcov/index.html 63 | 64 | docs: ## generate Sphinx HTML documentation, including API docs 65 | rm -f docs/aiohttp_json_api.rst 66 | rm -f docs/modules.rst 67 | sphinx-apidoc --no-headings --no-toc -o docs/ aiohttp_json_api 68 | $(MAKE) -C docs clean 69 | $(MAKE) -C docs html 70 | $(BROWSER) docs/_build/html/index.html 71 | 72 | servedocs: docs ## compile the docs watching for changes 73 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 74 | 75 | release: clean ## package and upload a release 76 | python setup.py sdist upload 77 | python setup.py bdist_wheel upload 78 | 79 | dist: clean ## builds source and wheel package 80 | python setup.py sdist 81 | python setup.py bdist_wheel 82 | ls -l dist 83 | 84 | install: clean ## install the package to the active Python's site-packages 85 | python setup.py install 86 | 87 | pipenv-update: 88 | pipenv update -d 89 | pipenv lock 90 | 91 | generate-requirements: 92 | pipenv lock -r 1> requirements.txt 2> /dev/null 93 | pipenv lock -r -d 1> requirements_dev.txt 2> /dev/null 94 | awk 'NR==1{printf "-r requirements.txt\n"}RS{print $$0}' requirements_dev.txt > tmp && mv tmp requirements_dev.txt 95 | 96 | update-requirements: pipenv-update generate-requirements 97 | 98 | simple-example: 99 | PYTHONPATH=./examples/simple ./examples/simple/main.py 100 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | 3 | url = "https://pypi.python.org/simple" 4 | verify_ssl = true 5 | name = "pypi" 6 | 7 | 8 | [packages] 9 | 10 | aiohttp = ">=2.0.0" 11 | inflection = ">=0.3.1" 12 | jsonpointer = ">=1.10" 13 | python-dateutil = ">=2.6.0" 14 | trafaret = ">=1.0.2" 15 | multidict = ">=3.3.0" 16 | yarl = ">=0.13.0" 17 | python-mimeparse = ">=1.6.0" 18 | 19 | 20 | [dev-packages] 21 | 22 | alabaster = "*" 23 | coverage = "*" 24 | cryptography = "*" 25 | "flake8" = "*" 26 | "flake8-docstrings" = "*" 27 | "punch.py" = "*" 28 | pytest = "*" 29 | pyyaml = "*" 30 | sphinx = "*" 31 | sphinx-autodoc-typehints = "*" 32 | tox = "*" 33 | twine = "*" 34 | watchdog = "*" 35 | pylint = "*" 36 | mimesis = "*" 37 | "pep8" = "*" 38 | "autopep8" = "*" 39 | mypy = "*" 40 | tox-pyenv = "*" 41 | aiopg = "*" 42 | sqlalchemy = "*" 43 | docker = "*" 44 | invoke = "*" 45 | attrs = "*" 46 | jsonschema = "*" 47 | aiohttp = "*" 48 | pytest-aiohttp = "*" 49 | "flake8-import-order" = "*" 50 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ======================================= 2 | `JSON API`_ implementation for aiohttp_ 3 | ======================================= 4 | 5 | 6 | .. image:: https://img.shields.io/pypi/v/aiohttp_json_api.svg 7 | :target: https://pypi.python.org/pypi/aiohttp_json_api 8 | 9 | .. image:: https://img.shields.io/travis/vovanbo/aiohttp_json_api.svg 10 | :target: https://travis-ci.org/vovanbo/aiohttp_json_api 11 | 12 | .. image:: https://readthedocs.org/projects/aiohttp-json-api/badge/?version=latest 13 | :target: https://aiohttp-json-api.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | 16 | .. image:: https://pyup.io/repos/github/vovanbo/aiohttp_json_api/shield.svg 17 | :target: https://pyup.io/repos/github/vovanbo/aiohttp_json_api/ 18 | :alt: Updates 19 | 20 | 21 | Introduction 22 | ------------ 23 | 24 | This project heavily inspired by py-jsonapi_ (author is `Benedikt Schmitt`_). 25 | Some parts of this project is improved and refactored dev-schema_ branch 26 | of **py-jsonapi**. At beginning of aiohttp-json-api_ this branch 27 | was a great, but not finished implementation of JSON API with 28 | *schema controllers*. Also, py-jsonapi_ is not asynchronous and use inside 29 | self-implemented Request/Response classes. 30 | 31 | Some of base entities of py-jsonapi_ was replaced with aiohttp_ 32 | server's objects, some of it was divided into new separate entities 33 | (e.g. `JSONAPIContext` or `Registry`). 34 | 35 | * Free software: MIT license 36 | * Documentation: https://aiohttp-json-api.readthedocs.io 37 | 38 | 39 | Requirements 40 | ------------ 41 | 42 | * **Python 3.6** or newer 43 | * aiohttp_ 44 | * inflection_ 45 | * multidict_ 46 | * jsonpointer_ 47 | * dateutil_ 48 | * trafaret_ 49 | * python-mimeparse_ 50 | 51 | 52 | Todo 53 | ---- 54 | 55 | * Tutorials 56 | * Improve documentation 57 | * Tests 58 | * Features description 59 | * Customizable payload keys inflection (default is `dasherize` <-> `underscore`) 60 | * Support for JSON API extensions (bulk creation etc.) 61 | * Polymorphic relationships 62 | 63 | 64 | Credits 65 | ------- 66 | 67 | This package was created with Cookiecutter_ and the 68 | `cookiecutter-pypackage`_ project template. 69 | 70 | 71 | .. _aiohttp-json-api: https://github.com/vovanbo/aiohttp_json_api 72 | .. _Cookiecutter: https://github.com/audreyr/cookiecutter 73 | .. _cookiecutter-pypackage: https://github.com/audreyr/cookiecutter-pypackage 74 | .. _JSON API: http://jsonapi.org 75 | .. _aiohttp: http://aiohttp.readthedocs.io/en/stable/ 76 | .. _py-jsonapi: https://github.com/benediktschmitt/py-jsonapi 77 | .. _dev-schema: https://github.com/benediktschmitt/py-jsonapi/tree/dev-schema 78 | .. _`Benedikt Schmitt`: https://github.com/benediktschmitt 79 | .. _inflection: https://inflection.readthedocs.io/en/latest/ 80 | .. _jsonpointer: https://python-json-pointer.readthedocs.io/en/latest/index.html 81 | .. _dateutil: https://dateutil.readthedocs.io/en/stable/ 82 | .. _trafaret: http://trafaret.readthedocs.io/en/latest/ 83 | .. _multidict: http://multidict.readthedocs.io/en/stable/ 84 | .. _python-mimeparse: https://pypi.python.org/pypi/python-mimeparse 85 | -------------------------------------------------------------------------------- /aiohttp_json_api/__init__.py: -------------------------------------------------------------------------------- 1 | """JSON API implementation for aiohttp.""" 2 | 3 | import inspect 4 | from collections import MutableMapping, Sequence 5 | 6 | __author__ = """Vladimir Bolshakov""" 7 | __email__ = 'vovanbo@gmail.com' 8 | __version__ = '0.37.0' 9 | 10 | 11 | def setup_app_registry(app, registry_class, config): 12 | """Set up JSON API application registry.""" 13 | from .common import ALLOWED_MEMBER_NAME_REGEX, logger, JSONAPI 14 | from .registry import Registry 15 | from .abc.schema import SchemaABC 16 | from .abc.contoller import ControllerABC 17 | 18 | if registry_class is not None: 19 | if not issubclass(registry_class, Registry): 20 | raise TypeError(f'Subclass of Registry is required. ' 21 | f'Got: {registry_class}') 22 | else: 23 | registry_class = Registry 24 | 25 | app_registry = registry_class() 26 | 27 | for schema_cls, controller_cls in config.items(): 28 | resource_type = schema_cls.opts.resource_type 29 | resource_cls = schema_cls.opts.resource_cls 30 | 31 | if not inspect.isclass(controller_cls): 32 | raise TypeError('Class (not instance) of controller is required.') 33 | 34 | if not issubclass(controller_cls, ControllerABC): 35 | raise TypeError(f'Subclass of ControllerABC is required. ' 36 | f'Got: {controller_cls}') 37 | 38 | if not inspect.isclass(schema_cls): 39 | raise TypeError('Class (not instance) of schema is required.') 40 | 41 | if not issubclass(schema_cls, SchemaABC): 42 | raise TypeError(f'Subclass of SchemaABC is required. ' 43 | f'Got: {schema_cls}') 44 | 45 | if not inspect.isclass(schema_cls.opts.resource_cls): 46 | raise TypeError('Class (not instance) of resource is required.') 47 | 48 | if not ALLOWED_MEMBER_NAME_REGEX.fullmatch(resource_type): 49 | raise ValueError(f"Resource type '{resource_type}' is not allowed.") 50 | 51 | app_registry[resource_type] = schema_cls, controller_cls 52 | app_registry[resource_cls] = schema_cls, controller_cls 53 | 54 | logger.debug( 55 | 'Registered %r ' 56 | '(schema: %r, resource class: %r, type %r)', 57 | controller_cls.__name__, schema_cls.__name__, 58 | resource_cls.__name__, resource_type 59 | ) 60 | 61 | return app_registry 62 | 63 | 64 | def setup_custom_handlers(custom_handlers): 65 | """Set up default and custom handlers for JSON API application.""" 66 | from . import handlers as default_handlers 67 | from .common import logger 68 | 69 | handlers = { 70 | name: handler 71 | for name, handler in inspect.getmembers(default_handlers, 72 | inspect.iscoroutinefunction) 73 | if name in default_handlers.__all__ 74 | } 75 | if custom_handlers is not None: 76 | if isinstance(custom_handlers, MutableMapping): 77 | custom_handlers_iter = custom_handlers.items() 78 | elif isinstance(custom_handlers, Sequence): 79 | custom_handlers_iter = ((c.__name__, c) for c in custom_handlers) 80 | else: 81 | raise TypeError('Wrong type of "custom_handlers" parameter. ' 82 | 'Mapping or Sequence is expected.') 83 | 84 | for name, custom_handler in custom_handlers_iter: 85 | handler_name = custom_handler.__name__ 86 | if name not in handlers: 87 | logger.warning('Custom handler %s is ignored.', name) 88 | continue 89 | if not inspect.iscoroutinefunction(custom_handler): 90 | logger.error('"%s" is not a co-routine function (ignored).', 91 | handler_name) 92 | continue 93 | 94 | handlers[name] = custom_handler 95 | logger.debug('Default handler "%s" is replaced ' 96 | 'with co-routine "%s" (%s)', 97 | name, handler_name, inspect.getmodule(custom_handler)) 98 | return handlers 99 | 100 | 101 | def setup_resources(app, base_path, handlers, routes_namespace): 102 | """Set up JSON API application resources.""" 103 | from .common import ALLOWED_MEMBER_NAME_RULE 104 | 105 | type_part = '{type:' + ALLOWED_MEMBER_NAME_RULE + '}' 106 | relation_part = '{relation:' + ALLOWED_MEMBER_NAME_RULE + '}' 107 | collection_resource = app.router.add_resource( 108 | f'{base_path}/{type_part}', 109 | name=f'{routes_namespace}.collection' 110 | ) 111 | resource_resource = app.router.add_resource( 112 | f'{base_path}/{type_part}/{{id}}', 113 | name=f'{routes_namespace}.resource' 114 | ) 115 | relationships_resource = app.router.add_resource( 116 | f'{base_path}/{type_part}/{{id}}/relationships/{relation_part}', 117 | name=f'{routes_namespace}.relationships' 118 | ) 119 | related_resource = app.router.add_resource( 120 | f'{base_path}/{type_part}/{{id}}/{relation_part}', 121 | name=f'{routes_namespace}.related' 122 | ) 123 | collection_resource.add_route('GET', handlers['get_collection']) 124 | collection_resource.add_route('POST', handlers['post_resource']) 125 | resource_resource.add_route('GET', handlers['get_resource']) 126 | resource_resource.add_route('PATCH', handlers['patch_resource']) 127 | resource_resource.add_route('DELETE', handlers['delete_resource']) 128 | relationships_resource.add_route('GET', handlers['get_relationship']) 129 | relationships_resource.add_route('POST', handlers['post_relationship']) 130 | relationships_resource.add_route('PATCH', handlers['patch_relationship']) 131 | relationships_resource.add_route('DELETE', handlers['delete_relationship']) 132 | related_resource.add_route('GET', handlers['get_related']) 133 | 134 | 135 | def setup_jsonapi(app, config, *, base_path='/api', version='1.0', 136 | meta=None, context_cls=None, registry_class=None, 137 | custom_handlers=None, log_errors=True, 138 | routes_namespace=None): 139 | """ 140 | Set up JSON API in aiohttp application. 141 | 142 | This function will setup resources, handlers and middleware. 143 | 144 | :param ~aiohttp.web.Application app: 145 | Application instance 146 | :param ~typing.Sequence[DefaultController] controllers: 147 | List of controllers to register in JSON API 148 | :param str base_path: 149 | Prefix of JSON API routes paths 150 | :param str version: 151 | JSON API version (used in ``jsonapi`` key of document) 152 | :param dict meta: 153 | Meta information will added to response (``meta`` key of document) 154 | :param context_cls: 155 | Override of JSONAPIContext class 156 | (must be subclass of :class:`~aiohttp_json_api.context.JSONAPIContext`) 157 | :param registry_class: 158 | Override of Registry class 159 | (must be subclass of :class:`~aiohttp_json_api.registry.Registry`) 160 | :param custom_handlers: 161 | Sequence or mapping with overrides of default JSON API handlers. 162 | 163 | If your custom handlers named in conform with convention 164 | of this application, then pass it as sequence:: 165 | 166 | custom_handlers=(get_collection, patch_resource) 167 | 168 | If you have custom name of these handlers, then pass it as mapping:: 169 | 170 | custom_handlers={ 171 | 'get_collection': some_handler_for_get_collection, 172 | 'patch_resource': another_handler_to_patch_resource 173 | } 174 | 175 | :param bool log_errors: 176 | Log errors handled by 177 | :func:`~aiohttp_json_api.middleware.jsonapi_middleware` 178 | :param str routes_namespace: 179 | Namespace of JSON API application routes 180 | :return: 181 | aiohttp Application instance with configured JSON API 182 | :rtype: ~aiohttp.web.Application 183 | """ 184 | from .common import JSONAPI, logger 185 | from .context import JSONAPIContext 186 | from .middleware import jsonapi_middleware 187 | 188 | if JSONAPI in app: 189 | logger.warning('JSON API application is initialized already. ' 190 | 'Please check your aiohttp.web.Application instance ' 191 | 'does not have a "%s" dictionary key.', JSONAPI) 192 | logger.error('Initialization of JSON API application is FAILED.') 193 | return app 194 | 195 | routes_namespace = routes_namespace \ 196 | if routes_namespace and isinstance(routes_namespace, str) \ 197 | else JSONAPI 198 | 199 | if context_cls is not None: 200 | if not issubclass(context_cls, JSONAPIContext): 201 | raise TypeError(f'Subclass of JSONAPIContext is required. ' 202 | f'Got: {context_cls}') 203 | else: 204 | context_cls = JSONAPIContext 205 | 206 | app[JSONAPI] = { 207 | 'registry': setup_app_registry(app, registry_class, config), 208 | 'context_cls': context_cls, 209 | 'meta': meta, 210 | 'jsonapi': { 211 | 'version': version, 212 | }, 213 | 'log_errors': log_errors, 214 | 'routes_namespace': routes_namespace 215 | } 216 | 217 | handlers = setup_custom_handlers(custom_handlers) 218 | 219 | setup_resources(app, base_path, handlers, routes_namespace) 220 | 221 | logger.debug('Registered JSON API resources list:') 222 | for resource in filter(lambda r: r.name.startswith(routes_namespace), 223 | app.router.resources()): 224 | logger.debug('%s -> %s', 225 | [r.method for r in resource], resource.get_info()) 226 | 227 | app.middlewares.append(jsonapi_middleware) 228 | 229 | return app 230 | -------------------------------------------------------------------------------- /aiohttp_json_api/abc/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/aiohttp_json_api/abc/__init__.py -------------------------------------------------------------------------------- /aiohttp_json_api/abc/contoller.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from .processors import MetaProcessors 4 | from ..context import JSONAPIContext 5 | 6 | 7 | class ControllerMeta(abc.ABCMeta, MetaProcessors): 8 | def __init__(cls, name, bases, attrs): 9 | """ 10 | Initialise a new schema class. 11 | """ 12 | super(ControllerMeta, cls).__init__(name, bases, attrs) 13 | cls._resolve_processors() 14 | 15 | 16 | class ControllerABC(abc.ABC, metaclass=ControllerMeta): 17 | def __init__(self, context: JSONAPIContext): 18 | self.ctx = context 19 | 20 | @staticmethod 21 | @abc.abstractmethod 22 | async def default_include(field, resources, **kwargs): 23 | raise NotImplementedError 24 | 25 | @staticmethod 26 | @abc.abstractmethod 27 | async def default_query(field, resource, **kwargs): 28 | raise NotImplementedError 29 | 30 | @staticmethod 31 | @abc.abstractmethod 32 | async def default_add(field, resource, data, sp, **kwargs): 33 | raise NotImplementedError 34 | 35 | @staticmethod 36 | @abc.abstractmethod 37 | async def default_remove(field, resource, data, sp, **kwargs): 38 | raise NotImplementedError 39 | 40 | # CRUD (resource) 41 | # --------------- 42 | 43 | @abc.abstractmethod 44 | async def fetch_resource(self, resource_id, **kwargs): 45 | raise NotImplementedError 46 | 47 | @abc.abstractmethod 48 | async def create_resource(self, data, **kwargs): 49 | """ 50 | .. seealso:: 51 | 52 | http://jsonapi.org/format/#crud-creating 53 | 54 | Creates a new resource instance and returns it. 55 | **You should override this method.** 56 | 57 | The default implementation passes the attributes, (dereferenced) 58 | relationships and meta data from the JSON API resource object 59 | *data* to the constructor of the resource class. If the primary 60 | key is *writable* on creation and a member of *data*, it is also 61 | passed to the constructor. 62 | 63 | :arg dict data: 64 | The JSON API deserialized data by schema. 65 | """ 66 | raise NotImplementedError 67 | 68 | @abc.abstractmethod 69 | async def update_resource(self, resource_id, data, sp, **kwargs): 70 | """ 71 | .. seealso:: 72 | 73 | http://jsonapi.org/format/#crud-updating 74 | 75 | Updates an existing *resource*. **You should overridde this method** in 76 | order to save the changes in the database. 77 | 78 | The default implementation uses the 79 | :class:`~aiohttp_json_api.schema.base_fields.BaseField` 80 | descriptors to update the resource. 81 | 82 | :arg resource_id: 83 | The id of the resource 84 | :arg dict data: 85 | The JSON API resource object with the update information 86 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 87 | The JSON pointer to the source of *data*. 88 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 89 | Request context instance 90 | """ 91 | 92 | raise NotImplementedError 93 | 94 | @abc.abstractmethod 95 | async def delete_resource(self, resource_id, **kwargs): 96 | """ 97 | .. seealso:: 98 | 99 | http://jsonapi.org/format/#crud-deleting 100 | 101 | Deletes the *resource*. **You must overridde this method.** 102 | 103 | :arg resource_id: 104 | The id of the resource or the resource instance 105 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 106 | Request context instance 107 | """ 108 | raise NotImplementedError 109 | 110 | # CRUD (relationships) 111 | # -------------------- 112 | 113 | @abc.abstractmethod 114 | async def update_relationship(self, field, resource, data, sp, **kwargs): 115 | """ 116 | .. seealso:: 117 | 118 | http://jsonapi.org/format/#crud-updating-relationships 119 | 120 | Updates the relationship with the JSON API name *relation_name*. 121 | 122 | :arg FieldABC field: 123 | Relationship field. 124 | :arg resource: 125 | Resource instance fetched by :meth:`~fetch_resource` in handler. 126 | :arg str data: 127 | The JSON API relationship object with the update information. 128 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 129 | The JSON pointer to the source of *data*. 130 | """ 131 | raise NotImplementedError 132 | 133 | @abc.abstractmethod 134 | async def add_relationship(self, field, resource, data, sp, **kwargs): 135 | """ 136 | .. seealso:: 137 | 138 | http://jsonapi.org/format/#crud-updating-to-many-relationships 139 | 140 | Adds the members specified in the JSON API relationship object *data* 141 | to the relationship, unless the relationships already exist. 142 | 143 | :arg FieldABC field: 144 | Relationship field. 145 | :arg resource: 146 | Resource instance fetched by :meth:`~fetch_resource` in handler. 147 | :arg str data: 148 | The JSON API relationship object with the update information. 149 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 150 | The JSON pointer to the source of *data*. 151 | """ 152 | raise NotImplementedError 153 | 154 | @abc.abstractmethod 155 | async def remove_relationship(self, field, resource, data, sp, **kwargs): 156 | """ 157 | .. seealso:: 158 | 159 | http://jsonapi.org/format/#crud-updating-to-many-relationships 160 | 161 | Deletes the members specified in the JSON API relationship object 162 | *data* from the relationship. 163 | 164 | :arg FieldABC field: 165 | Relationship field. 166 | :arg resource: 167 | Resource instance fetched by :meth:`~fetch_resource` in handler. 168 | :arg str data: 169 | The JSON API relationship object with the update information. 170 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 171 | The JSON pointer to the source of *data*. 172 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 173 | Request context instance. 174 | """ 175 | raise NotImplementedError 176 | 177 | # Querying 178 | # -------- 179 | 180 | @abc.abstractmethod 181 | async def query_collection(self, **kwargs): 182 | """ 183 | .. seealso:: 184 | 185 | http://jsonapi.org/format/#fetching 186 | 187 | Fetches a subset of the collection represented by this schema. 188 | **Must be overridden.** 189 | 190 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 191 | Request context instance. 192 | """ 193 | raise NotImplementedError 194 | 195 | @abc.abstractmethod 196 | async def query_resource(self, resource_id, **kwargs): 197 | """ 198 | .. seealso:: 199 | 200 | http://jsonapi.org/format/#fetching 201 | 202 | Fetches the resource with the id *id_*. **Must be overridden.** 203 | 204 | :arg str resource_id: 205 | The id of the requested resource. 206 | :arg JSONAPIContext context: 207 | A request context instance 208 | :raises ~aiohttp_json_api.errors.ResourceNotFound: 209 | If there is no resource with the given *id_*. 210 | """ 211 | raise NotImplementedError 212 | 213 | @abc.abstractmethod 214 | async def query_relatives(self, field, resource, **kwargs): 215 | """ 216 | Controller for the *related* endpoint of the relationship with 217 | then name *relation_name*. 218 | 219 | :arg FieldABC field: 220 | Relationship field. 221 | :arg resource: 222 | Resource instance fetched by :meth:`~fetch_resource` in handler. 223 | """ 224 | raise NotImplementedError 225 | 226 | @abc.abstractmethod 227 | async def fetch_compound_documents(self, field, resources, *, 228 | rest_path=None, **kwargs): 229 | """ 230 | .. seealso:: 231 | 232 | http://jsonapi.org/format/#fetching-includes 233 | 234 | Fetches the related resources. The default method uses the 235 | controller's :meth:`~default_include`. 236 | **Can be overridden.** 237 | 238 | :arg FieldABC field: 239 | Relationship field. 240 | :arg resources: 241 | A list of resources. 242 | :arg JSONAPIContext context: 243 | Request context instance. 244 | :arg list rest_path: 245 | The name of the relationships of the returned relatives, which 246 | will also be included. 247 | :rtype: list 248 | :returns: 249 | A list with the related resources. The list is empty or has 250 | exactly one element in the case of *to-one* relationships. 251 | If *to-many* relationships are paginated, the relatives from the 252 | first page should be returned. 253 | """ 254 | raise NotImplementedError 255 | -------------------------------------------------------------------------------- /aiohttp_json_api/abc/field.py: -------------------------------------------------------------------------------- 1 | """ 2 | Field abstract base class 3 | ========================= 4 | """ 5 | 6 | import abc 7 | from typing import Optional 8 | 9 | from ..jsonpointer import JSONPointer 10 | 11 | 12 | class FieldABC(abc.ABC): 13 | @property 14 | @abc.abstractmethod 15 | def key(self) -> str: 16 | raise NotImplementedError 17 | 18 | @property 19 | @abc.abstractmethod 20 | def sp(self) -> JSONPointer: 21 | raise NotImplementedError 22 | 23 | @property 24 | @abc.abstractmethod 25 | def name(self) -> Optional[str]: 26 | raise NotImplementedError 27 | 28 | @name.setter 29 | @abc.abstractmethod 30 | def name(self, value: Optional[str]): 31 | pass 32 | 33 | @property 34 | @abc.abstractmethod 35 | def mapped_key(self) -> Optional[str]: 36 | raise NotImplementedError 37 | 38 | @mapped_key.setter 39 | @abc.abstractmethod 40 | def mapped_key(self, value: Optional[str]): 41 | pass 42 | 43 | @abc.abstractmethod 44 | def serialize(self, schema, data, **kwargs): 45 | """ 46 | Serialize the passed *data*. 47 | """ 48 | raise NotImplementedError 49 | 50 | @abc.abstractmethod 51 | def deserialize(self, schema, data, sp, **kwargs): 52 | """ 53 | Deserialize the raw *data* from the JSON API input document 54 | and returns it. 55 | """ 56 | raise NotImplementedError 57 | 58 | @abc.abstractmethod 59 | def pre_validate(self, schema, data, sp): 60 | """ 61 | Validates the raw JSON API input for this field. This method is 62 | called before :meth:`deserialize`. 63 | 64 | :arg ~aiohttp_json_api.schema.BaseSchema schema: 65 | The schema this field has been defined on. 66 | :arg data: 67 | The raw input data 68 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 69 | A JSON pointer to the source of *data*. 70 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 71 | A JSON API request context instance 72 | """ 73 | raise NotImplementedError 74 | 75 | @abc.abstractmethod 76 | def post_validate(self, schema, data, sp): 77 | """ 78 | Validates the decoded input *data* for this field. This method is 79 | called after :meth:`deserialize`. 80 | 81 | :arg ~aiohttp_json_api.schema.BaseSchema schema: 82 | The schema this field has been defined on. 83 | :arg data: 84 | The decoded input data 85 | :arg ~aiohttp_json_api.jsonpointer.JSONPointer sp: 86 | A JSON pointer to the source of *data*. 87 | :arg ~aiohttp_json_api.context.JSONAPIContext context: 88 | A JSON API request context instance 89 | """ 90 | raise NotImplementedError 91 | -------------------------------------------------------------------------------- /aiohttp_json_api/abc/processors.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections import defaultdict 3 | 4 | 5 | class MetaProcessors: 6 | def _resolve_processors(cls): 7 | """ 8 | Add in the decorated processors 9 | By doing this after constructing the class, we let standard inheritance 10 | do all the hard work. 11 | 12 | Almost the same as https://github.com/marshmallow-code/marshmallow/blob/dev/marshmallow/schema.py#L139-L174 13 | """ 14 | mro = inspect.getmro(cls) 15 | cls._has_processors = False 16 | cls.__processors__ = defaultdict(list) 17 | for attr_name in dir(cls): 18 | # Need to look up the actual descriptor, not whatever might be 19 | # bound to the class. This needs to come from the __dict__ of the 20 | # declaring class. 21 | for parent in mro: 22 | try: 23 | attr = parent.__dict__[attr_name] 24 | except KeyError: 25 | continue 26 | else: 27 | break 28 | else: 29 | # In case we didn't find the attribute and didn't break above. 30 | # We should never hit this - it's just here for completeness 31 | # to exclude the possibility of attr being undefined. 32 | continue 33 | 34 | try: 35 | processor_tags = attr.__processing_tags__ 36 | except AttributeError: 37 | continue 38 | 39 | cls._has_processors = bool(processor_tags) 40 | for tag in processor_tags: 41 | # Use name here so we can get the bound method later, in case 42 | # the processor was a descriptor or something. 43 | cls.__processors__[tag].append(attr_name) 44 | -------------------------------------------------------------------------------- /aiohttp_json_api/common.py: -------------------------------------------------------------------------------- 1 | """Common constants, enumerations and structures.""" 2 | 3 | import collections 4 | import logging 5 | import re 6 | from collections import namedtuple 7 | from enum import Enum, Flag, auto 8 | 9 | from mimeparse import parse_media_range 10 | 11 | #: Logger instance 12 | logger = logging.getLogger('aiohttp-json-api') 13 | 14 | #: Key of JSON API stuff in aiohttp.web.Application 15 | JSONAPI = 'jsonapi' 16 | 17 | #: JSON API Content-Type by specification 18 | JSONAPI_CONTENT_TYPE = 'application/vnd.api+json' 19 | JSONAPI_CONTENT_TYPE_PARSED = parse_media_range(JSONAPI_CONTENT_TYPE) 20 | 21 | #: Regular expression rule for check allowed fields and types names 22 | ALLOWED_MEMBER_NAME_RULE = \ 23 | r'[a-zA-Z0-9]([a-zA-Z0-9\-_]+[a-zA-Z0-9]|[a-zA-Z0-9]?)' 24 | 25 | #: Compiled regexp of rule 26 | ALLOWED_MEMBER_NAME_REGEX = re.compile('^' + ALLOWED_MEMBER_NAME_RULE + '$') 27 | 28 | #: Filter rule 29 | FilterRule = namedtuple('FilterRule', ('name', 'value')) 30 | 31 | #: JSON API resource identifier 32 | ResourceID = collections.namedtuple('ResourceID', ['type', 'id']) 33 | 34 | 35 | class SortDirection(Enum): 36 | """Sorting direction enumeration.""" 37 | 38 | ASC = '+' 39 | DESC = '-' 40 | 41 | 42 | class Step(Enum): 43 | """Marshalling step enumeration.""" 44 | 45 | BEFORE_DESERIALIZATION = auto() 46 | AFTER_DESERIALIZATION = auto() 47 | BEFORE_SERIALIZATION = auto() 48 | AFTER_SERIALIZATION = auto() 49 | 50 | 51 | class Event(Flag): 52 | """Request event enumeration.""" 53 | 54 | GET = auto() 55 | POST = auto() 56 | PATCH = auto() 57 | DELETE = auto() 58 | NEVER = auto() 59 | ALWAYS = GET | POST | PATCH | DELETE 60 | CREATE = POST 61 | UPDATE = PATCH 62 | 63 | 64 | class Relation(Enum): 65 | """Types of relations enumeration.""" 66 | 67 | TO_ONE = auto() 68 | TO_MANY = auto() 69 | -------------------------------------------------------------------------------- /aiohttp_json_api/controller.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from .abc.contoller import ControllerABC 4 | from .common import logger 5 | from .fields.decorators import Tag 6 | from .helpers import first, get_processors 7 | 8 | 9 | class DefaultController(ControllerABC): 10 | @staticmethod 11 | async def default_include(field, resources, **kwargs): 12 | if field.mapped_key: 13 | ctx = kwargs['context'] 14 | compound_documents = [] 15 | for resource in resources: 16 | compound_document = getattr(resource, field.mapped_key) 17 | if compound_document: 18 | compound_documents.extend( 19 | (compound_document,) 20 | if type(compound_document) in ctx.registry 21 | else compound_document 22 | ) 23 | return compound_documents 24 | raise RuntimeError('No includer and mapped_key have been defined.') 25 | 26 | @staticmethod 27 | async def default_query(field, resource, **kwargs): 28 | if field.mapped_key: 29 | return getattr(resource, field.mapped_key) 30 | raise RuntimeError('No query method and mapped_key have been defined.') 31 | 32 | @staticmethod 33 | async def default_add(field, resource, data, sp, **kwargs): 34 | logger.warning('You should override the adder.') 35 | 36 | if not field.mapped_key: 37 | raise RuntimeError('No adder and mapped_key have been defined.') 38 | 39 | relatives = getattr(resource, field.mapped_key) 40 | relatives.extend(data) 41 | 42 | @staticmethod 43 | async def default_remove(field, resource, data, sp, **kwargs): 44 | logger.warning('You should override the remover.') 45 | 46 | if not field.mapped_key: 47 | raise RuntimeError('No remover and mapped_key have been defined.') 48 | 49 | relatives = getattr(resource, field.mapped_key) 50 | for relative in data: 51 | try: 52 | relatives.remove(relative) 53 | except ValueError: 54 | pass 55 | 56 | async def update_resource(self, resource, data, sp, **kwargs): 57 | updated_resource = copy.deepcopy(resource) 58 | for key, (field_data, sp) in data.items(): 59 | field = self.ctx.schema.get_field(key) 60 | await self.ctx.schema.set_value(field, updated_resource, 61 | field_data, sp, **kwargs) 62 | 63 | return resource, updated_resource 64 | 65 | async def update_relationship(self, field, resource, data, sp, **kwargs): 66 | updated_resource = copy.deepcopy(resource) 67 | await self.ctx.schema.set_value(field, updated_resource, data, sp, 68 | **kwargs) 69 | return resource, updated_resource 70 | 71 | async def add_relationship(self, field, resource, data, sp, **kwargs): 72 | updated_resource = copy.deepcopy(resource) 73 | adder, adder_kwargs = first( 74 | get_processors(self, Tag.ADD, field, self.default_add) 75 | ) 76 | await adder(field, updated_resource, data, sp, 77 | **adder_kwargs, **kwargs) 78 | return resource, updated_resource 79 | 80 | async def remove_relationship(self, field, resource, data, sp, **kwargs): 81 | updated_resource = copy.deepcopy(resource) 82 | remover, remover_kwargs = first( 83 | get_processors(self, Tag.REMOVE, field, self.default_remove) 84 | ) 85 | await remover(field, updated_resource, data, sp, 86 | **remover_kwargs, **kwargs) 87 | return resource, updated_resource 88 | 89 | async def query_relatives(self, field, resource, **kwargs): 90 | query, query_kwargs = first( 91 | get_processors(self, Tag.QUERY, field, self.default_query) 92 | ) 93 | return await query(field, resource, **query_kwargs, **kwargs) 94 | 95 | async def fetch_compound_documents(self, field, resources, **kwargs): 96 | include, include_kwargs = first( 97 | get_processors(self, Tag.INCLUDE, field, self.default_include) 98 | ) 99 | return await include(field, resources, context=self.ctx, 100 | **include_kwargs, **kwargs) 101 | -------------------------------------------------------------------------------- /aiohttp_json_api/encoder.py: -------------------------------------------------------------------------------- 1 | """JSON encoder extension.""" 2 | 3 | import functools 4 | import json 5 | 6 | from .jsonpointer import JSONPointer 7 | 8 | 9 | class JSONEncoder(json.JSONEncoder): 10 | """Overloaded JSON encoder with JSONPointer support.""" 11 | 12 | def default(self, o): 13 | """Add JSONPointer serializing support to default json.dumps.""" 14 | if isinstance(o, JSONPointer): 15 | return o.path 16 | 17 | return super(JSONEncoder, self).default(o) 18 | 19 | 20 | # pylint: disable=C0103 21 | json_dumps = functools.partial(json.dumps, cls=JSONEncoder) 22 | -------------------------------------------------------------------------------- /aiohttp_json_api/fields/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/aiohttp_json_api/fields/__init__.py -------------------------------------------------------------------------------- /aiohttp_json_api/fields/decorators.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Schema decorators 5 | ================= 6 | 7 | This module contains some decorators, which can be used instead of the 8 | descriptors on the :class:`~aiohttp_json_api.schema.base_fields.BaseField` 9 | class. 10 | 11 | .. todo:: 12 | 13 | Allow to define a *getter*, ..., *includer* for multiple fields:: 14 | 15 | @includes("author", "comments") 16 | def include_all(self, article, **kargs): 17 | return (article.author, article.comments) 18 | 19 | @validates("a", "b") 20 | def validate_a_and_b(self, a, spa, b, spb, **kargs): 21 | if a > b: 22 | raise InvalidValue("a must be less than b", source_pointer=spa) 23 | return None 24 | 25 | .. todo:: 26 | 27 | Use convention over configuration:: 28 | 29 | @gets("author") 30 | def get_author(self, article, **kargs): 31 | return article.author_id 32 | 33 | # Should be the same as 34 | 35 | def get_author(self, article, **kargs): 36 | return article.author_id 37 | """ 38 | import functools 39 | from enum import Enum 40 | 41 | from ..common import Event, Step 42 | 43 | __all__ = ( 44 | 'Tag', 45 | 'gets', 46 | 'sets', 47 | 'updates', 48 | 'validates', 49 | 'adds', 50 | 'removes', 51 | 'includes', 52 | 'queries' 53 | ) 54 | 55 | 56 | class Tag(Enum): 57 | GET = 'get' 58 | SET = 'set' 59 | VALIDATE = 'validate' 60 | ADD = 'add' 61 | REMOVE = 'remove' 62 | INCLUDE = 'include' 63 | QUERY = 'query' 64 | 65 | 66 | def tag_processor(tag, callee, **kwargs): 67 | """ 68 | Tags decorated processor function to be picked up later. 69 | 70 | .. note:: 71 | Currently ony works with functions and instance methods. Class and 72 | static methods are not supported. 73 | 74 | :return: Decorated function if supplied, else this decorator with its args 75 | bound. 76 | """ 77 | # Allow using this as either a decorator or a decorator factory. 78 | if callee is None: 79 | return functools.partial(tag_processor, tag, **kwargs) 80 | 81 | try: 82 | processing_tags = callee.__processing_tags__ 83 | except AttributeError: 84 | callee.__processing_tags__ = processing_tags = set() 85 | # Also save the kwargs for the tagged function on 86 | # __processing_kwargs__, keyed by (, ) 87 | try: 88 | processing_kwargs = callee.__processing_kwargs__ 89 | except AttributeError: 90 | callee.__processing_kwargs__ = processing_kwargs = {} 91 | 92 | field_key = kwargs.pop('field_key', None) 93 | processing_tags.add((tag, field_key)) 94 | processing_kwargs[(tag, field_key)] = kwargs 95 | 96 | return callee 97 | 98 | 99 | def gets(field_key): 100 | """ 101 | Decorator for marking the getter of a field:: 102 | 103 | class Article(BaseSchema): 104 | 105 | title = String() 106 | 107 | @gets("title") 108 | def get_title(self, article): 109 | return article.get_title() 110 | 111 | A field can have at most **one** getter. 112 | 113 | :arg str field_key: 114 | The key of the field. 115 | """ 116 | return tag_processor(Tag.GET, None, field_key=field_key) 117 | 118 | 119 | def sets(field_key): 120 | """ 121 | Decorator for marking the setter of a field:: 122 | 123 | class Article(BaseSchema): 124 | 125 | title = String() 126 | 127 | @sets("title") 128 | def update_title(self, article, title, sp): 129 | article.set_title(title) 130 | return None 131 | 132 | A field can have at most **one** updater. 133 | 134 | :arg str field_key: 135 | The key of the field. 136 | """ 137 | return tag_processor(Tag.SET, None, field_key=field_key) 138 | 139 | 140 | #: Alias for :func:`sets`. 141 | updates = sets 142 | 143 | 144 | def validates(field_key, 145 | step: Step = Step.AFTER_DESERIALIZATION, 146 | on: Event = Event.ALWAYS): 147 | """ 148 | Decorator for adding a validator:: 149 | 150 | class Article(BaseSchema): 151 | 152 | created_at = DateTime() 153 | 154 | @validates("created_at") 155 | def validate_created_at(self, data, sp, context): 156 | if created_at > datetime.utcnow(): 157 | detail = "Must be in the past." 158 | raise InvalidValue(detail=detail, source_pointer=sp) 159 | 160 | A field can have as many validators as you want. Note, that they are not 161 | necessarily called in the order of their definition. 162 | 163 | :arg str field_key: 164 | The key of the field. 165 | :arg Step step: 166 | Must be any Step enumeration value (e.g. Step.BEFORE_DESERIALIZATION) 167 | :arg Event on: 168 | Validator's Event 169 | """ 170 | return tag_processor(Tag.VALIDATE, None, 171 | field_key=field_key, step=step, on=on) 172 | 173 | 174 | def adds(field_key): 175 | """ 176 | Decorator for marking the adder of a relationship:: 177 | 178 | class Article(BaseSchema): 179 | 180 | comments = ToMany() 181 | 182 | @adds("comments") 183 | def add_comments(self, field, resource, data, sp, 184 | context=None, **kwargs): 185 | for comment in comment: 186 | comment.article_id = article.id 187 | 188 | A relationship can have at most **one** adder. 189 | 190 | :arg str field_key: 191 | The key of the relationship. 192 | """ 193 | return tag_processor(Tag.ADD, None, field_key=field_key) 194 | 195 | 196 | def removes(field_key): 197 | """ 198 | Decorator for marking the remover of a relationship:: 199 | 200 | class Article(BaseSchema): 201 | 202 | comments = ToMany() 203 | 204 | @removes("comments") 205 | def remove_comments(self, field, resource, data, sp, 206 | context=None, **kwargs): 207 | for comment in comment: 208 | comment.article_id = None 209 | 210 | A relationship can have at most **one** remover. 211 | 212 | :arg str field_key: 213 | The key of the relationship. 214 | """ 215 | return tag_processor(Tag.REMOVE, None, field_key=field_key) 216 | 217 | 218 | def includes(field_key): 219 | """ 220 | Decorator for marking the includer of a relationship:: 221 | 222 | class Article(BaseSchema): 223 | 224 | author = ToOne() 225 | 226 | @includes("author") 227 | def include_author(self, field, resources, context, **kwargs): 228 | return article.load_author() 229 | 230 | A field can have at most **one** includer. 231 | 232 | .. hint:: 233 | 234 | The includer should receive list of all resources related to request. 235 | This able to make one request for all related includes at each step 236 | of recursively fetched compound documents. 237 | Look at :func:`~aiohttp_json_api.utils.get_compound_documents` 238 | for more details about how it works. 239 | 240 | :arg str field_key: 241 | The name of the relationship. 242 | """ 243 | return tag_processor(Tag.INCLUDE, None, field_key=field_key) 244 | 245 | 246 | def queries(field_key): 247 | """ 248 | Decorator for marking the function used to query the resources in a 249 | relationship:: 250 | 251 | class Article(BaseSchema): 252 | 253 | comments = ToMany() 254 | 255 | @queries("comments") 256 | def query_comments(self, article_id, **kargs): 257 | pass 258 | 259 | A field can have at most **one** query method. 260 | 261 | .. todo:: 262 | 263 | Add an example. 264 | 265 | :arg str field_key: 266 | The name of the relationship. 267 | """ 268 | return tag_processor(Tag.QUERY, None, field_key=field_key) 269 | -------------------------------------------------------------------------------- /aiohttp_json_api/fields/relationships.py: -------------------------------------------------------------------------------- 1 | """ 2 | Relationships 3 | ============= 4 | """ 5 | 6 | import typing 7 | from collections import Mapping, OrderedDict 8 | 9 | from .base import Relationship 10 | from ..common import Relation 11 | from ..errors import InvalidType 12 | from ..helpers import is_collection 13 | 14 | __all__ = ( 15 | 'ToOne', 16 | 'ToMany', 17 | ) 18 | 19 | 20 | class ToOne(Relationship): 21 | """ 22 | .. seealso:: 23 | 24 | * http://jsonapi.org/format/#document-resource-object-relationships 25 | * http://jsonapi.org/format/#document-resource-object-linkage 26 | 27 | Describes how to serialize, deserialize and update a *to-one* relationship. 28 | """ 29 | relation = Relation.TO_ONE 30 | 31 | def validate_relationship_object(self, schema, data, sp): 32 | """ 33 | Checks additionaly to :meth:`Relationship.validate_relationship_object` 34 | that the *data* member is a valid resource linkage. 35 | """ 36 | super(ToOne, self).validate_relationship_object(schema, data, sp) 37 | if 'data' in data and data['data'] is not None: 38 | self.validate_resource_identifier(schema, data['data'], 39 | sp / 'data') 40 | 41 | def serialize(self, schema, data, **kwargs) -> typing.MutableMapping: 42 | """Composes the final relationships object.""" 43 | document = OrderedDict() 44 | 45 | if data is None: 46 | document['data'] = data 47 | elif isinstance(data, Mapping): 48 | # JSON API resource linkage or JSON API relationships object 49 | if 'type' in data and 'id' in data: 50 | document['data'] = data 51 | else: 52 | # the related resource instance 53 | document['data'] = \ 54 | schema.ctx.registry.ensure_identifier(data, asdict=True) 55 | 56 | links = kwargs.get('links') 57 | if links is not None: 58 | document['links'] = links 59 | 60 | return document 61 | 62 | 63 | class ToMany(Relationship): 64 | """ 65 | .. seealso:: 66 | 67 | * http://jsonapi.org/format/#document-resource-object-relationships 68 | * http://jsonapi.org/format/#document-resource-object-linkage 69 | 70 | Describes how to serialize, deserialize and update a *to-many* 71 | relationship. Additionally to *to-one* relationships, *to-many* 72 | relationships must also support adding and removing relatives. 73 | 74 | :arg aiohttp_json_api.pagination.PaginationABC pagination: 75 | The pagination helper *class* used to paginate the *to-many* 76 | relationship. 77 | """ 78 | relation = Relation.TO_MANY 79 | 80 | def __init__(self, *, pagination=None, **kwargs): 81 | super(ToMany, self).__init__(**kwargs) 82 | self.pagination = pagination 83 | 84 | def serialize(self, schema, data, links=None, pagination=None, 85 | **kwargs) -> typing.MutableMapping: 86 | """Composes the final JSON API relationships object. 87 | 88 | :arg ~aiohttp_json_api.pagination.PaginationABC pagination: 89 | If not *None*, the links and meta members of the pagination 90 | helper are added to the final JSON API relationship object. 91 | """ 92 | document = OrderedDict() 93 | 94 | if is_collection(data): 95 | document['data'] = [ 96 | schema.ctx.registry.ensure_identifier(item, asdict=True) 97 | for item in data 98 | ] 99 | 100 | if links is not None: 101 | document['links'] = links 102 | 103 | if pagination is not None: 104 | document['links'].update(pagination.links()) 105 | document.setdefault('meta', OrderedDict()) 106 | document['meta'].update(pagination.meta()) 107 | 108 | return document 109 | 110 | def validate_relationship_object(self, schema, data, sp): 111 | """ 112 | Checks additionaly to :meth:`Relationship.validate_relationship_object` 113 | that the *data* member is a list of resource identifier objects. 114 | """ 115 | super(ToMany, self).validate_relationship_object(schema, data, sp) 116 | if 'data' in data and not is_collection(data['data']): 117 | detail = 'The "data" must be an array ' \ 118 | 'of resource identifier objects.' 119 | raise InvalidType(detail=detail, source_pointer=sp / 'data') 120 | 121 | for i, item in enumerate(data['data']): 122 | self.validate_resource_identifier(schema, item, sp / 'data' / i) 123 | -------------------------------------------------------------------------------- /aiohttp_json_api/fields/trafarets.py: -------------------------------------------------------------------------------- 1 | """ 2 | Additional trafaret's fields 3 | ============================ 4 | """ 5 | 6 | import decimal 7 | import numbers 8 | 9 | import trafaret as t 10 | 11 | 12 | class DecimalTrafaret(t.Float): 13 | convertable = t.str_types + (numbers.Real, int) 14 | value_type = decimal.Decimal 15 | 16 | def __init__(self, places=None, rounding=None, allow_nan=False, **kwargs): 17 | self.allow_nan = allow_nan 18 | self.places = decimal.Decimal((0, (1,), -places)) \ 19 | if places is not None else None 20 | self.rounding = rounding 21 | super(DecimalTrafaret, self).__init__(**kwargs) 22 | 23 | def _converter(self, value): 24 | if not isinstance(value, self.convertable): 25 | self._failure(f'value is not {self.value_type.__name__}', 26 | value=value) 27 | try: 28 | return self.value_type(value) 29 | except (ValueError, decimal.InvalidOperation): 30 | self._failure( 31 | f"value can't be converted to {self.value_type.__name__}", 32 | value=value 33 | ) 34 | 35 | def check_and_return(self, data): 36 | data = super(DecimalTrafaret, self).check_and_return(data) 37 | 38 | if self.allow_nan: 39 | if data.is_nan(): 40 | return decimal.Decimal('NaN') # avoid sNaN, -sNaN and -NaN 41 | else: 42 | if data.is_nan() or data.is_infinite(): 43 | self._failure('Special numeric values are not permitted.', 44 | value=data) 45 | 46 | if self.places is not None and data.is_finite(): 47 | try: 48 | data = data.quantize(self.places, rounding=self.rounding) 49 | except decimal.InvalidOperation: 50 | self._failure('Decimal can not be properly quantized.', 51 | value=data) 52 | 53 | return data 54 | -------------------------------------------------------------------------------- /aiohttp_json_api/handlers.py: -------------------------------------------------------------------------------- 1 | """Handlers.""" 2 | 3 | import collections 4 | from http import HTTPStatus 5 | 6 | from aiohttp import hdrs, web 7 | 8 | from .context import JSONAPIContext 9 | from .common import Relation 10 | from .errors import InvalidType 11 | from .helpers import get_router_resource 12 | from .jsonpointer import JSONPointer 13 | from .utils import (get_compound_documents, jsonapi_response, render_document, 14 | validate_uri_resource_id) 15 | 16 | __all__ = ( 17 | 'get_collection', 18 | 'post_resource', 19 | 'get_resource', 20 | 'patch_resource', 21 | 'delete_resource', 22 | 'get_relationship', 23 | 'post_relationship', 24 | 'patch_relationship', 25 | 'delete_relationship', 26 | 'get_related' 27 | ) 28 | 29 | 30 | async def get_collection(request: web.Request): 31 | """ 32 | Fetch resources collection, render JSON API document and return response. 33 | 34 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.query_collection` 35 | method of the schema to query the resources in the collection. 36 | 37 | :seealso: http://jsonapi.org/format/#fetching 38 | """ 39 | ctx = JSONAPIContext(request) 40 | resources = await ctx.controller.query_collection() 41 | 42 | compound_documents = None 43 | if ctx.include and resources: 44 | compound_documents, relationships = \ 45 | await get_compound_documents(resources, ctx) 46 | 47 | result = await render_document(resources, compound_documents, ctx) 48 | 49 | return jsonapi_response(result) 50 | 51 | 52 | async def post_resource(request: web.Request): 53 | """ 54 | Create resource, render JSON API document and return response. 55 | 56 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.create_resource` 57 | method of the schema to create a new resource. 58 | 59 | :seealso: http://jsonapi.org/format/#crud-creating 60 | """ 61 | raw_data = await request.json() 62 | if not isinstance(raw_data, collections.Mapping): 63 | detail = 'Must be an object.' 64 | raise InvalidType(detail=detail, source_pointer='') 65 | 66 | ctx = JSONAPIContext(request) 67 | 68 | deserialized_data = await ctx.schema.deserialize_resource( 69 | raw_data.get('data', {}), JSONPointer('/data') 70 | ) 71 | data = ctx.schema.map_data_to_schema(deserialized_data) 72 | 73 | resource = await ctx.controller.create_resource(data) 74 | result = await render_document(resource, None, ctx) 75 | 76 | location = request.url.join( 77 | get_router_resource(request.app, 'resource').url_for( 78 | **ctx.registry.ensure_identifier(resource, asdict=True) 79 | ) 80 | ) 81 | 82 | return jsonapi_response(result, status=HTTPStatus.CREATED, 83 | headers={hdrs.LOCATION: str(location)}) 84 | 85 | 86 | async def get_resource(request: web.Request): 87 | """ 88 | Get single resource, render JSON API document and return response. 89 | 90 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.query_resource` 91 | method of the schema to query the requested resource. 92 | 93 | :seealso: http://jsonapi.org/format/#fetching-resources 94 | """ 95 | ctx = JSONAPIContext(request) 96 | resource_id = request.match_info.get('id') 97 | validate_uri_resource_id(ctx.schema, resource_id) 98 | 99 | resource = await ctx.controller.query_resource(resource_id) 100 | 101 | compound_documents = None 102 | if ctx.include and resource: 103 | compound_documents, relationships = \ 104 | await get_compound_documents(resource, ctx) 105 | 106 | result = await render_document(resource, compound_documents, ctx) 107 | 108 | return jsonapi_response(result) 109 | 110 | 111 | async def patch_resource(request: web.Request): 112 | """ 113 | Update resource (via PATCH), render JSON API document and return response. 114 | 115 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.update_resource` 116 | method of the schema to update a resource. 117 | 118 | :seealso: http://jsonapi.org/format/#crud-updating 119 | """ 120 | ctx = JSONAPIContext(request) 121 | resource_id = request.match_info.get('id') 122 | validate_uri_resource_id(ctx.schema, resource_id) 123 | 124 | raw_data = await request.json() 125 | if not isinstance(raw_data, collections.Mapping): 126 | detail = 'Must be an object.' 127 | raise InvalidType(detail=detail, source_pointer='') 128 | 129 | sp = JSONPointer('/data') 130 | deserialized_data = await ctx.schema.deserialize_resource( 131 | raw_data.get('data', {}), sp, expected_id=resource_id 132 | ) 133 | 134 | resource = await ctx.controller.fetch_resource(resource_id) 135 | old_resource, new_resource = await ctx.controller.update_resource( 136 | resource, deserialized_data, sp 137 | ) 138 | 139 | if old_resource == new_resource: 140 | return web.HTTPNoContent() 141 | 142 | result = await render_document(new_resource, None, ctx) 143 | return jsonapi_response(result) 144 | 145 | 146 | async def delete_resource(request: web.Request): 147 | """ 148 | Remove resource. 149 | 150 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.delete_resource` 151 | method of the schema to delete a resource. 152 | 153 | :seealso: http://jsonapi.org/format/#crud-deleting 154 | """ 155 | ctx = JSONAPIContext(request) 156 | resource_id = request.match_info.get('id') 157 | validate_uri_resource_id(ctx.schema, resource_id) 158 | 159 | await ctx.controller.delete_resource(resource_id) 160 | return web.HTTPNoContent() 161 | 162 | 163 | async def get_relationship(request: web.Request): 164 | """ 165 | Get relationships of resource. 166 | 167 | :param request: Request instance 168 | :return: Response 169 | """ 170 | relation_name = request.match_info['relation'] 171 | ctx = JSONAPIContext(request) 172 | 173 | relation_field = ctx.schema.get_relationship_field(relation_name, 174 | source_parameter='URI') 175 | resource_id = request.match_info.get('id') 176 | validate_uri_resource_id(ctx.schema, resource_id) 177 | 178 | pagination = None 179 | if relation_field.relation is Relation.TO_MANY: 180 | pagination_type = relation_field.pagination 181 | if pagination_type: 182 | pagination = pagination_type(request) 183 | 184 | resource = await ctx.controller.query_resource(resource_id) 185 | result = ctx.schema.serialize_relationship(relation_name, resource, 186 | pagination=pagination) 187 | return jsonapi_response(result) 188 | 189 | 190 | async def post_relationship(request: web.Request): 191 | """ 192 | Create relationships of resource. 193 | 194 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.add_relationship` 195 | method of the schemato add new relationships. 196 | 197 | :seealso: http://jsonapi.org/format/#crud-updating-relationships 198 | """ 199 | relation_name = request.match_info['relation'] 200 | ctx = JSONAPIContext(request) 201 | relation_field = ctx.schema.get_relationship_field(relation_name, 202 | source_parameter='URI') 203 | 204 | resource_id = request.match_info.get('id') 205 | validate_uri_resource_id(ctx.schema, resource_id) 206 | 207 | pagination = None 208 | if relation_field.relation is Relation.TO_MANY: 209 | pagination_type = relation_field.pagination 210 | if pagination_type: 211 | pagination = pagination_type(request) 212 | 213 | data = await request.json() 214 | 215 | sp = JSONPointer('') 216 | field = ctx.schema.get_relationship_field(relation_name) 217 | if field.relation is not Relation.TO_MANY: 218 | raise RuntimeError('Wrong relationship field.' 219 | 'Relation to-many is required.') 220 | 221 | await ctx.schema.pre_validate_field(field, data, sp) 222 | deserialized_data = field.deserialize(ctx.schema, data, sp) 223 | 224 | resource = await ctx.controller.fetch_resource(resource_id) 225 | 226 | old_resource, new_resource = \ 227 | await ctx.controller.add_relationship(field, resource, 228 | deserialized_data, sp) 229 | 230 | if old_resource == new_resource: 231 | return web.HTTPNoContent() 232 | 233 | result = ctx.schema.serialize_relationship(relation_name, new_resource, 234 | pagination=pagination) 235 | return jsonapi_response(result) 236 | 237 | 238 | async def patch_relationship(request: web.Request): 239 | """ 240 | Update relationships of resource. 241 | 242 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.update_relationship` 243 | method of the schema to update the relationship. 244 | 245 | :seealso: http://jsonapi.org/format/#crud-updating-relationships 246 | """ 247 | relation_name = request.match_info['relation'] 248 | ctx = JSONAPIContext(request) 249 | relation_field = ctx.schema.get_relationship_field(relation_name, 250 | source_parameter='URI') 251 | 252 | resource_id = request.match_info.get('id') 253 | validate_uri_resource_id(ctx.schema, resource_id) 254 | 255 | pagination = None 256 | if relation_field.relation is Relation.TO_MANY: 257 | pagination_type = relation_field.pagination 258 | if pagination_type: 259 | pagination = pagination_type(request) 260 | 261 | data = await request.json() 262 | 263 | field = ctx.schema.get_relationship_field(relation_name) 264 | sp = JSONPointer('') 265 | 266 | await ctx.schema.pre_validate_field(field, data, sp) 267 | deserialized_data = field.deserialize(ctx.schema, data, sp) 268 | 269 | resource = await ctx.controller.fetch_resource(resource_id) 270 | 271 | old_resource, new_resource = \ 272 | await ctx.controller.update_relationship(field, resource, 273 | deserialized_data, sp) 274 | 275 | if old_resource == new_resource: 276 | return web.HTTPNoContent() 277 | 278 | result = ctx.schema.serialize_relationship(relation_name, new_resource, 279 | pagination=pagination) 280 | return jsonapi_response(result) 281 | 282 | 283 | async def delete_relationship(request: web.Request): 284 | """ 285 | Remove relationships of resource. 286 | 287 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.delete_relationship` 288 | method of the schema to update the relationship. 289 | 290 | :seealso: http://jsonapi.org/format/#crud-updating-relationships 291 | """ 292 | relation_name = request.match_info['relation'] 293 | ctx = JSONAPIContext(request) 294 | relation_field = ctx.schema.get_relationship_field(relation_name, 295 | source_parameter='URI') 296 | 297 | resource_id = request.match_info.get('id') 298 | validate_uri_resource_id(ctx.schema, resource_id) 299 | 300 | pagination = None 301 | if relation_field.relation is Relation.TO_MANY: 302 | pagination_type = relation_field.pagination 303 | if pagination_type: 304 | pagination = pagination_type(request) 305 | 306 | data = await request.json() 307 | 308 | sp = JSONPointer('') 309 | field = ctx.schema.get_relationship_field(relation_name) 310 | if field.relation is not Relation.TO_MANY: 311 | raise RuntimeError('Wrong relationship field.' 312 | 'Relation to-many is required.') 313 | 314 | await ctx.schema.pre_validate_field(field, data, sp) 315 | deserialized_data = field.deserialize(ctx.schema, data, sp) 316 | 317 | resource = await ctx.controller.fetch_resource(resource_id) 318 | 319 | old_resource, new_resource = \ 320 | await ctx.controller.remove_relationship(field, resource, 321 | deserialized_data, sp) 322 | 323 | if old_resource == new_resource: 324 | return web.HTTPNoContent() 325 | 326 | result = ctx.schema.serialize_relationship(relation_name, new_resource, 327 | pagination=pagination) 328 | return jsonapi_response(result) 329 | 330 | 331 | async def get_related(request: web.Request): 332 | """ 333 | Get related resources. 334 | 335 | Uses the :meth:`~aiohttp_json_api.schema.BaseSchema.query_relative` 336 | method of the schema to query the related resource. 337 | 338 | :seealso: http://jsonapi.org/format/#fetching 339 | """ 340 | relation_name = request.match_info['relation'] 341 | ctx = JSONAPIContext(request) 342 | relation_field = ctx.schema.get_relationship_field(relation_name, 343 | source_parameter='URI') 344 | compound_documents = None 345 | pagination = None 346 | 347 | resource_id = request.match_info.get('id') 348 | validate_uri_resource_id(ctx.schema, resource_id) 349 | 350 | if relation_field.relation is Relation.TO_MANY: 351 | pagination_type = relation_field.pagination 352 | if pagination_type: 353 | pagination = pagination_type(request) 354 | 355 | field = ctx.schema.get_relationship_field(relation_name) 356 | resource = await ctx.controller.fetch_resource(resource_id) 357 | 358 | relatives = await ctx.controller.query_relatives(field, resource) 359 | 360 | if ctx.include and relatives: 361 | compound_documents, relationships = \ 362 | await get_compound_documents(relatives, ctx) 363 | 364 | result = await render_document(relatives, compound_documents, ctx, 365 | pagination=pagination) 366 | 367 | return jsonapi_response(result) 368 | -------------------------------------------------------------------------------- /aiohttp_json_api/helpers.py: -------------------------------------------------------------------------------- 1 | """Helpers.""" 2 | 3 | import inspect 4 | from collections import Iterable, Mapping 5 | from typing import Optional, Tuple, List, Iterable as IterableType 6 | 7 | from aiohttp import web 8 | from mimeparse import parse_media_range, _filter_blank 9 | 10 | from .abc.field import FieldABC 11 | from .fields.decorators import Tag 12 | from .typings import Callee, MimeTypeComponents, QFParsed 13 | from .common import JSONAPI 14 | 15 | 16 | def is_generator(obj): 17 | """Return True if ``obj`` is a generator.""" 18 | return inspect.isgeneratorfunction(obj) or inspect.isgenerator(obj) 19 | 20 | 21 | def is_iterable_but_not_string(obj): 22 | """Return True if ``obj`` is an iterable object that isn't a string.""" 23 | return ( 24 | (isinstance(obj, Iterable) and not hasattr(obj, "strip")) or 25 | is_generator(obj) 26 | ) 27 | 28 | 29 | def is_indexable_but_not_string(obj): 30 | """Return True if ``obj`` is indexable but isn't a string.""" 31 | return not hasattr(obj, "strip") and hasattr(obj, "__getitem__") 32 | 33 | 34 | def is_collection(obj, exclude=()): 35 | """Return True if ``obj`` is a collection type.""" 36 | return (not isinstance(obj, (Mapping,) + exclude) and 37 | is_iterable_but_not_string(obj)) 38 | 39 | 40 | def ensure_collection(value, exclude=()): 41 | """Ensure value is collection.""" 42 | return value if is_collection(value, exclude=exclude) else (value,) 43 | 44 | 45 | def first(iterable, default=None, key=None): 46 | """ 47 | Return first element of *iterable*. 48 | 49 | Return first element of *iterable* that evaluates to ``True``, else 50 | return ``None`` or optional *default*. 51 | 52 | >>> first([0, False, None, [], (), 42]) 53 | 42 54 | >>> first([0, False, None, [], ()]) is None 55 | True 56 | >>> first([0, False, None, [], ()], default='ohai') 57 | 'ohai' 58 | >>> import re 59 | >>> m = first(re.match(regex, 'abc') for regex in ['b.*', 'a(.*)']) 60 | >>> m.group(1) 61 | 'bc' 62 | 63 | The optional *key* argument specifies a one-argument predicate function 64 | like that used for *filter()*. The *key* argument, if supplied, should be 65 | in keyword form. For example, finding the first even number in an iterable: 66 | 67 | >>> first([1, 1, 3, 4, 5], key=lambda x: x % 2 == 0) 68 | 4 69 | 70 | Contributed by Hynek Schlawack, author of `the original standalone module`_ 71 | 72 | .. _the original standalone module: https://github.com/hynek/first 73 | """ 74 | return next(filter(key, iterable), default) 75 | 76 | 77 | def make_sentinel(name='_MISSING', var_name=None): 78 | """ 79 | Create sentinel instance. 80 | 81 | Creates and returns a new **instance** of a new class, suitable for 82 | usage as a "sentinel", a kind of singleton often used to indicate 83 | a value is missing when ``None`` is a valid input. 84 | 85 | >>> make_sentinel(var_name='_MISSING') 86 | _MISSING 87 | 88 | The most common use cases here in project are as default values 89 | for optional function arguments, partly because of its 90 | less-confusing appearance in automatically generated 91 | documentation. Sentinels also function well as placeholders in queues 92 | and linked lists. 93 | 94 | .. note:: 95 | 96 | By design, additional calls to ``make_sentinel`` with the same 97 | values will not produce equivalent objects. 98 | 99 | >>> make_sentinel('TEST') == make_sentinel('TEST') 100 | False 101 | >>> type(make_sentinel('TEST')) == type(make_sentinel('TEST')) 102 | False 103 | 104 | :arg str name: 105 | Name of the Sentinel 106 | :arg str var_name: 107 | Set this name to the name of the variable in its respective 108 | module enable pickleability. 109 | """ 110 | class Sentinel(object): 111 | def __init__(self): 112 | self.name = name 113 | self.var_name = var_name 114 | 115 | def __repr__(self): 116 | if self.var_name: 117 | return self.var_name 118 | return '%s(%r)' % (self.__class__.__name__, self.name) 119 | 120 | if var_name: 121 | def __reduce__(self): 122 | return self.var_name 123 | 124 | def __nonzero__(self): 125 | return False 126 | 127 | __bool__ = __nonzero__ 128 | 129 | return Sentinel() 130 | 131 | 132 | def get_router_resource(app: web.Application, resource: str): 133 | """Return route of JSON API application for resource.""" 134 | return app.router[f"{app[JSONAPI]['routes_namespace']}.{resource}"] 135 | 136 | 137 | def get_processors(obj, tag: Tag, field: FieldABC, 138 | default: Optional[Callee] = None): 139 | has_processors = getattr(obj, '_has_processors', False) 140 | if has_processors: 141 | processor_tag = tag, field.key 142 | processors = obj.__processors__.get(processor_tag) 143 | if processors: 144 | for processor_name in processors: 145 | processor = getattr(obj, processor_name) 146 | processor_kwargs = \ 147 | processor.__processing_kwargs__.get(processor_tag) 148 | yield processor, processor_kwargs 149 | return 150 | 151 | if not callable(default): 152 | return 153 | 154 | yield default, {} 155 | 156 | 157 | def quality_and_fitness_parsed(mime_type: str, 158 | parsed_ranges: List[MimeTypeComponents] 159 | ) -> QFParsed: 160 | """Find the best match for a mime-type amongst parsed media-ranges. 161 | 162 | Find the best match for a given mime-type against a list of media_ranges 163 | that have already been parsed by parse_media_range(). Returns a tuple of 164 | the fitness value and the value of the 'q' quality parameter of the best 165 | match, or (-1, 0) if no match was found. Just as for quality_parsed(), 166 | 'parsed_ranges' must be a list of parsed media ranges. 167 | 168 | Cherry-picked from python-mimeparse and improved. 169 | """ 170 | best_fitness = -1 171 | best_fit_q = 0 172 | (target_type, target_subtype, target_params) = parse_media_range(mime_type) 173 | best_matched = None 174 | 175 | for (type, subtype, params) in parsed_ranges: 176 | 177 | # check if the type and the subtype match 178 | type_match = ( 179 | type in (target_type, '*') or 180 | target_type == '*' 181 | ) 182 | subtype_match = ( 183 | subtype in (target_subtype, '*') or 184 | target_subtype == '*' 185 | ) 186 | 187 | # if they do, assess the "fitness" of this mime_type 188 | if type_match and subtype_match: 189 | 190 | # 100 points if the type matches w/o a wildcard 191 | fitness = type == target_type and 100 or 0 192 | 193 | # 10 points if the subtype matches w/o a wildcard 194 | fitness += subtype == target_subtype and 10 or 0 195 | 196 | # 1 bonus point for each matching param besides "q" 197 | param_matches = sum([ 198 | 1 for (key, value) in target_params.items() 199 | if key != 'q' and key in params and value == params[key] 200 | ]) 201 | fitness += param_matches 202 | 203 | # finally, add the target's "q" param (between 0 and 1) 204 | fitness += float(target_params.get('q', 1)) 205 | 206 | if fitness > best_fitness: 207 | best_fitness = fitness 208 | best_fit_q = params['q'] 209 | best_matched = (type, subtype, params) 210 | 211 | return (float(best_fit_q), best_fitness), best_matched 212 | 213 | 214 | def best_match(supported: IterableType[str], 215 | header: str) -> Tuple[str, Optional[MimeTypeComponents]]: 216 | """Return mime-type with the highest quality ('q') from list of candidates. 217 | Takes a list of supported mime-types and finds the best match for all the 218 | media-ranges listed in header. The value of header must be a string that 219 | conforms to the format of the HTTP Accept: header. The value of 'supported' 220 | is a list of mime-types. The list of supported mime-types should be sorted 221 | in order of increasing desirability, in case of a situation where there is 222 | a tie. 223 | 224 | Cherry-picked from python-mimeparse and improved. 225 | 226 | >>> best_match(['application/xbel+xml', 'text/xml'], 227 | 'text/*;q=0.5,*/*; q=0.1') 228 | ('text/xml', ('text', '*', {'q': '0.5'})) 229 | """ 230 | split_header = _filter_blank(header.split(',')) 231 | parsed_header = [parse_media_range(r) for r in split_header] 232 | weighted_matches = {} 233 | for i, mime_type in enumerate(supported): 234 | weight, match = quality_and_fitness_parsed(mime_type, parsed_header) 235 | weighted_matches[(weight, i)] = (mime_type, match) 236 | best = max(weighted_matches.keys()) 237 | return best[0][0] and weighted_matches[best] or ('', None) 238 | 239 | 240 | def get_mime_type_params(mime_type: MimeTypeComponents): 241 | return {k: v for k, v in mime_type[2].items() if k != 'q'} 242 | 243 | 244 | MISSING = make_sentinel() 245 | -------------------------------------------------------------------------------- /aiohttp_json_api/jsonpointer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Extended JSONPointer from python-json-pointer_ 3 | ============================================== 4 | 5 | .. _python-json-pointer: https://github.com/stefankoegl/python-json-pointer 6 | """ 7 | import typing 8 | 9 | from jsonpointer import JsonPointer as BaseJsonPointer 10 | 11 | 12 | class JSONPointer(BaseJsonPointer): 13 | def __truediv__(self, 14 | path: typing.Union['JSONPointer', str]) -> 'JSONPointer': 15 | parts = self.parts.copy() 16 | 17 | if isinstance(path, int): 18 | path = str(path) 19 | 20 | if isinstance(path, str): 21 | if not path.startswith('/'): 22 | path = f'/{path}' 23 | new_parts = JSONPointer(path).parts.pop(0) 24 | parts.append(new_parts) 25 | else: 26 | new_parts = path.parts 27 | parts.extend(new_parts) 28 | return JSONPointer.from_parts(parts) 29 | -------------------------------------------------------------------------------- /aiohttp_json_api/middleware.py: -------------------------------------------------------------------------------- 1 | """Middleware.""" 2 | from aiohttp import hdrs 3 | 4 | from .common import ( 5 | JSONAPI, JSONAPI_CONTENT_TYPE, JSONAPI_CONTENT_TYPE_PARSED, 6 | logger 7 | ) 8 | from .errors import ( 9 | Error, ErrorList, HTTPUnsupportedMediaType, HTTPNotAcceptable 10 | ) 11 | from .helpers import best_match, get_mime_type_params 12 | from .utils import error_to_response 13 | 14 | 15 | async def jsonapi_middleware(app, handler): 16 | """Middleware for handling JSON API errors.""" 17 | async def middleware_handler(request): 18 | try: 19 | route_name = request.match_info.route.name 20 | namespace = request.app[JSONAPI]['routes_namespace'] 21 | 22 | if route_name and route_name.startswith('%s.' % namespace): 23 | request_ct = request.headers.get(hdrs.CONTENT_TYPE) 24 | 25 | content_type_error = \ 26 | f"Content-Type '{JSONAPI_CONTENT_TYPE}' is required." 27 | 28 | if request_ct is None and request.has_body: 29 | raise HTTPUnsupportedMediaType(detail=content_type_error) 30 | 31 | if (request_ct is not None and 32 | request_ct != JSONAPI_CONTENT_TYPE): 33 | raise HTTPUnsupportedMediaType(detail=content_type_error) 34 | 35 | accept_header = request.headers.get(hdrs.ACCEPT, '*/*') 36 | matched_mt, parsed_mt = best_match( 37 | (JSONAPI_CONTENT_TYPE,), accept_header 38 | ) 39 | if matched_mt != JSONAPI_CONTENT_TYPE: 40 | raise HTTPNotAcceptable() 41 | 42 | if JSONAPI_CONTENT_TYPE_PARSED[:2] == parsed_mt[:2]: 43 | additional_params = get_mime_type_params(parsed_mt) 44 | if additional_params: 45 | formatted = ','.join( 46 | f'{k}={v}' for k, v in additional_params.items() 47 | ) 48 | detail = (f'JSON API media type is modified ' 49 | f'with media type parameters. ({formatted})') 50 | raise HTTPNotAcceptable(detail=detail) 51 | 52 | return await handler(request) 53 | except Exception as exc: 54 | if isinstance(exc, (Error, ErrorList)): 55 | if app[JSONAPI]['log_errors']: 56 | logger.exception(exc) 57 | return error_to_response(request, exc) 58 | else: 59 | raise 60 | 61 | return middleware_handler 62 | -------------------------------------------------------------------------------- /aiohttp_json_api/registry.py: -------------------------------------------------------------------------------- 1 | """Application registry.""" 2 | 3 | import collections 4 | import inspect 5 | 6 | from .common import ResourceID 7 | from .typings import ResourceIdentifier 8 | 9 | 10 | class Registry(collections.UserDict): 11 | """ 12 | JSON API application registry. 13 | 14 | This is a dictionary created on JSON API application set up. 15 | It contains a mapping between types, resource classes and schemas. 16 | """ 17 | 18 | __slots__ = ('data',) 19 | 20 | def __getitem__(self, key): 21 | """ 22 | Get schema for type or resource class type. 23 | 24 | :param key: Type string or resource class. 25 | :return: Schema instance 26 | """ 27 | return super(Registry, self).__getitem__( 28 | key if isinstance(key, str) or inspect.isclass(key) else type(key) 29 | ) 30 | 31 | def ensure_identifier(self, obj, asdict=False) -> ResourceIdentifier: 32 | """ 33 | Return the identifier object for the *resource*. 34 | 35 | (:class:`ResourceID <.common.ResourceID>`) 36 | 37 | .. code-block:: python3 38 | 39 | >>> registry.ensure_identifier({'type': 'something', 'id': 123}) 40 | ResourceID(type='something', id='123') 41 | 42 | :arg obj: 43 | A two tuple ``(typename, id)``, a resource object or a resource 44 | document, which contains the *id* and *type* key 45 | ``{"type": ..., "id": ...}``. 46 | :arg bool asdict: 47 | Return ResourceID as dictionary if true 48 | """ 49 | if isinstance(obj, collections.Sequence) and len(obj) == 2: 50 | result = ResourceID(str(obj[0]), str(obj[1])) 51 | elif isinstance(obj, collections.Mapping): 52 | result = ResourceID(str(obj['type']), str(obj['id'])) 53 | else: 54 | try: 55 | schema_cls, _ = self.data[type(obj)] 56 | result = ResourceID(schema_cls.opts.resource_type, 57 | schema_cls.get_object_id(obj)) 58 | except KeyError: 59 | raise RuntimeError( 60 | 'Schema for %s is not found.' % obj.__class__.__name__ 61 | ) 62 | 63 | return result._asdict() if asdict and result else result 64 | -------------------------------------------------------------------------------- /aiohttp_json_api/schema.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Base schema 5 | =========== 6 | 7 | This module contains the base schema which implements the encoding, decoding, 8 | validation and update operations based on 9 | :class:`fields `. 10 | """ 11 | import asyncio 12 | import urllib.parse 13 | from collections import MutableMapping, OrderedDict 14 | from typing import Dict 15 | 16 | from .abc.field import FieldABC 17 | from .abc.schema import SchemaABC 18 | from .fields.base import Attribute, Relationship 19 | from .fields.decorators import Tag 20 | from .common import Event, Relation, Step, JSONAPI 21 | from .errors import ( 22 | HTTPBadRequest, HTTPConflict, InvalidType, InvalidValue, ValidationError 23 | ) 24 | from .helpers import MISSING, first, get_router_resource, get_processors 25 | 26 | __all__ = ( 27 | 'BaseSchema', 28 | ) 29 | 30 | 31 | class BaseSchema(SchemaABC): 32 | """ 33 | A schema defines how we can serialize a resource and patch it. 34 | It also allows to patch a resource. All in all, it defines 35 | a **controller** for a *type* in the JSON API. 36 | 37 | If you want, you can implement your own request handlers and only use 38 | the schema for validation and serialization. 39 | """ 40 | 41 | @staticmethod 42 | def get_object_id(resource) -> str: 43 | """ 44 | **Can be overridden**. 45 | 46 | Returns the id (string) of the resource. The default implementation 47 | looks for a property ``resource.id``, an id method ``resource.id()``, 48 | ``resource.get_id()`` or a key ``resource["id"]``. 49 | 50 | :arg resource: 51 | A resource object 52 | :rtype: str 53 | :returns: 54 | The string representation of ID of the *resource* 55 | """ 56 | if hasattr(resource, 'id'): 57 | resource_id = \ 58 | resource.id() if callable(resource.id) else resource.id 59 | elif hasattr(resource, 'get_id'): 60 | resource_id = resource.get_id() 61 | elif 'id' in resource: 62 | resource_id = resource['id'] 63 | else: 64 | raise Exception('Could not determine the resource id.') 65 | return str(resource_id) 66 | 67 | @classmethod 68 | def get_field(cls, key) -> FieldABC: 69 | return cls._declared_fields[key] 70 | 71 | @classmethod 72 | def get_relationship_field(cls, relation_name, source_parameter=None): 73 | try: 74 | return cls._relationships[cls.opts.deflect(relation_name)] 75 | except KeyError: 76 | raise HTTPBadRequest( 77 | detail=f"Wrong relationship name '{relation_name}'.", 78 | source_parameter=source_parameter 79 | ) 80 | 81 | @staticmethod 82 | def default_getter(field, resource, **kwargs): 83 | if field.mapped_key: 84 | return getattr(resource, field.mapped_key) 85 | return None 86 | 87 | @staticmethod 88 | async def default_setter(field, resource, data, sp, **kwargs): 89 | if field.mapped_key: 90 | setattr(resource, field.mapped_key, data) 91 | 92 | def get_value(self, field, resource, **kwargs): 93 | getter, getter_kwargs = first( 94 | get_processors(self, Tag.GET, field, self.default_getter) 95 | ) 96 | return getter(field, resource, **getter_kwargs, **kwargs) 97 | 98 | async def set_value(self, field, resource, data, sp, **kwargs): 99 | if field.writable is Event.NEVER: 100 | raise RuntimeError('Attempt to set value to read-only field.') 101 | 102 | setter, setter_kwargs = first( 103 | get_processors(self, Tag.SET, field, self.default_setter) 104 | ) 105 | return await setter(field, resource, data, sp, **setter_kwargs, 106 | **kwargs) 107 | 108 | def serialize_resource(self, resource, **kwargs) -> MutableMapping: 109 | """ 110 | .. seealso:: 111 | 112 | http://jsonapi.org/format/#document-resource-objects 113 | 114 | :arg resource: 115 | A resource object 116 | """ 117 | fieldset = self.ctx.fields.get(self.opts.resource_type) 118 | 119 | fields_map = ( 120 | ('attributes', self._attributes), 121 | ('relationships', self._relationships), 122 | ('meta', self._meta), 123 | ('links', self._links) 124 | ) 125 | 126 | result = OrderedDict(( 127 | ('type', self.opts.resource_type), 128 | ('id', self.get_object_id(resource)), 129 | )) 130 | 131 | for key, schema_fields in fields_map: 132 | for field in schema_fields.values(): 133 | # Ignore 'load_only' field during serialization 134 | if getattr(field, 'load_only', False): 135 | continue 136 | 137 | if fieldset is None or field.name in fieldset: 138 | field_data = self.get_value(field, resource, **kwargs) 139 | links = None 140 | if isinstance(field, Relationship): 141 | links = { 142 | link.name: link.serialize(self, resource, **kwargs) 143 | for link in field.links.values() 144 | } 145 | # TODO: Validation steps for pre/post serialization 146 | result.setdefault(key, OrderedDict()) 147 | result[key][field.name] = \ 148 | field.serialize(self, field_data, links=links, 149 | **kwargs) 150 | 151 | result.setdefault('links', OrderedDict()) 152 | if 'self' not in result['links']: 153 | rid = self.ctx.registry.ensure_identifier(resource) 154 | route = get_router_resource(self.ctx.request.app, 'resource') 155 | route_url = route._formatter.format_map({'type': rid.type, 156 | 'id': rid.id}) 157 | route_url = urllib.parse.urlunsplit( 158 | (self.ctx.request.scheme, self.ctx.request.host, route_url, 159 | None, None) 160 | ) 161 | result['links']['self'] = route_url 162 | 163 | return result 164 | 165 | # Validation (pre deserialize) 166 | # ---------------------------- 167 | 168 | def serialize_relationship(self, relation_name, resource, 169 | *, pagination=None): 170 | field = self.get_relationship_field(relation_name) 171 | 172 | kwargs = dict() 173 | if field.relation is Relation.TO_MANY and pagination: 174 | kwargs['pagination'] = pagination 175 | field_data = self.get_value(field, resource, **kwargs) 176 | return field.serialize(self, field_data, **kwargs) 177 | 178 | async def pre_validate_field(self, field, data, sp): 179 | writable = field.writable in (Event.ALWAYS, self.ctx.event) 180 | if data is not MISSING and not writable: 181 | detail = f"The field '{field.name}' is readonly." 182 | raise ValidationError(detail=detail, source_pointer=sp) 183 | 184 | if data is MISSING and field.required in (Event.ALWAYS, 185 | self.ctx.event): 186 | if isinstance(field, Attribute): 187 | detail = f"Attribute '{field.name}' is required." 188 | elif isinstance(field, Relationship): 189 | detail = f"Relationship '{field.name}' is required." 190 | else: 191 | detail = f"The field '{field.name}' is required." 192 | raise InvalidValue(detail=detail, source_pointer=sp) 193 | 194 | if data is not MISSING: 195 | if asyncio.iscoroutinefunction(field.pre_validate): 196 | await field.pre_validate(self, data, sp) 197 | else: 198 | field.pre_validate(self, data, sp) 199 | 200 | # Run custom pre-validators for field 201 | validators = get_processors(self, Tag.VALIDATE, field, None) 202 | for validator, validator_kwargs in validators: 203 | if validator_kwargs['step'] is not Step.BEFORE_DESERIALIZATION: 204 | continue 205 | if validator_kwargs['on'] not in (Event.ALWAYS, 206 | self.ctx.event): 207 | continue 208 | 209 | if asyncio.iscoroutinefunction(validator): 210 | await validator(self, field, data, sp) 211 | else: 212 | validator(self, field, data, sp) 213 | 214 | # Validation (post deserialize) 215 | # ----------------------------- 216 | 217 | async def pre_validate_resource(self, data, sp, *, expected_id=None): 218 | if not isinstance(data, MutableMapping): 219 | detail = 'Must be an object.' 220 | raise InvalidType(detail=detail, source_pointer=sp) 221 | 222 | # JSON API id 223 | if ((expected_id or self.ctx.event is Event.UPDATE) and 224 | 'id' not in data): 225 | detail = "The 'id' member is missing." 226 | raise InvalidValue(detail=detail, source_pointer=sp / 'id') 227 | 228 | if expected_id: 229 | if str(data['id']) == str(expected_id): 230 | if self._id is not None: 231 | await self.pre_validate_field(self._id, data['id'], 232 | sp / 'id') 233 | else: 234 | detail = ( 235 | f"The id '{data['id']}' does not match " 236 | f"the endpoint id '{expected_id}'." 237 | ) 238 | raise HTTPConflict(detail=detail, source_pointer=sp / 'id') 239 | 240 | async def post_validate_resource(self, data): 241 | # NOTE: The fields in *data* are ordered, such that children are 242 | # listed before their parent. 243 | for key, (field_data, field_sp) in data.items(): 244 | field = self.get_field(key) 245 | field.post_validate(self, field_data, field_sp) 246 | 247 | # Run custom post-validators for field 248 | validators = get_processors(self, Tag.VALIDATE, field, None) 249 | for validator, validator_kwargs in validators: 250 | if validator_kwargs['step'] is not Step.AFTER_DESERIALIZATION: 251 | continue 252 | if validator_kwargs['on'] not in (Event.ALWAYS, self.ctx.event): 253 | continue 254 | 255 | if asyncio.iscoroutinefunction(validator): 256 | await validator(field, field_data, field_sp, 257 | context=self.ctx) 258 | else: 259 | validator(field, field_data, field_sp, context=self.ctx) 260 | 261 | async def deserialize_resource(self, data, sp, *, expected_id=None, 262 | validate=True, validation_steps=None): 263 | if validation_steps is None: 264 | validation_steps = (Step.BEFORE_DESERIALIZATION, 265 | Step.AFTER_DESERIALIZATION) 266 | 267 | if validate and Step.BEFORE_DESERIALIZATION in validation_steps: 268 | await self.pre_validate_resource(data, sp, expected_id=expected_id) 269 | 270 | result = OrderedDict() 271 | fields_map = ( 272 | ('attributes', self._attributes), 273 | ('relationships', self._relationships), 274 | ('meta', self._meta), 275 | ) 276 | 277 | for key, fields in fields_map: 278 | data_for_fields = data.get(key, {}) 279 | 280 | if validate and not isinstance(data_for_fields, MutableMapping): 281 | detail = 'Must be an object.' 282 | raise InvalidType(detail=detail, source_pointer=sp / key) 283 | 284 | for field in fields.values(): 285 | field_data = data_for_fields.get(field.name, MISSING) 286 | 287 | if field.key: 288 | field_sp = sp / key / field.name 289 | 290 | if (validate and 291 | Step.BEFORE_DESERIALIZATION in validation_steps): 292 | await self.pre_validate_field(field, field_data, 293 | field_sp) 294 | 295 | if field_data is not MISSING: 296 | result[field.key] = ( 297 | field.deserialize(self, field_data, field_sp), 298 | field_sp 299 | ) 300 | 301 | if validate and Step.AFTER_DESERIALIZATION in validation_steps: 302 | await self.post_validate_resource(result) 303 | 304 | return result 305 | 306 | def map_data_to_schema(self, data) -> Dict: 307 | # Map the property names on the resource instance to its initial data. 308 | result = { 309 | self.get_field(key).mapped_key: field_data 310 | for key, (field_data, sp) in data.items() 311 | } 312 | if 'id' in data: 313 | result['id'] = data['id'] 314 | return result 315 | -------------------------------------------------------------------------------- /aiohttp_json_api/typings.py: -------------------------------------------------------------------------------- 1 | """Useful typing.""" 2 | 3 | # pylint: disable=C0103 4 | from typing import ( 5 | Callable, Coroutine, Dict, MutableMapping, Tuple, Union, Optional 6 | ) 7 | 8 | from .common import FilterRule, ResourceID, SortDirection 9 | 10 | #: Type for Request filters 11 | RequestFilters = MutableMapping[str, FilterRule] 12 | 13 | #: Type for Request fields 14 | RequestFields = MutableMapping[str, Tuple[str, ...]] 15 | 16 | #: Type for Request includes (compound documents) 17 | RequestIncludes = Tuple[Tuple[str, ...], ...] 18 | 19 | #: Type for Request sorting parameters 20 | RequestSorting = MutableMapping[Tuple[str, ...], SortDirection] 21 | 22 | #: Type for Resource identifier 23 | ResourceIdentifier = Union[ResourceID, Dict[str, str]] 24 | 25 | #: Type for callable or co-routine 26 | Callee = Union[Callable, Coroutine] 27 | 28 | MimeTypeComponents = Tuple[str, str, Dict[str, str]] 29 | QualityAndFitness = Tuple[float, int] 30 | QFParsed = Tuple[QualityAndFitness, Optional[MimeTypeComponents]] 31 | -------------------------------------------------------------------------------- /aiohttp_json_api/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities related to JSON API.""" 2 | 3 | import asyncio 4 | import typing 5 | from collections import defaultdict, OrderedDict 6 | 7 | from aiohttp import web 8 | from aiohttp.web_response import Response 9 | import trafaret as t 10 | 11 | from .common import JSONAPI, JSONAPI_CONTENT_TYPE 12 | from .encoder import json_dumps 13 | from .errors import Error, ErrorList, ValidationError 14 | from .helpers import first, is_collection 15 | 16 | 17 | def jsonapi_response(data, *, status=web.HTTPOk.status_code, 18 | reason=None, headers=None, dumps=None): 19 | """ 20 | Return JSON API response. 21 | 22 | :param data: Rendered JSON API document 23 | :param status: HTTP status of JSON API response 24 | :param reason: Readable reason of error response 25 | :param headers: Headers 26 | :param dumps: Custom JSON dumps callable 27 | :return: Response instance 28 | """ 29 | if not callable(dumps): 30 | dumps = json_dumps 31 | 32 | body = dumps(data).encode('utf-8') 33 | return Response(body=body, status=status, reason=reason, 34 | headers=headers, content_type=JSONAPI_CONTENT_TYPE) 35 | 36 | 37 | async def get_compound_documents(resources, ctx): 38 | """ 39 | Get compound documents of resources. 40 | 41 | .. seealso:: 42 | 43 | http://jsonapi.org/format/#fetching-includes 44 | 45 | Fetches the relationship paths *paths*. 46 | 47 | :param resources: 48 | A list with the primary data (resources) of the compound 49 | response document. 50 | :param ctx: 51 | A web Request context 52 | 53 | :returns: 54 | A two tuple with a list of the included resources and a dictionary, 55 | which maps each resource (primary and included) to a set with the 56 | names of the included relationships. 57 | """ 58 | relationships = defaultdict(set) 59 | compound_documents = OrderedDict() 60 | 61 | collection = (resources,) if type(resources) in ctx.registry else resources 62 | for path in ctx.include: 63 | if path and collection: 64 | rest_path = path 65 | nested_collection = collection 66 | while rest_path and nested_collection: 67 | schema_cls, controller_cls = \ 68 | ctx.registry[first(nested_collection)] 69 | resource_type = schema_cls.opts.resource_type 70 | 71 | if rest_path in relationships[resource_type]: 72 | break 73 | 74 | field = schema_cls.get_relationship_field( 75 | rest_path[0], source_parameter='include' 76 | ) 77 | 78 | controller = controller_cls(ctx) 79 | nested_collection = await controller.fetch_compound_documents( 80 | field, nested_collection, rest_path=rest_path[1:] 81 | ) 82 | 83 | for relative in nested_collection: 84 | compound_documents.setdefault( 85 | ctx.registry.ensure_identifier(relative), 86 | relative 87 | ) 88 | 89 | relationships[resource_type].add(rest_path) 90 | rest_path = rest_path[1:] 91 | 92 | return compound_documents, relationships 93 | 94 | 95 | def serialize_resource(resource, ctx): 96 | """ 97 | Serialize resource by schema. 98 | 99 | :param resource: Resource instance 100 | :param ctx: Request context 101 | :return: Serialized resource 102 | """ 103 | schema_cls, _ = ctx.registry[resource] 104 | return schema_cls(ctx).serialize_resource(resource) 105 | 106 | 107 | async def render_document(data, included, ctx, *, 108 | pagination=None, 109 | links=None) -> typing.MutableMapping: 110 | """ 111 | Render JSON API document. 112 | 113 | :param data: One or many resources 114 | :param included: Compound documents 115 | :param ctx: Request context 116 | :param pagination: Pagination instance 117 | :param links: Additional links 118 | :return: Rendered JSON API document 119 | """ 120 | document = OrderedDict() 121 | 122 | if is_collection(data, exclude=(ctx.schema.opts.resource_cls,)): 123 | document['data'] = [serialize_resource(r, ctx) for r in data] 124 | else: 125 | document['data'] = serialize_resource(data, ctx) if data else None 126 | 127 | if ctx.include and included: 128 | document['included'] = \ 129 | [serialize_resource(r, ctx) for r in included.values()] 130 | 131 | document.setdefault('links', OrderedDict()) 132 | document['links']['self'] = str(ctx.request.url) 133 | if links is not None: 134 | document['links'].update(links) 135 | 136 | meta_object = ctx.request.app[JSONAPI]['meta'] 137 | pagination = pagination or ctx.pagination 138 | 139 | if pagination or meta_object: 140 | document.setdefault('meta', OrderedDict()) 141 | 142 | if pagination is not None: 143 | document['links'].update(pagination.links()) 144 | document['meta'].update(pagination.meta()) 145 | 146 | if meta_object: 147 | document['meta'].update(meta_object) 148 | 149 | jsonapi_info = ctx.request.app[JSONAPI]['jsonapi'] 150 | if jsonapi_info: 151 | document['jsonapi'] = jsonapi_info 152 | 153 | return document 154 | 155 | 156 | def error_to_response(request: web.Request, 157 | error: typing.Union[Error, ErrorList]): 158 | """ 159 | Convert an :class:`Error` or :class:`ErrorList` to JSON API response. 160 | 161 | :arg ~aiohttp.web.Request request: 162 | The web request instance. 163 | :arg typing.Union[Error, ErrorList] error: 164 | The error, which is converted into a response. 165 | 166 | :rtype: ~aiohttp.web.Response 167 | """ 168 | if not isinstance(error, (Error, ErrorList)): 169 | raise TypeError('Error or ErrorList instance is required.') 170 | 171 | return jsonapi_response( 172 | { 173 | 'errors': 174 | [error.as_dict] if isinstance(error, Error) else error.as_dict, 175 | 'jsonapi': request.app[JSONAPI]['jsonapi'] 176 | }, 177 | status=error.status 178 | ) 179 | 180 | 181 | def validate_uri_resource_id(schema, resource_id): 182 | """ 183 | Validate resource ID from URI. 184 | 185 | :param schema: Resource schema 186 | :param resource_id: Resource ID 187 | """ 188 | field = getattr(schema, '_id', None) 189 | if field is None: 190 | try: 191 | t.Int().check(resource_id) 192 | except t.DataError as exc: 193 | raise ValidationError(detail=str(exc).capitalize(), 194 | source_parameter='id') 195 | else: 196 | try: 197 | field.pre_validate(schema, resource_id, sp=None) 198 | except ValidationError as exc: 199 | exc.source_parameter = 'id' 200 | raise exc 201 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/aiohttp_json_api.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/aiohttp_json_api.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/aiohttp_json_api" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/aiohttp_json_api" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/_static/logo-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/docs/_static/logo-1024x1024.png -------------------------------------------------------------------------------- /docs/_static/logo.svg: -------------------------------------------------------------------------------- 1 | aiohttp JSON API iconCreated using Figma -------------------------------------------------------------------------------- /docs/aiohttp_json_api.abc.rst: -------------------------------------------------------------------------------- 1 | aiohttp\_json\_api.abc package 2 | ============================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. automodule:: aiohttp_json_api.abc.contoller 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | .. automodule:: aiohttp_json_api.abc.field 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. automodule:: aiohttp_json_api.abc.processors 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: aiohttp_json_api.abc.schema 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | 28 | Module contents 29 | --------------- 30 | 31 | .. automodule:: aiohttp_json_api.abc 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | -------------------------------------------------------------------------------- /docs/aiohttp_json_api.fields.rst: -------------------------------------------------------------------------------- 1 | aiohttp\_json\_api.fields package 2 | ================================= 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. automodule:: aiohttp_json_api.fields.attributes 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | .. automodule:: aiohttp_json_api.fields.base 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. automodule:: aiohttp_json_api.fields.decorators 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: aiohttp_json_api.fields.relationships 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: aiohttp_json_api.fields.trafarets 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | 33 | Module contents 34 | --------------- 35 | 36 | .. automodule:: aiohttp_json_api.fields 37 | :members: 38 | :undoc-members: 39 | :show-inheritance: 40 | -------------------------------------------------------------------------------- /docs/aiohttp_json_api.rst: -------------------------------------------------------------------------------- 1 | aiohttp\_json\_api package 2 | ========================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | .. automodule:: aiohttp_json_api.common 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | .. automodule:: aiohttp_json_api.context 13 | :members: 14 | :undoc-members: 15 | :show-inheritance: 16 | 17 | .. automodule:: aiohttp_json_api.controller 18 | :members: 19 | :undoc-members: 20 | :show-inheritance: 21 | 22 | .. automodule:: aiohttp_json_api.encoder 23 | :members: 24 | :undoc-members: 25 | :show-inheritance: 26 | 27 | .. automodule:: aiohttp_json_api.errors 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | 32 | .. automodule:: aiohttp_json_api.handlers 33 | :members: 34 | :undoc-members: 35 | :show-inheritance: 36 | 37 | .. automodule:: aiohttp_json_api.helpers 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | .. automodule:: aiohttp_json_api.jsonpointer 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | .. automodule:: aiohttp_json_api.middleware 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | .. automodule:: aiohttp_json_api.pagination 53 | :members: 54 | :undoc-members: 55 | :show-inheritance: 56 | 57 | .. automodule:: aiohttp_json_api.registry 58 | :members: 59 | :undoc-members: 60 | :show-inheritance: 61 | 62 | .. automodule:: aiohttp_json_api.schema 63 | :members: 64 | :undoc-members: 65 | :show-inheritance: 66 | 67 | .. automodule:: aiohttp_json_api.typings 68 | :members: 69 | :undoc-members: 70 | :show-inheritance: 71 | 72 | .. automodule:: aiohttp_json_api.utils 73 | :members: 74 | :undoc-members: 75 | :show-inheritance: 76 | 77 | 78 | Module contents 79 | --------------- 80 | 81 | .. automodule:: aiohttp_json_api 82 | :members: 83 | :undoc-members: 84 | :show-inheritance: 85 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | aiohttp_json_api 10 | aiohttp_json_api.abc 11 | aiohttp_json_api.fields 12 | 13 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # aiohttp_json_api documentation build configuration file, created by 5 | # sphinx-quickstart on Tue Jul 9 22:26:36 2013. 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 | import sys 17 | import os 18 | 19 | # If extensions (or modules to document with autodoc) are in another 20 | # directory, add these directories to sys.path here. If the directory is 21 | # relative to the documentation root, use os.path.abspath to make it 22 | # absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # Get the project root dir, which is the parent dir of this 26 | cwd = os.getcwd() 27 | project_root = os.path.dirname(cwd) 28 | 29 | # Insert the project root dir as the first element in the PYTHONPATH. 30 | # This lets us ensure that the source package is imported, and that its 31 | # version is used. 32 | sys.path.insert(0, project_root) 33 | 34 | import aiohttp_json_api 35 | 36 | # -- General configuration --------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 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 = [ 44 | 'sphinx.ext.todo', 45 | 'sphinx.ext.autodoc', 46 | 'sphinx.ext.viewcode', 47 | 'alabaster', 48 | 'sphinx_autodoc_typehints' 49 | ] 50 | 51 | # Add any paths that contain templates here, relative to this directory. 52 | templates_path = ['_templates'] 53 | 54 | # The suffix of source filenames. 55 | source_suffix = '.rst' 56 | 57 | # The encoding of source files. 58 | #source_encoding = 'utf-8-sig' 59 | 60 | # The master toctree document. 61 | master_doc = 'index' 62 | 63 | # General information about the project. 64 | project = u'aiohttp JSON API' 65 | copyright = u"2018, Vladimir Bolshakov" 66 | 67 | # The version info for the project you're documenting, acts as replacement 68 | # for |version| and |release|, also used in various other places throughout 69 | # the built documents. 70 | # 71 | # The short X.Y version. 72 | version = aiohttp_json_api.__version__ 73 | # The full version, including alpha/beta/rc tags. 74 | release = aiohttp_json_api.__version__ 75 | 76 | # The language for content autogenerated by Sphinx. Refer to documentation 77 | # for a list of supported languages. 78 | #language = None 79 | 80 | # There are two options for replacing |today|: either, you set today to 81 | # some non-false value, then it is used: 82 | #today = '' 83 | # Else, today_fmt is used as the format for a strftime call. 84 | #today_fmt = '%B %d, %Y' 85 | 86 | # List of patterns, relative to source directory, that match files and 87 | # directories to ignore when looking for source files. 88 | exclude_patterns = ['_build'] 89 | 90 | # The reST default role (used for this markup: `text`) to use for all 91 | # documents. 92 | #default_role = None 93 | 94 | # If true, '()' will be appended to :func: etc. cross-reference text. 95 | #add_function_parentheses = True 96 | 97 | # If true, the current module name will be prepended to all description 98 | # unit titles (such as .. function::). 99 | #add_module_names = True 100 | 101 | # If true, sectionauthor and moduleauthor directives will be shown in the 102 | # output. They are ignored by default. 103 | #show_authors = False 104 | 105 | # The name of the Pygments (syntax highlighting) style to use. 106 | pygments_style = 'sphinx' 107 | highlight_language = 'python3' 108 | 109 | # A list of ignored prefixes for module index sorting. 110 | #modindex_common_prefix = [] 111 | 112 | # If true, keep warnings as "system message" paragraphs in the built 113 | # documents. 114 | #keep_warnings = False 115 | 116 | 117 | # -- Options for HTML output ------------------------------------------- 118 | 119 | # The theme to use for HTML and HTML Help pages. See the documentation for 120 | # a list of builtin themes. 121 | html_theme = 'alabaster' 122 | 123 | # Theme options are theme-specific and customize the look and feel of a 124 | # theme further. For a list of options available for each theme, see the 125 | # documentation. 126 | body_default_font = 'system-ui, -apple-system, BlinkMacSystemFont, ' \ 127 | '"SF UI Text_", "Segoe UI", Roboto, Oxygen, Ubuntu, ' \ 128 | 'Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", ' \ 129 | 'Helvetica, Arial, sans-serif;' 130 | code_default_font = '"SFMono-Regular", "SF Mono", Consolas, ' \ 131 | '"Liberation Mono", Menlo, Courier, monospace;' 132 | html_theme_options = { 133 | 'logo': 'logo-1024x1024.png', 134 | 'logo_name': False, 135 | 'github_user': 'vovanbo', 136 | 'github_repo': 'aiohttp_json_api', 137 | 'github_button': True, 138 | 'github_type': 'star', 139 | 'github_banner': True, 140 | 'travis_button': True, 141 | 'font_family': body_default_font, 142 | 'font_size': '16px', 143 | 'head_font_family': body_default_font, 144 | 'code_font_family': code_default_font 145 | } 146 | 147 | # Add any paths that contain custom themes here, relative to this directory. 148 | #html_theme_path = [] 149 | 150 | # The name for this set of Sphinx documents. If None, it defaults to 151 | # " v documentation". 152 | #html_title = None 153 | 154 | # A shorter title for the navigation bar. Default is the same as 155 | # html_title. 156 | #html_short_title = None 157 | 158 | # The name of an image file (relative to this directory) to place at the 159 | # top of the sidebar. 160 | #html_logo = None 161 | 162 | # The name of an image file (within the static path) to use as favicon 163 | # of the docs. This file should be a Windows icon file (.ico) being 164 | # 16x16 or 32x32 pixels large. 165 | #html_favicon = None 166 | 167 | # Add any paths that contain custom static files (such as style sheets) 168 | # here, relative to this directory. They are copied after the builtin 169 | # static files, so a file named "default.css" will overwrite the builtin 170 | # "default.css". 171 | html_static_path = ['_static'] 172 | 173 | # If not '', a 'Last updated on:' timestamp is inserted at every page 174 | # bottom, using the given strftime format. 175 | #html_last_updated_fmt = '%b %d, %Y' 176 | 177 | # If true, SmartyPants will be used to convert quotes and dashes to 178 | # typographically correct entities. 179 | #html_use_smartypants = True 180 | 181 | # Custom sidebar templates, maps document names to template names. 182 | html_sidebars = { 183 | '**': [ 184 | 'about.html', 185 | 'navigation.html', 186 | 'searchbox.html', 187 | ] 188 | } 189 | # Additional templates that should be rendered to pages, maps page names 190 | # to template names. 191 | #html_additional_pages = {} 192 | 193 | # If false, no module index is generated. 194 | #html_domain_indices = True 195 | 196 | # If false, no index is generated. 197 | #html_use_index = True 198 | 199 | # If true, the index is split into individual pages for each letter. 200 | #html_split_index = False 201 | 202 | # If true, links to the reST sources are added to the pages. 203 | #html_show_sourcelink = True 204 | 205 | # If true, "Created using Sphinx" is shown in the HTML footer. 206 | # Default is True. 207 | #html_show_sphinx = True 208 | 209 | # If true, "(C) Copyright ..." is shown in the HTML footer. 210 | # Default is True. 211 | #html_show_copyright = True 212 | 213 | # If true, an OpenSearch description file will be output, and all pages 214 | # will contain a tag referring to it. The value of this option 215 | # must be the base URL from which the finished HTML is served. 216 | #html_use_opensearch = '' 217 | 218 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 219 | #html_file_suffix = None 220 | 221 | # Output file base name for HTML help builder. 222 | htmlhelp_basename = 'aiohttp_json_apidoc' 223 | 224 | 225 | # -- Options for LaTeX output ------------------------------------------ 226 | 227 | latex_elements = { 228 | # The paper size ('letterpaper' or 'a4paper'). 229 | #'papersize': 'letterpaper', 230 | 231 | # The font size ('10pt', '11pt' or '12pt'). 232 | #'pointsize': '10pt', 233 | 234 | # Additional stuff for the LaTeX preamble. 235 | #'preamble': '', 236 | } 237 | 238 | # Grouping the document tree into LaTeX files. List of tuples 239 | # (source start file, target name, title, author, documentclass 240 | # [howto/manual]). 241 | latex_documents = [ 242 | ('index', 'aiohttp_json_api.tex', 243 | u'aiohttp JSON API Documentation', 244 | u'Vladimir Bolshakov', 'manual'), 245 | ] 246 | 247 | # The name of an image file (relative to this directory) to place at 248 | # the top of the title page. 249 | #latex_logo = None 250 | 251 | # For "manual" documents, if this is true, then toplevel headings 252 | # are parts, not chapters. 253 | #latex_use_parts = False 254 | 255 | # If true, show page references after internal links. 256 | #latex_show_pagerefs = False 257 | 258 | # If true, show URL addresses after external links. 259 | #latex_show_urls = False 260 | 261 | # Documents to append as an appendix to all manuals. 262 | #latex_appendices = [] 263 | 264 | # If false, no module index is generated. 265 | #latex_domain_indices = True 266 | 267 | 268 | # -- Options for manual page output ------------------------------------ 269 | 270 | # One entry per manual page. List of tuples 271 | # (source start file, name, description, authors, manual section). 272 | man_pages = [ 273 | ('index', 'aiohttp_json_api', 274 | u'aiohttp JSON API Documentation', 275 | [u'Vladimir Bolshakov'], 1) 276 | ] 277 | 278 | # If true, show URL addresses after external links. 279 | #man_show_urls = False 280 | 281 | 282 | # -- Options for Texinfo output ---------------------------------------- 283 | 284 | # Grouping the document tree into Texinfo files. List of tuples 285 | # (source start file, target name, title, author, 286 | # dir menu entry, description, category) 287 | texinfo_documents = [ 288 | ('index', 'aiohttp_json_api', 289 | u'aiohttp JSON API Documentation', 290 | u'Vladimir Bolshakov', 291 | 'aiohttp_json_api', 292 | 'JSON API implementation for aiohttp', 293 | 'Miscellaneous'), 294 | ] 295 | 296 | # Documents to append as an appendix to all manuals. 297 | #texinfo_appendices = [] 298 | 299 | # If false, no module index is generated. 300 | #texinfo_domain_indices = True 301 | 302 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 303 | #texinfo_show_urls = 'footnote' 304 | 305 | # If true, do not generate a @detailmenu in the "Top" node's menu. 306 | #texinfo_no_detailmenu = False 307 | 308 | todo_include_todos = True 309 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | aiohttp-json-api: JSON API implementation for aiohttp 2 | ===================================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | readme 10 | installation 11 | usage 12 | contributing 13 | authors 14 | history 15 | api 16 | 17 | Indices and tables 18 | ================== 19 | 20 | * :ref:`genindex` 21 | * :ref:`modindex` 22 | * :ref:`search` 23 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | To install aiohttp JSON API, run this command in your terminal: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install aiohttp_json_api 16 | 17 | This is the preferred method to install aiohttp JSON API, 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 aiohttp JSON API 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/vovanbo/aiohttp_json_api 36 | 37 | Or download the `tarball`_: 38 | 39 | .. code-block:: console 40 | 41 | $ curl -OL https://github.com/vovanbo/aiohttp_json_api/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/vovanbo/aiohttp_json_api 51 | .. _tarball: https://github.com/vovanbo/aiohttp_json_api/tarball/master 52 | 53 | 54 | Default setup of resources, routes and handlers 55 | ----------------------------------------------- 56 | 57 | ===================== ====== ========================================= ====================================================== 58 | Resource name Method Route Handler 59 | ===================== ====== ========================================= ====================================================== 60 | jsonapi.collection GET ``/{type}`` :func:`~aiohttp_json_api.handlers.get_collection` 61 | jsonapi.collection POST ``/{type}`` :func:`~aiohttp_json_api.handlers.post_resource` 62 | jsonapi.resource GET ``/{type}/{id}`` :func:`~aiohttp_json_api.handlers.get_resource` 63 | jsonapi.resource PATCH ``/{type}/{id}`` :func:`~aiohttp_json_api.handlers.patch_resource` 64 | jsonapi.resource DELETE ``/{type}/{id}`` :func:`~aiohttp_json_api.handlers.delete_resource` 65 | jsonapi.relationships GET ``/{type}/{id}/relationships/{relation}`` :func:`~aiohttp_json_api.handlers.get_relationship` 66 | jsonapi.relationships POST ``/{type}/{id}/relationships/{relation}`` :func:`~aiohttp_json_api.handlers.post_relationship` 67 | jsonapi.relationships PATCH ``/{type}/{id}/relationships/{relation}`` :func:`~aiohttp_json_api.handlers.patch_relationship` 68 | jsonapi.relationships DELETE ``/{type}/{id}/relationships/{relation}`` :func:`~aiohttp_json_api.handlers.delete_relationship` 69 | jsonapi.related GET ``/{type}/{id}/{relation}`` :func:`~aiohttp_json_api.handlers.get_related` 70 | ===================== ====== ========================================= ====================================================== 71 | 72 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\aiohttp_json_api.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\aiohttp_json_api.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /docs/readme.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | .. todo:: 6 | 7 | Tutorials will be added soon. 8 | 9 | At this moment, the best way to examine features of this application is looking 10 | at `simple example`_. Models of this example related to entities from official 11 | JSON API specification (e.g. `here `_ 12 | in "Compound Documents" section). 13 | 14 | 15 | .. _simple example: https://github.com/vovanbo/aiohttp_json_api/tree/master/examples/simple 16 | -------------------------------------------------------------------------------- /examples/fantasy/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/examples/fantasy/__init__.py -------------------------------------------------------------------------------- /examples/fantasy/controllers.py: -------------------------------------------------------------------------------- 1 | from aiohttp_json_api.controller import DefaultController 2 | from aiohttp_json_api.errors import ResourceNotFound 3 | from aiohttp_json_api.fields.decorators import includes 4 | 5 | import examples.fantasy.tables as tbl 6 | from examples.fantasy.models import Author 7 | 8 | 9 | class CommonController(DefaultController): 10 | async def create_resource(self, data, **kwargs): 11 | pass 12 | 13 | async def fetch_resource(self, resource_id, **kwargs): 14 | model = self.ctx.schema.opts.resource_cls 15 | async with self.ctx.app['db'].acquire() as connection: 16 | result = await model.fetch_one(connection, resource_id) 17 | 18 | if result is None: 19 | raise ResourceNotFound(type=self.ctx.resource_type, id=resource_id) 20 | 21 | return result 22 | 23 | async def query_collection(self, **kwargs): 24 | model = self.ctx.schema.opts.resource_cls 25 | async with self.ctx.app['db'].acquire() as connection: 26 | results = await model.fetch_many(connection) 27 | 28 | return results.values() 29 | 30 | async def query_resource(self, resource_id, **kwargs): 31 | return await self.fetch_resource(resource_id, **kwargs) 32 | 33 | async def delete_resource(self, resource_id, **kwargs): 34 | pass 35 | 36 | 37 | class BooksController(CommonController): 38 | @includes('author') 39 | async def include_authors(self, field, resources, **kwargs): 40 | authors_ids = set(r.author.id for r in resources) 41 | 42 | if not authors_ids: 43 | return () 44 | 45 | cte = Author.cte(where=(tbl.authors.c.id.in_(authors_ids))) 46 | 47 | async with self.ctx.app['db'].acquire() as connection: 48 | results = await Author.fetch_many(connection, cte) 49 | 50 | return results.values() 51 | -------------------------------------------------------------------------------- /examples/fantasy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | fantasy-db: 5 | image: postgres:10-alpine 6 | ports: 7 | - "5432:5432" 8 | volumes: 9 | - fantasy-db-data:/var/lib/postgresql/data 10 | environment: 11 | POSTGRES_PASSWORD: somepassword 12 | POSTGRES_USER: example 13 | 14 | volumes: 15 | fantasy-db-data: 16 | driver: local 17 | -------------------------------------------------------------------------------- /examples/fantasy/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Simple JSON API application example with in-memory storage.""" 3 | 4 | import asyncio 5 | import os 6 | import logging 7 | 8 | import time 9 | from aiohttp import web 10 | from aiopg.sa import create_engine 11 | 12 | from aiohttp_json_api import setup_jsonapi 13 | 14 | 15 | async def close_db_connections(app): 16 | app['db'].close() 17 | await app['db'].wait_closed() 18 | 19 | 20 | async def init(db_dsn: str, debug=False, loop=None) -> web.Application: 21 | from examples.fantasy.schemas import ( 22 | AuthorSchema, BookSchema, ChapterSchema, 23 | PhotoSchema, StoreSchema, SeriesSchema 24 | ) 25 | from examples.fantasy.controllers import ( 26 | CommonController, BooksController 27 | ) 28 | 29 | app = web.Application(debug=debug, loop=loop) 30 | engine = await create_engine(dsn=db_dsn, echo=debug) 31 | app['db'] = engine 32 | app.on_cleanup.append(close_db_connections) 33 | 34 | # Note that we pass schema classes, not instances of them. 35 | # Schemas instances will be initialized application-wide. 36 | # Schema instance is stateless, therefore any request state must be passed 37 | # to each of Schema's method as JSONAPIContext instance. 38 | # JSONAPIContext instance created automatically in JSON API middleware 39 | # for each request. JSON API handlers use it in calls of Schema's methods. 40 | setup_jsonapi( 41 | app, 42 | { 43 | AuthorSchema: CommonController, 44 | BookSchema: BooksController, 45 | ChapterSchema: CommonController, 46 | PhotoSchema: CommonController, 47 | StoreSchema: CommonController, 48 | SeriesSchema: CommonController 49 | }, 50 | log_errors=debug, meta={'fantasy': {'version': '0.0.1'}} 51 | ) 52 | 53 | return app 54 | 55 | 56 | def main(): 57 | logging.basicConfig( 58 | level=logging.DEBUG, 59 | format='%(levelname)-8s [%(asctime)s.%(msecs)03d] ' 60 | '(%(name)s): %(message)s', 61 | datefmt='%Y-%m-%d %H:%M:%S' 62 | ) 63 | logging.Formatter.converter = time.gmtime 64 | 65 | dsn = os.getenv('EXAMPLE_DSN', 66 | 'postgresql://example:somepassword@localhost/example') 67 | port = os.getenv('EXAMPLE_PORT', 8082) 68 | 69 | app = asyncio.get_event_loop().run_until_complete(init(dsn, debug=True)) 70 | 71 | # More useful log format than default 72 | log_format = '%a (%{X-Real-IP}i) %t "%r" %s %b %Tf ' \ 73 | '"%{Referrer}i" "%{User-Agent}i"' 74 | web.run_app(app, port=port, access_log_format=log_format) 75 | 76 | 77 | if __name__ == '__main__': 78 | main() 79 | -------------------------------------------------------------------------------- /examples/fantasy/schemas.py: -------------------------------------------------------------------------------- 1 | from aiohttp_json_api.schema import BaseSchema 2 | from aiohttp_json_api.fields import attributes, relationships 3 | 4 | from examples.fantasy.models import Author, Store, Book, Series, Photo, Chapter 5 | 6 | 7 | class AuthorSchema(BaseSchema): 8 | name = attributes.String() 9 | date_of_birth = attributes.Date() 10 | date_of_death = attributes.Date(allow_none=True) 11 | created_at = attributes.DateTime() 12 | updated_at = attributes.DateTime(allow_none=True) 13 | 14 | books = relationships.ToMany(foreign_types=('books',)) 15 | photos = relationships.ToMany(foreign_types=('photos',), allow_none=True) 16 | 17 | class Options: 18 | resource_cls = Author 19 | resource_type = 'authors' 20 | 21 | 22 | class BookSchema(BaseSchema): 23 | title = attributes.String() 24 | date_published = attributes.Date() 25 | created_at = attributes.DateTime() 26 | updated_at = attributes.DateTime(allow_none=True) 27 | 28 | author = relationships.ToOne(foreign_types=('author',)) 29 | series = relationships.ToOne(foreign_types=('series',), allow_none=True) 30 | chapters = relationships.ToMany(foreign_types=('chapters',)) 31 | photos = relationships.ToMany(foreign_types=('photos',), allow_none=True) 32 | 33 | class Options: 34 | resource_cls = Book 35 | resource_type = 'books' 36 | 37 | 38 | class ChapterSchema(BaseSchema): 39 | title = attributes.String() 40 | ordering = attributes.Integer() 41 | created_at = attributes.DateTime() 42 | updated_at = attributes.DateTime(allow_none=True) 43 | 44 | book = relationships.ToOne(foreign_types=('books',)) 45 | 46 | class Options: 47 | resource_cls = Chapter 48 | resource_type = 'chapters' 49 | 50 | 51 | class PhotoSchema(BaseSchema): 52 | title = attributes.String() 53 | uri = attributes.String() 54 | created_at = attributes.DateTime() 55 | updated_at = attributes.DateTime(allow_none=True) 56 | 57 | imageable = relationships.ToOne( 58 | foreign_types=('authors', 'books', 'series') # polymorphic 59 | ) 60 | 61 | class Options: 62 | resource_cls = Photo 63 | resource_type = 'photos' 64 | 65 | 66 | class SeriesSchema(BaseSchema): 67 | title = attributes.String() 68 | created_at = attributes.DateTime() 69 | updated_at = attributes.DateTime(allow_none=True) 70 | 71 | books = relationships.ToMany(foreign_types=('books',)) 72 | photos = relationships.ToMany(foreign_types=('photos',)) 73 | 74 | class Options: 75 | resource_cls = Series 76 | resource_type = 'series' 77 | 78 | 79 | class StoreSchema(BaseSchema): 80 | name = attributes.String() 81 | created_at = attributes.DateTime() 82 | updated_at = attributes.DateTime(allow_none=True) 83 | books = relationships.ToMany(foreign_types=('books',), allow_none=True) 84 | 85 | class Options: 86 | resource_cls = Store 87 | resource_type = 'stores' 88 | -------------------------------------------------------------------------------- /examples/fantasy/tables.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from sqlalchemy import ( 3 | CheckConstraint, Column, Date, DateTime, ForeignKey, Integer, MetaData, 4 | Table, Text, text 5 | ) 6 | 7 | metadata = MetaData() 8 | 9 | authors = Table( 10 | 'authors', metadata, 11 | Column('id', Integer, primary_key=True), 12 | Column('name', Text, nullable=False), 13 | Column('date_of_birth', Date, nullable=False), 14 | Column('date_of_death', Date), 15 | Column('created_at', DateTime, nullable=False, 16 | server_default=text('CURRENT_TIMESTAMP')), 17 | Column('updated_at', DateTime), 18 | CheckConstraint("name <> ''::text") 19 | ) 20 | 21 | books = Table( 22 | 'books', metadata, 23 | Column('id', Integer, primary_key=True), 24 | Column('author_id', ForeignKey('authors.id'), nullable=False), 25 | Column('series_id', ForeignKey('series.id')), 26 | Column('date_published', Date, nullable=False), 27 | Column('title', Text, nullable=False), 28 | Column('created_at', DateTime, nullable=False, 29 | server_default=text('CURRENT_TIMESTAMP')), 30 | Column('updated_at', DateTime), 31 | CheckConstraint("title <> ''::text") 32 | ) 33 | 34 | books_stores = Table( 35 | 'books_stores', metadata, 36 | Column('book_id', ForeignKey('books.id'), nullable=False), 37 | Column('store_id', ForeignKey('stores.id'), nullable=False) 38 | ) 39 | 40 | chapters = Table( 41 | 'chapters', metadata, 42 | Column('id', Integer, primary_key=True), 43 | Column('book_id', ForeignKey('books.id'), nullable=False), 44 | Column('title', Text, nullable=False), 45 | Column('ordering', Integer, nullable=False), 46 | Column('created_at', DateTime, nullable=False, 47 | server_default=text("CURRENT_TIMESTAMP")), 48 | Column('updated_at', DateTime), 49 | CheckConstraint("title <> ''::text") 50 | ) 51 | 52 | photos = Table( 53 | 'photos', metadata, 54 | Column('id', Integer, primary_key=True), 55 | Column('title', Text, nullable=False), 56 | Column('uri', Text, nullable=False), 57 | Column('imageable_id', Integer, nullable=False), 58 | Column('imageable_type', Text, nullable=False), 59 | Column('created_at', DateTime, nullable=False, 60 | server_default=text("CURRENT_TIMESTAMP")), 61 | Column('updated_at', DateTime), 62 | CheckConstraint("imageable_type <> ''::text"), 63 | CheckConstraint("title <> ''::text"), 64 | CheckConstraint("uri <> ''::text") 65 | ) 66 | 67 | series = Table( 68 | 'series', metadata, 69 | Column('id', Integer, primary_key=True), 70 | Column('title', Text, nullable=False), 71 | Column('photo_id', ForeignKey('photos.id'), nullable=False), 72 | Column('created_at', DateTime, nullable=False, 73 | server_default=text("CURRENT_TIMESTAMP")), 74 | Column('updated_at', DateTime), 75 | CheckConstraint("title <> ''::text") 76 | ) 77 | 78 | stores = Table( 79 | 'stores', metadata, 80 | Column('id', Integer, primary_key=True), 81 | Column('name', Text, nullable=False), 82 | Column('created_at', DateTime, nullable=False, 83 | server_default=text("CURRENT_TIMESTAMP")), 84 | Column('updated_at', DateTime), 85 | CheckConstraint("name <> ''::text") 86 | ) 87 | -------------------------------------------------------------------------------- /examples/fantasy/tasks.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import sys 5 | import sqlalchemy as sa 6 | from invoke import task 7 | 8 | FANTASY_DATA_FOLDER = Path(__file__).parent / 'fantasy-database' 9 | 10 | 11 | @task 12 | def populate_db(ctx, data_folder=FANTASY_DATA_FOLDER, dsn=None): 13 | from examples.fantasy import tables 14 | 15 | data_file = data_folder / 'data.json' 16 | if not Path(data_file).exists(): 17 | sys.exit(f'Invalid data file: {data_file}') 18 | 19 | with data_file.open() as f: 20 | data = json.load(f) 21 | 22 | create_sql = (data_folder / 'schema.sql').read_text() 23 | 24 | if dsn is None: 25 | dsn = 'postgresql://example:somepassword@localhost/example' 26 | 27 | engine = sa.create_engine(dsn, echo=True) 28 | conn = engine.connect() 29 | trans = conn.begin() 30 | 31 | conn.execute(sa.text(create_sql)) 32 | 33 | tables_in_order = ('photos', 'stores', 'authors', 'series', 'books', 34 | 'chapters', 'books_stores') 35 | 36 | try: 37 | for table_name in tables_in_order: 38 | table = getattr(tables, table_name) 39 | values = data[table_name] 40 | for value in values: 41 | query = table.insert().values(value) 42 | conn.execute(query) 43 | trans.commit() 44 | except Exception as exc: 45 | trans.rollback() 46 | raise 47 | 48 | print('\nDatabase is successfully populated!') 49 | -------------------------------------------------------------------------------- /examples/simple/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/examples/simple/__init__.py -------------------------------------------------------------------------------- /examples/simple/controllers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | 4 | from aiohttp_json_api.errors import ResourceNotFound 5 | from aiohttp_json_api.controller import DefaultController 6 | from examples.simple.models import People 7 | 8 | logger = logging.getLogger() 9 | 10 | 11 | class SimpleController(DefaultController): 12 | @property 13 | def storage(self): 14 | """Shortcut for application simple storage""" 15 | return self.ctx.app['storage'] 16 | 17 | async def fetch_resource(self, resource_id, **kwargs): 18 | rid = self.ctx.registry.ensure_identifier( 19 | {'type': self.ctx.resource_type, 'id': resource_id} 20 | ) 21 | result = self.storage[rid.type].get(rid.id) 22 | if result is None: 23 | raise ResourceNotFound(self.ctx.resource_type, resource_id) 24 | 25 | logger.debug('Fetch resource %r from storage.', result) 26 | return result 27 | 28 | async def query_collection(self, **kwargs): 29 | return self.storage[self.ctx.resource_type].values() 30 | 31 | async def query_resource(self, resource_id, **kwargs): 32 | # Here can be added additional permission check, for example. 33 | # Without this, query_resource is almost the same as fetch_resource. 34 | return await self.fetch_resource(resource_id, **kwargs) 35 | 36 | async def create_resource(self, data, **kwargs): 37 | resource_cls = self.ctx.schema.opts.resource_cls 38 | new_resource = resource_cls(id=random.randint(1000, 9999), **data) 39 | 40 | rid = self.ctx.registry.ensure_identifier(new_resource) 41 | self.storage[rid.type][rid.id] = new_resource 42 | 43 | logger.debug('%r is created.', new_resource) 44 | return new_resource 45 | 46 | async def update_resource(self, resource, data, sp, **kwargs): 47 | resource, updated_resource = \ 48 | await super(SimpleController, self).update_resource( 49 | resource, data, sp, **kwargs) 50 | 51 | rid = self.ctx.registry.ensure_identifier(updated_resource) 52 | self.storage[rid.type][rid.id] = updated_resource 53 | 54 | logger.debug('%r is updated to %r.', resource, updated_resource) 55 | return resource, updated_resource 56 | 57 | async def delete_resource(self, resource_id, **kwargs): 58 | try: 59 | rid = self.ctx.registry.ensure_identifier( 60 | {'type': self.ctx.resource_type, 'id': resource_id} 61 | ) 62 | removed_resource = self.storage[rid.type].pop(rid.id) 63 | except KeyError: 64 | raise ResourceNotFound(self.ctx.resource_type, resource_id) 65 | 66 | logger.debug('%r is removed.', removed_resource) 67 | 68 | 69 | class CommentsController(SimpleController): 70 | async def create_resource(self, data, **kwargs): 71 | rid = self.ctx.registry.ensure_identifier(data['author']['data']) 72 | author = self.storage[rid.type].get(rid.id) 73 | if author is None: 74 | raise ResourceNotFound(rid.type, rid.id) 75 | 76 | resource_cls = self.ctx.schema.opts.resource_cls 77 | new_resource = resource_cls( 78 | id=random.randint(1000, 9999), body=data['body'], 79 | author=author 80 | ) 81 | 82 | rid = self.ctx.registry.ensure_identifier(new_resource) 83 | self.storage[rid.type][rid.id] = new_resource 84 | 85 | logger.debug('%r is created.', new_resource) 86 | return new_resource 87 | 88 | -------------------------------------------------------------------------------- /examples/simple/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Simple JSON API application example with in-memory storage.""" 3 | 4 | import asyncio 5 | import logging 6 | from collections import defaultdict, OrderedDict 7 | 8 | import time 9 | from aiohttp import web 10 | 11 | from aiohttp_json_api import setup_jsonapi 12 | from aiohttp_json_api.common import JSONAPI 13 | 14 | 15 | def setup_fixtures(app): 16 | from examples.simple.models import Article, People, Comment 17 | 18 | registry = app[JSONAPI]['registry'] 19 | 20 | people = tuple(sorted(People.populate(), key=lambda p: p.id)) 21 | comments = tuple(sorted(Comment.populate(people), key=lambda c: c.id)) 22 | articles = tuple(sorted(Article.populate(comments, people), 23 | key=lambda a: a.id)) 24 | 25 | for resources in (people, comments, articles): 26 | for resource in resources: 27 | # Registry have a helper to return a ResourceID of instances 28 | # of registered resource classes 29 | resource_id = registry.ensure_identifier(resource) 30 | app['storage'][resource_id.type][resource_id.id] = resource 31 | 32 | return app 33 | 34 | 35 | async def init() -> web.Application: 36 | from examples.simple.controllers import ( 37 | SimpleController, CommentsController 38 | ) 39 | from examples.simple.schemas import ( 40 | ArticleSchema, CommentSchema, PeopleSchema 41 | ) 42 | 43 | app = web.Application(debug=True) 44 | app['storage'] = defaultdict(OrderedDict) 45 | 46 | # Note that we pass schema classes, not instances of them. 47 | # Schemas instances will be initialized application-wide. 48 | # Schema instance is stateless, therefore any request state must be passed 49 | # to each of Schema's method as JSONAPIContext instance. 50 | # JSONAPIContext instance created automatically in JSON API middleware 51 | # for each request. JSON API handlers use it in calls of Schema's methods. 52 | setup_jsonapi( 53 | app, 54 | { 55 | ArticleSchema: SimpleController, 56 | CommentSchema: CommentsController, 57 | PeopleSchema: SimpleController, 58 | }, 59 | meta={'example': {'version': '0.0.1'}} 60 | ) 61 | 62 | # After setup of JSON API application fixtures able to use Registry 63 | # if needed. In setup_fixtures function, Registry will be used 64 | # to get ResourceID as keys of resources saved to simple storage. 65 | setup_fixtures(app) 66 | 67 | return app 68 | 69 | 70 | def main(): 71 | loop = asyncio.get_event_loop() 72 | 73 | root = logging.getLogger() 74 | if root.handlers: 75 | for handler in root.handlers: 76 | root.removeHandler(handler) 77 | 78 | logging.basicConfig( 79 | level=logging.DEBUG, 80 | format='%(levelname)-8s [%(asctime)s.%(msecs)03d] ' 81 | '(%(name)s): %(message)s', 82 | datefmt='%Y-%m-%d %H:%M:%S' 83 | ) 84 | logging.Formatter.converter = time.gmtime 85 | 86 | app = loop.run_until_complete(init()) 87 | 88 | # More useful log format than default 89 | log_format = '%a (%{X-Real-IP}i) %t "%r" %s %b %Tf ' \ 90 | '"%{Referrer}i" "%{User-Agent}i"' 91 | web.run_app(app, access_log_format=log_format) 92 | 93 | 94 | if __name__ == '__main__': 95 | main() 96 | -------------------------------------------------------------------------------- /examples/simple/models.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import random 3 | from typing import Sequence, Generator 4 | 5 | from aiohttp_json_api.helpers import ensure_collection 6 | 7 | 8 | class BaseModel(abc.ABC): 9 | def __init__(self, id: int): 10 | self._id = id 11 | 12 | def __hash__(self): 13 | return hash((self.__class__.__name__, self._id)) 14 | 15 | def _repr(self, fields: Sequence[str]): 16 | """Smart representation helper for inherited models.""" 17 | fields = ', '.join( 18 | '{}={!r}'.format(field, getattr(self, field)) for field in fields 19 | ) 20 | return f'{self.__class__.__name__}({fields})' 21 | 22 | @property 23 | def id(self): 24 | return self._id 25 | 26 | @id.setter 27 | def id(self, value): 28 | self._id = int(value) 29 | 30 | 31 | class People(BaseModel): 32 | def __init__(self, id: int, first_name: str, last_name: str, 33 | twitter: str = None): 34 | super().__init__(id) 35 | self.first_name = first_name 36 | self.last_name = last_name 37 | self.twitter = twitter 38 | 39 | def __repr__(self): 40 | return self._repr(('id', 'first_name', 'last_name', 'twitter')) 41 | 42 | @staticmethod 43 | def populate(count=100) -> Generator['People', None, None]: 44 | import mimesis 45 | 46 | person = mimesis.Person() 47 | 48 | return ( 49 | People(id=int(person.identifier('####')), first_name=person.name(), 50 | last_name=person.surname(), twitter=person.username()) 51 | for _ in range(count) 52 | ) 53 | 54 | 55 | class Comment(BaseModel): 56 | def __init__(self, id: int, body: str, author: 'People'): 57 | super().__init__(id) 58 | self.body = body 59 | self.author = author 60 | 61 | def __repr__(self): 62 | return self._repr(('id', 'body', 'author')) 63 | 64 | @staticmethod 65 | def populate(authors: Sequence['People'], 66 | count=100) -> Generator['Comment', None, None]: 67 | import mimesis 68 | 69 | cid = mimesis.Numbers() 70 | comment = mimesis.Text() 71 | 72 | return ( 73 | Comment(id=cid.between(1, count), 74 | body=comment.sentence(), 75 | author=random.choice(authors)) 76 | for _ in range(count) 77 | ) 78 | 79 | 80 | class Article(BaseModel): 81 | def __init__(self, id: int, title: str, author: 'People', 82 | comments: Sequence['Comment']): 83 | super().__init__(id) 84 | self.title = title 85 | self.author = author 86 | self.comments = list(ensure_collection(comments)) 87 | 88 | def __repr__(self): 89 | return self._repr(('id', 'title', 'author', 'comments')) 90 | 91 | @staticmethod 92 | def populate(comments: Sequence['Comment'], authors: Sequence['People'], 93 | count=100) -> Generator['Article', None, None]: 94 | import mimesis 95 | 96 | aid = mimesis.Numbers() 97 | article = mimesis.Text() 98 | answers = list(comments) 99 | 100 | def get_random_answers(max): 101 | counter = 0 102 | while answers and counter < max: 103 | yield answers.pop(random.randint(0, len(answers) - 1)) 104 | counter += 1 105 | 106 | return ( 107 | Article( 108 | id=aid.between(1, count), 109 | title=article.title(), 110 | author=random.choice(authors), 111 | comments=[c for c in get_random_answers(random.randint(1, 10))] 112 | ) 113 | for _ in range(count) 114 | ) 115 | -------------------------------------------------------------------------------- /examples/simple/schemas.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiohttp_json_api.errors import ResourceNotFound 4 | from aiohttp_json_api.fields.decorators import sets 5 | from aiohttp_json_api.schema import BaseSchema 6 | from aiohttp_json_api.fields import attributes, relationships 7 | from aiohttp_json_api.common import Event, JSONAPI 8 | 9 | from .models import Article, Comment, People 10 | 11 | logger = logging.getLogger() 12 | 13 | 14 | class PeopleSchema(BaseSchema): 15 | first_name = attributes.String(required=Event.CREATE, max_length=128) 16 | last_name = attributes.String(required=Event.CREATE, allow_blank=True, 17 | max_length=128) 18 | twitter = attributes.String(allow_none=True, max_length=32) 19 | 20 | class Options: 21 | resource_cls = People 22 | resource_type = 'people' 23 | 24 | 25 | class CommentSchema(BaseSchema): 26 | body = attributes.String(required=Event.CREATE, max_length=1024) 27 | author = relationships.ToOne(required=Event.CREATE, 28 | foreign_types=('people',)) 29 | 30 | class Options: 31 | resource_cls = Comment 32 | resource_type = 'comments' 33 | 34 | @sets('author') 35 | async def set_author(self, field, resource, data, sp, context=None, 36 | **kwargs): 37 | rid = self.ctx.registry.ensure_identifier(data['data']) 38 | storage = self.ctx.app['storage'] 39 | author = storage[rid.type].get(rid.id) 40 | if author is None: 41 | raise ResourceNotFound(rid.type, rid.id) 42 | 43 | logger.debug('Set author of %r to %r.', resource, author) 44 | 45 | resource.author = author 46 | return resource 47 | 48 | 49 | class ArticleSchema(BaseSchema): 50 | title = attributes.String(required=Event.CREATE, max_length=256) 51 | author = relationships.ToOne(required=Event.CREATE, 52 | foreign_types=('people',)) 53 | comments = relationships.ToMany(foreign_types=('comments',)) 54 | 55 | class Options: 56 | resource_cls = Article 57 | resource_type = 'articles' 58 | 59 | # TODO: Create, update, add/remove comments 60 | -------------------------------------------------------------------------------- /punch_config.py: -------------------------------------------------------------------------------- 1 | __config_version__ = 1 2 | 3 | GLOBALS = { 4 | 'serializer': '{{major}}.{{minor}}.{{patch}}', 5 | } 6 | 7 | FILES = [ 8 | { 9 | 'path': 'setup.py', 10 | 'serializer': "version='{{ GLOBALS.serializer }}'," 11 | }, 12 | { 13 | 'path': 'aiohttp_json_api/__init__.py', 14 | 'serializer': "__version__ = '{{ GLOBALS.serializer }}'" 15 | }, 16 | ] 17 | 18 | VERSION = ['major', 'minor', 'patch'] 19 | 20 | VCS = { 21 | 'name': 'git', 22 | 'commit_message': 23 | "Version updated from {{ current_version }} to {{ new_version }}", 24 | } 25 | -------------------------------------------------------------------------------- /punch_version.py: -------------------------------------------------------------------------------- 1 | major = 0 2 | minor = 37 3 | patch = 0 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -i https://pypi.python.org/simple 2 | aiohttp==3.1.3 3 | async-timeout==2.0.1 4 | attrs==17.4.0 5 | chardet==3.0.4 6 | idna-ssl==1.0.1 7 | idna==2.7 8 | inflection==0.3.1 9 | jsonpointer==2.0 10 | multidict==4.2.0 11 | python-dateutil==2.7.2 12 | python-mimeparse==1.6.0 13 | six==1.11.0 14 | trafaret==1.1.1 15 | yarl==1.2.6 16 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | -i https://pypi.python.org/simple 3 | aiohttp==3.1.3 4 | aiopg==0.14.0 5 | alabaster==0.7.10 6 | argh==0.26.2 7 | asn1crypto==0.24.0 8 | astroid==1.6.3 9 | async-timeout==2.0.1 10 | attrs==17.4.0 11 | autopep8==1.3.5 12 | babel==2.5.3 13 | certifi==2018.4.16 14 | cffi==1.11.5; platform_python_implementation != 'PyPy' 15 | chardet==3.0.4 16 | coverage==4.5.1 17 | cryptography==2.3.1 18 | docker-pycreds==0.2.3 19 | docker==3.3.0 20 | docutils==0.14 21 | flake8-docstrings==1.3.0 22 | flake8-import-order==0.17.1 23 | flake8-polyfill==1.0.2 24 | flake8==3.5.0 25 | idna-ssl==1.0.1 26 | idna==2.7 27 | imagesize==1.0.0 28 | invoke==0.23.0 29 | isort==4.3.4 30 | jinja2==2.10 31 | jsonschema==2.6.0 32 | lazy-object-proxy==1.3.1 33 | markupsafe==1.0 34 | mccabe==0.6.1 35 | mimesis==2.0.1 36 | more-itertools==4.1.0 37 | multidict==4.2.0 38 | mypy==0.590 39 | packaging==17.1 40 | pathtools==0.1.2 41 | pep8==1.7.1 42 | pkginfo==1.4.2 43 | pluggy==0.6.0 44 | psycopg2==2.7.4 45 | punch.py==1.5.0 46 | py==1.5.3 47 | pycodestyle==2.3.1 48 | pycparser==2.18 49 | pydocstyle==2.1.1 50 | pyflakes==2.0.0 51 | pygments==2.2.0 52 | pylint==1.8.4 53 | pyparsing==2.2.0 54 | pytest-aiohttp==0.3.0 55 | pytest==3.5.1 56 | pytz==2018.4 57 | pyyaml==3.12 58 | requests-toolbelt==0.8.0 59 | requests==2.19.1 60 | six==1.11.0 61 | snowballstemmer==1.2.1 62 | sphinx-autodoc-typehints==1.3.0 63 | sphinx==1.7.5 64 | sphinxcontrib-websupport==1.0.1 65 | sqlalchemy==1.2.7 66 | tox-pyenv==1.1.0 67 | tox==3.0.0 68 | tqdm==4.23.1 69 | twine==1.11.0 70 | typed-ast==1.1.0 71 | urllib3==1.22 72 | virtualenv==15.2.0 73 | watchdog==0.8.3 74 | websocket-client==0.48.0 75 | wrapt==1.10.11 76 | yarl==1.2.6 77 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | python-tag = py36 3 | 4 | [flake8] 5 | exclude = 6 | docs, 7 | punch_config.py, 8 | punch_version.py, 9 | setup.py, 10 | travis_pypi_setup.py, 11 | ignore = D102 12 | import-order-style = edited 13 | 14 | [pydocstyle] 15 | ignore = D401,D212 16 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | with open('README.rst') as readme_file: 7 | readme = readme_file.read() 8 | 9 | with open('HISTORY.rst') as history_file: 10 | history = history_file.read() 11 | 12 | requirements = [ 13 | 'aiohttp>=2.0.0', 14 | 'inflection>=0.3.1', 15 | 'multidict>=3.3.0', 16 | 'jsonpointer>=1.10', 17 | 'python-dateutil>=2.6.0', 18 | 'python-mimeparse>=1.6.0', 19 | 'trafaret>=1.0.2', 20 | 'yarl>=0.13.0', 21 | ] 22 | 23 | test_requirements = [ 24 | # TODO: put package test requirements here 25 | ] 26 | 27 | setup( 28 | name='aiohttp_json_api', 29 | version='0.37.0', 30 | description="JSON API driven by aiohttp", 31 | long_description=readme + '\n\n' + history, 32 | author="Vladimir Bolshakov", 33 | author_email='vovanbo@gmail.com', 34 | url='https://github.com/vovanbo/aiohttp_json_api', 35 | packages=[ 36 | 'aiohttp_json_api', 37 | 'aiohttp_json_api.abc', 38 | 'aiohttp_json_api.fields', 39 | ], 40 | package_dir={'aiohttp_json_api': 41 | 'aiohttp_json_api'}, 42 | include_package_data=True, 43 | install_requires=requirements, 44 | license="MIT license", 45 | zip_safe=False, 46 | keywords='aiohttp_json_api', 47 | classifiers=[ 48 | 'Development Status :: 3 - Alpha', 49 | 'Intended Audience :: Developers', 50 | 'Framework :: AsyncIO', 51 | 'License :: OSI Approved :: MIT License', 52 | 'Natural Language :: English', 53 | 'Programming Language :: Python :: 3', 54 | 'Programming Language :: Python :: 3.6', 55 | 'Topic :: Internet :: WWW/HTTP', 56 | ], 57 | test_suite='tests', 58 | tests_require=test_requirements 59 | ) 60 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | import uuid 4 | 5 | import docker as libdocker 6 | import pathlib 7 | 8 | import invoke 9 | import psycopg2 10 | import pytest 11 | import time 12 | from jsonschema import Draft4Validator 13 | 14 | DSN_FORMAT = 'postgresql://{user}:{password}@{host}:{port}/{dbname}' 15 | 16 | 17 | @pytest.fixture(scope='session') 18 | def session_id(): 19 | return str(uuid.uuid4()) 20 | 21 | 22 | @pytest.fixture(scope='session') 23 | def docker(): 24 | return libdocker.APIClient() 25 | 26 | 27 | @pytest.fixture(scope='session') 28 | def unused_port(): 29 | def f(): 30 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 31 | s.bind(('127.0.0.1', 0)) 32 | return s.getsockname()[1] 33 | 34 | return f 35 | 36 | 37 | @pytest.fixture(scope='session') 38 | def here(): 39 | return pathlib.Path(__file__).parent 40 | 41 | 42 | @pytest.yield_fixture(scope='session') 43 | def pg_server(unused_port, session_id, docker): 44 | docker_image = 'postgres:10-alpine' 45 | database = 'example' 46 | user = 'example' 47 | password = 'somepassword' 48 | 49 | port = unused_port() 50 | host_config_options = {'port_bindings': {5432: port}} 51 | 52 | host_config = dict( 53 | tmpfs={'/var/lib/postgresql/data': ''}, 54 | **host_config_options 55 | ) 56 | 57 | docker.pull(docker_image) 58 | container = docker.create_container( 59 | image=docker_image, 60 | name=f'test-fantasy-example-{session_id}', 61 | ports=[5432], 62 | detach=True, 63 | environment={ 64 | 'POSTGRES_USER': user, 65 | 'POSTGRES_PASSWORD': password 66 | }, 67 | host_config=docker.create_host_config(**host_config) 68 | ) 69 | docker.start(container=container['Id']) 70 | 71 | host = '0.0.0.0' 72 | 73 | pg_params = dict(dbname=database, 74 | user=user, 75 | password=password, 76 | host=host, 77 | port=port, 78 | connect_timeout=2) 79 | 80 | delay = 0.001 81 | for i in range(20): 82 | try: 83 | conn = psycopg2.connect(**pg_params) 84 | conn.close() 85 | break 86 | except psycopg2.Error: 87 | time.sleep(delay) 88 | delay *= 2 89 | else: 90 | pytest.fail("Cannot start postgres server") 91 | 92 | inspection = docker.inspect_container(container['Id']) 93 | container['host'] = inspection['NetworkSettings']['IPAddress'] 94 | container['port'] = 5432 95 | container['pg_params'] = pg_params 96 | 97 | yield container 98 | 99 | docker.kill(container=container['Id']) 100 | docker.remove_container(container['Id']) 101 | 102 | 103 | @pytest.fixture(scope='session') 104 | def pg_params(pg_server): 105 | return dict(**pg_server['pg_params']) 106 | 107 | 108 | @pytest.fixture(scope='session') 109 | def populated_db(here, pg_params): 110 | from examples.fantasy.tasks import populate_db 111 | 112 | populate_db( 113 | invoke.context.Context(), 114 | data_folder=here.parent / 'examples' / 'fantasy' / 'fantasy-database', 115 | dsn=DSN_FORMAT.format(**pg_params) 116 | ) 117 | 118 | 119 | @pytest.fixture(scope='session') 120 | def jsonapi_validator(here): 121 | path = here / 'integration' / 'schema.dms' 122 | with open(path) as fp: 123 | schema = json.load(fp) 124 | 125 | Draft4Validator.check_schema(schema) 126 | return Draft4Validator(schema) 127 | 128 | 129 | @pytest.fixture 130 | async def fantasy_app(loop, pg_params, populated_db): 131 | from examples.fantasy.main import init 132 | return await init(DSN_FORMAT.format(**pg_params), debug=False, loop=loop) 133 | 134 | 135 | @pytest.fixture 136 | async def fantasy_client(fantasy_app, test_client): 137 | return await test_client(fantasy_app) 138 | -------------------------------------------------------------------------------- /tests/integration/schema.dms: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "JSON API Schema", 4 | "description": "This is a schema for responses in the JSON API format. For more, see http://jsonapi.org", 5 | "oneOf": [ 6 | { 7 | "$ref": "#/definitions/success" 8 | }, 9 | { 10 | "$ref": "#/definitions/failure" 11 | }, 12 | { 13 | "$ref": "#/definitions/info" 14 | } 15 | ], 16 | 17 | "definitions": { 18 | "success": { 19 | "type": "object", 20 | "required": [ 21 | "data" 22 | ], 23 | "properties": { 24 | "data": { 25 | "$ref": "#/definitions/data" 26 | }, 27 | "included": { 28 | "description": "To reduce the number of HTTP requests, servers **MAY** allow responses that include related resources along with the requested primary resources. Such responses are called \"compound documents\".", 29 | "type": "array", 30 | "items": { 31 | "$ref": "#/definitions/resource" 32 | }, 33 | "uniqueItems": true 34 | }, 35 | "meta": { 36 | "$ref": "#/definitions/meta" 37 | }, 38 | "links": { 39 | "description": "Link members related to the primary data.", 40 | "allOf": [ 41 | { 42 | "$ref": "#/definitions/links" 43 | }, 44 | { 45 | "$ref": "#/definitions/pagination" 46 | } 47 | ] 48 | }, 49 | "jsonapi": { 50 | "$ref": "#/definitions/jsonapi" 51 | } 52 | }, 53 | "additionalProperties": false 54 | }, 55 | "failure": { 56 | "type": "object", 57 | "required": [ 58 | "errors" 59 | ], 60 | "properties": { 61 | "errors": { 62 | "type": "array", 63 | "items": { 64 | "$ref": "#/definitions/error" 65 | }, 66 | "uniqueItems": true 67 | }, 68 | "meta": { 69 | "$ref": "#/definitions/meta" 70 | }, 71 | "jsonapi": { 72 | "$ref": "#/definitions/jsonapi" 73 | }, 74 | "links": { 75 | "$ref": "#/definitions/links" 76 | } 77 | }, 78 | "additionalProperties": false 79 | }, 80 | "info": { 81 | "type": "object", 82 | "required": [ 83 | "meta" 84 | ], 85 | "properties": { 86 | "meta": { 87 | "$ref": "#/definitions/meta" 88 | }, 89 | "links": { 90 | "$ref": "#/definitions/links" 91 | }, 92 | "jsonapi": { 93 | "$ref": "#/definitions/jsonapi" 94 | } 95 | }, 96 | "additionalProperties": false 97 | }, 98 | 99 | "meta": { 100 | "description": "Non-standard meta-information that can not be represented as an attribute or relationship.", 101 | "type": "object", 102 | "additionalProperties": true 103 | }, 104 | "data": { 105 | "description": "The document's \"primary data\" is a representation of the resource or collection of resources targeted by a request.", 106 | "oneOf": [ 107 | { 108 | "$ref": "#/definitions/resource" 109 | }, 110 | { 111 | "description": "An array of resource objects, an array of resource identifier objects, or an empty array ([]), for requests that target resource collections.", 112 | "type": "array", 113 | "items": { 114 | "$ref": "#/definitions/resource" 115 | }, 116 | "uniqueItems": true 117 | }, 118 | { 119 | "description": "null if the request is one that might correspond to a single resource, but doesn't currently.", 120 | "type": "null" 121 | } 122 | ] 123 | }, 124 | "resource": { 125 | "description": "\"Resource objects\" appear in a JSON API document to represent resources.", 126 | "type": "object", 127 | "required": [ 128 | "type", 129 | "id" 130 | ], 131 | "properties": { 132 | "type": { 133 | "type": "string" 134 | }, 135 | "id": { 136 | "type": "string" 137 | }, 138 | "attributes": { 139 | "$ref": "#/definitions/attributes" 140 | }, 141 | "relationships": { 142 | "$ref": "#/definitions/relationships" 143 | }, 144 | "links": { 145 | "$ref": "#/definitions/links" 146 | }, 147 | "meta": { 148 | "$ref": "#/definitions/meta" 149 | } 150 | }, 151 | "additionalProperties": false 152 | }, 153 | 154 | "relationshipLinks": { 155 | "description": "A resource object **MAY** contain references to other resource objects (\"relationships\"). Relationships may be to-one or to-many. Relationships can be specified by including a member in a resource's links object.", 156 | "type": "object", 157 | "properties": { 158 | "self": { 159 | "description": "A `self` member, whose value is a URL for the relationship itself (a \"relationship URL\"). This URL allows the client to directly manipulate the relationship. For example, it would allow a client to remove an `author` from an `article` without deleting the people resource itself.", 160 | "$ref": "#/definitions/link" 161 | }, 162 | "related": { 163 | "$ref": "#/definitions/link" 164 | } 165 | }, 166 | "additionalProperties": true 167 | }, 168 | "links": { 169 | "type": "object", 170 | "additionalProperties": { 171 | "$ref": "#/definitions/link" 172 | } 173 | }, 174 | "link": { 175 | "description": "A link **MUST** be represented as either: a string containing the link's URL or a link object.", 176 | "oneOf": [ 177 | { 178 | "description": "A string containing the link's URL.", 179 | "type": "string", 180 | "format": "uri-reference" 181 | }, 182 | { 183 | "type": "object", 184 | "required": [ 185 | "href" 186 | ], 187 | "properties": { 188 | "href": { 189 | "description": "A string containing the link's URL.", 190 | "type": "string", 191 | "format": "uri-reference" 192 | }, 193 | "meta": { 194 | "$ref": "#/definitions/meta" 195 | } 196 | } 197 | } 198 | ] 199 | }, 200 | 201 | "attributes": { 202 | "description": "Members of the attributes object (\"attributes\") represent information about the resource object in which it's defined.", 203 | "type": "object", 204 | "patternProperties": { 205 | "^(?!relationships$|links$|id$|type$)\\w[-\\w_]*$": { 206 | "description": "Attributes may contain any valid JSON value." 207 | } 208 | }, 209 | "additionalProperties": false 210 | }, 211 | 212 | "relationships": { 213 | "description": "Members of the relationships object (\"relationships\") represent references from the resource object in which it's defined to other resource objects.", 214 | "type": "object", 215 | "patternProperties": { 216 | "^(?!id$|type$)\\w[-\\w_]*$": { 217 | "properties": { 218 | "links": { 219 | "$ref": "#/definitions/relationshipLinks" 220 | }, 221 | "data": { 222 | "description": "Member, whose value represents \"resource linkage\".", 223 | "oneOf": [ 224 | { 225 | "$ref": "#/definitions/relationshipToOne" 226 | }, 227 | { 228 | "$ref": "#/definitions/relationshipToMany" 229 | } 230 | ] 231 | }, 232 | "meta": { 233 | "$ref": "#/definitions/meta" 234 | } 235 | }, 236 | "anyOf": [ 237 | {"required": ["data"]}, 238 | {"required": ["meta"]}, 239 | {"required": ["links"]} 240 | ], 241 | "additionalProperties": false 242 | } 243 | }, 244 | "additionalProperties": false 245 | }, 246 | "relationshipToOne": { 247 | "description": "References to other resource objects in a to-one (\"relationship\"). Relationships can be specified by including a member in a resource's links object.", 248 | "anyOf": [ 249 | { 250 | "$ref": "#/definitions/empty" 251 | }, 252 | { 253 | "$ref": "#/definitions/linkage" 254 | } 255 | ] 256 | }, 257 | "relationshipToMany": { 258 | "description": "An array of objects each containing \"type\" and \"id\" members for to-many relationships.", 259 | "type": "array", 260 | "items": { 261 | "$ref": "#/definitions/linkage" 262 | }, 263 | "uniqueItems": true 264 | }, 265 | "empty": { 266 | "description": "Describes an empty to-one relationship.", 267 | "type": "null" 268 | }, 269 | "linkage": { 270 | "description": "The \"type\" and \"id\" to non-empty members.", 271 | "type": "object", 272 | "required": [ 273 | "type", 274 | "id" 275 | ], 276 | "properties": { 277 | "type": { 278 | "type": "string" 279 | }, 280 | "id": { 281 | "type": "string" 282 | }, 283 | "meta": { 284 | "$ref": "#/definitions/meta" 285 | } 286 | }, 287 | "additionalProperties": false 288 | }, 289 | "pagination": { 290 | "type": "object", 291 | "properties": { 292 | "first": { 293 | "description": "The first page of data", 294 | "oneOf": [ 295 | { "type": "string", "format": "uri-reference" }, 296 | { "type": "null" } 297 | ] 298 | }, 299 | "last": { 300 | "description": "The last page of data", 301 | "oneOf": [ 302 | { "type": "string", "format": "uri-reference" }, 303 | { "type": "null" } 304 | ] 305 | }, 306 | "prev": { 307 | "description": "The previous page of data", 308 | "oneOf": [ 309 | { "type": "string", "format": "uri-reference" }, 310 | { "type": "null" } 311 | ] 312 | }, 313 | "next": { 314 | "description": "The next page of data", 315 | "oneOf": [ 316 | { "type": "string", "format": "uri-reference" }, 317 | { "type": "null" } 318 | ] 319 | } 320 | } 321 | }, 322 | 323 | "jsonapi": { 324 | "description": "An object describing the server's implementation", 325 | "type": "object", 326 | "properties": { 327 | "version": { 328 | "type": "string" 329 | }, 330 | "meta": { 331 | "$ref": "#/definitions/meta" 332 | } 333 | }, 334 | "additionalProperties": false 335 | }, 336 | 337 | "error": { 338 | "type": "object", 339 | "properties": { 340 | "id": { 341 | "description": "A unique identifier for this particular occurrence of the problem.", 342 | "type": "string" 343 | }, 344 | "links": { 345 | "$ref": "#/definitions/links" 346 | }, 347 | "status": { 348 | "description": "The HTTP status code applicable to this problem, expressed as a string value.", 349 | "type": "string" 350 | }, 351 | "code": { 352 | "description": "An application-specific error code, expressed as a string value.", 353 | "type": "string" 354 | }, 355 | "title": { 356 | "description": "A short, human-readable summary of the problem. It **SHOULD NOT** change from occurrence to occurrence of the problem, except for purposes of localization.", 357 | "type": "string" 358 | }, 359 | "detail": { 360 | "description": "A human-readable explanation specific to this occurrence of the problem.", 361 | "type": "string" 362 | }, 363 | "source": { 364 | "type": "object", 365 | "properties": { 366 | "pointer": { 367 | "description": "A JSON Pointer [RFC6901] to the associated entity in the request document [e.g. \"/data\" for a primary data object, or \"/data/attributes/title\" for a specific attribute].", 368 | "type": "string" 369 | }, 370 | "parameter": { 371 | "description": "A string indicating which query parameter caused the error.", 372 | "type": "string" 373 | } 374 | } 375 | }, 376 | "meta": { 377 | "$ref": "#/definitions/meta" 378 | } 379 | }, 380 | "additionalProperties": false 381 | } 382 | } 383 | } 384 | -------------------------------------------------------------------------------- /tests/integration/test_content_negotiation.py: -------------------------------------------------------------------------------- 1 | from aiohttp import hdrs 2 | 3 | 4 | class TestContentNegotiation: 5 | """Content Negotiation""" 6 | 7 | async def test_request_content_type(self, fantasy_client): 8 | """ 9 | Clients **MUST** send all JSON API data in request documents with 10 | the header `Content-Type: application/vnd.api+json` without any media 11 | type parameters. 12 | """ 13 | pass 14 | 15 | async def test_request_accept(self): 16 | """ 17 | Clients that include the JSON API media type in their `Accept` header 18 | **MUST** specify the media type there at least once without any media 19 | type parameters. 20 | """ 21 | pass 22 | 23 | async def test_response_ignore_parameters(self): 24 | """ 25 | Clients **MUST** ignore any parameters for the 26 | `application/vnd.api+json` media type received in the `Content-Type` 27 | header of response documents. 28 | """ 29 | pass 30 | 31 | async def test_response_content_type(self, fantasy_client): 32 | """ 33 | Servers **MUST** send all JSON API data in response documents with 34 | the header `Content-Type: application/vnd.api+json` without any media 35 | type parameters. 36 | """ 37 | response = await fantasy_client.get('/api/books/1') 38 | assert response.status == 200 39 | assert response.headers[hdrs.CONTENT_TYPE] == \ 40 | 'application/vnd.api+json' 41 | 42 | async def test_response_unsupported_media_type(self, fantasy_client): 43 | """ 44 | Servers **MUST** respond with a `415 Unsupported Media Type` status 45 | code if a request specifies the header 46 | `Content-Type: application/vnd.api+json` with any media type 47 | parameters. 48 | """ 49 | response = await fantasy_client.post( 50 | '/api/books', 51 | json={}, 52 | headers={hdrs.CONTENT_TYPE: 'application/vnd.api+json; foo=bar'} 53 | ) 54 | assert response.status == 415 55 | 56 | async def test_response_not_acceptable(self, fantasy_client): 57 | """ 58 | Servers **MUST** respond with a `406 Not Acceptable` status code 59 | if a request's `Accept` header contains the JSON API media type and 60 | all instances of that media type are modified with media type 61 | parameters. 62 | """ 63 | response = await fantasy_client.get( 64 | '/api/books/1', 65 | headers={hdrs.ACCEPT: 'application/vnd.api+json; foo=bar'} 66 | ) 67 | assert response.status == 406 68 | 69 | -------------------------------------------------------------------------------- /tests/integration/test_creating.py: -------------------------------------------------------------------------------- 1 | class TestCreating: 2 | """Creating Resources""" 3 | pass 4 | -------------------------------------------------------------------------------- /tests/integration/test_deleting.py: -------------------------------------------------------------------------------- 1 | class TestDeleting: 2 | """Deleting Resources""" 3 | pass 4 | -------------------------------------------------------------------------------- /tests/integration/test_document_structure.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiohttp_json_api.common import JSONAPI_CONTENT_TYPE 4 | 5 | 6 | class TestDocumentStructure: 7 | """Document Structure""" 8 | 9 | @pytest.mark.parametrize( 10 | 'resource_type', 11 | ('authors', 'books', 'chapters', 'photos', 'stores') 12 | ) 13 | async def test_response_by_json_schema(self, fantasy_client, 14 | jsonapi_validator, resource_type): 15 | response = await fantasy_client.get(f'/api/{resource_type}') 16 | json = await response.json(content_type=JSONAPI_CONTENT_TYPE) 17 | assert jsonapi_validator.is_valid(json) 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/integration/test_errors.py: -------------------------------------------------------------------------------- 1 | class TestErrors: 2 | """Errors""" 3 | pass 4 | 5 | -------------------------------------------------------------------------------- /tests/integration/test_query_parameters.py: -------------------------------------------------------------------------------- 1 | class TestQueryParameters: 2 | """Query Parameters""" 3 | pass 4 | -------------------------------------------------------------------------------- /tests/integration/test_reading.py: -------------------------------------------------------------------------------- 1 | class TestReading: 2 | """Fetching Resources""" 3 | pass 4 | -------------------------------------------------------------------------------- /tests/integration/test_updating.py: -------------------------------------------------------------------------------- 1 | class TestUpdating: 2 | """Updating Resrouces""" 3 | pass 4 | -------------------------------------------------------------------------------- /tests/schema/test_base_fields.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vovanbo/aiohttp_json_api/1d4864a0f73e4df33278e16d499642a60fa89aaa/tests/schema/test_base_fields.py -------------------------------------------------------------------------------- /tests/schema/test_trafarets.py: -------------------------------------------------------------------------------- 1 | import trafaret as t 2 | import decimal 3 | 4 | from aiohttp_json_api.fields.trafarets import DecimalTrafaret 5 | 6 | 7 | def test_decimal_repr(): 8 | res = DecimalTrafaret() 9 | assert repr(res) == '' 10 | res = DecimalTrafaret(gte=1) 11 | assert repr(res) == '' 12 | res = DecimalTrafaret(lte=10) 13 | assert repr(res) == '' 14 | res = DecimalTrafaret(gte=1, lte=10) 15 | assert repr(res) == '' 16 | 17 | 18 | def test_decimal(): 19 | res = DecimalTrafaret().check(1.0) 20 | assert res == decimal.Decimal(1.0) 21 | assert res == 1.0 22 | assert res == 1 23 | res = t.extract_error(DecimalTrafaret(), 1 + 3j) 24 | assert res == 'value is not Decimal' 25 | res = t.extract_error(DecimalTrafaret(), 'abc') 26 | assert res == "value can't be converted to Decimal" 27 | res = t.extract_error(DecimalTrafaret(), 1) 28 | assert res == decimal.Decimal(1) 29 | assert res == 1.0 30 | assert res == 1 31 | res = DecimalTrafaret(gte=2).check(3.0) 32 | assert res == decimal.Decimal(3.0) 33 | assert res == 3.0 34 | assert res == 3 35 | res = t.extract_error(DecimalTrafaret(gte=2), 1.0) 36 | assert res == 'value is less than 2' 37 | res = DecimalTrafaret(lte=10).check(5.0) 38 | assert res == decimal.Decimal(5.0) 39 | assert res == 5.0 40 | assert res == 5 41 | res = t.extract_error(DecimalTrafaret(lte=3), 5.0) 42 | assert res == 'value is greater than 3' 43 | res = DecimalTrafaret().check("5.0") 44 | assert res == decimal.Decimal(5.0) 45 | assert res == 5.0 46 | assert res == 5 47 | -------------------------------------------------------------------------------- /tests/spec/test_spec_schema.py: -------------------------------------------------------------------------------- 1 | from collections import MutableMapping 2 | 3 | from aiohttp import hdrs 4 | from jsonpointer import resolve_pointer 5 | 6 | from aiohttp_json_api.common import JSONAPI_CONTENT_TYPE, JSONAPI 7 | from aiohttp_json_api.helpers import MISSING, get_router_resource 8 | 9 | GET_HEADERS = {hdrs.ACCEPT: JSONAPI_CONTENT_TYPE} 10 | 11 | 12 | async def test_fetch_single_resource(fantasy_client): 13 | response = await fantasy_client.get('/api/books/1', headers=GET_HEADERS) 14 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 15 | 16 | assert response.status == 200 17 | assert isinstance(resolve_pointer(data, '/data'), MutableMapping) 18 | 19 | assert isinstance(resolve_pointer(data, '/data/type'), str) 20 | assert resolve_pointer(data, '/data/type') == 'books' 21 | 22 | assert isinstance(resolve_pointer(data, '/data/id'), str) 23 | assert resolve_pointer(data, '/data/id') == '1' 24 | 25 | assert resolve_pointer(data, '/data/attributes/title') == \ 26 | 'The Fellowship of the Ring' 27 | 28 | assert isinstance(resolve_pointer(data, '/data/relationships/author'), 29 | MutableMapping) 30 | assert resolve_pointer(data, '/data/relationships/author') 31 | 32 | assert isinstance(resolve_pointer(data, '/data/relationships/series'), 33 | MutableMapping) 34 | assert resolve_pointer(data, '/data/relationships/series') 35 | assert resolve_pointer(data, '/data/links/self') 36 | 37 | 38 | async def test_fetch_resource_not_found(fantasy_client): 39 | response = await fantasy_client.get('/api/books/9999', headers=GET_HEADERS) 40 | assert response.status == 404 41 | 42 | 43 | async def test_fetch_bad_request(fantasy_client): 44 | response = await fantasy_client.get('/api/books/foo', headers=GET_HEADERS) 45 | assert response.status == 400 46 | 47 | 48 | async def test_fetch_collection(fantasy_client): 49 | response = await fantasy_client.get('/api/books', headers=GET_HEADERS) 50 | assert response.status == 200 51 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 52 | books = resolve_pointer(data, '/data') 53 | for index in range(len(books)): 54 | assert resolve_pointer(data, f'/data/{index}/type') == 'books' 55 | 56 | 57 | async def test_fetch_single_resource_with_includes(fantasy_client): 58 | response = await fantasy_client.get('/api/books/1?include=author', 59 | headers=GET_HEADERS) 60 | assert response.status == 200 61 | 62 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 63 | assert resolve_pointer(data, '/data/type') == 'books' 64 | assert resolve_pointer(data, '/data/id') == '1' 65 | 66 | author_relationship = \ 67 | resolve_pointer(data, '/data/relationships/author/data') 68 | assert author_relationship['id'] == '1' 69 | assert author_relationship['type'] == 'authors' 70 | 71 | assert resolve_pointer(data, '/data/relationships/series') 72 | 73 | author = resolve_pointer(data, '/included/0') 74 | assert author['id'] == author_relationship['id'] 75 | assert author['type'] == author_relationship['type'] 76 | 77 | 78 | async def test_fetch_single_resource_with_includes_and_fields(fantasy_client): 79 | response = await fantasy_client.get( 80 | '/api/books/1?include=author&fields[books]=title', 81 | headers=GET_HEADERS 82 | ) 83 | assert response.status == 200 84 | 85 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 86 | assert resolve_pointer(data, '/data/type') == 'books' 87 | assert resolve_pointer(data, '/data/id') == '1' 88 | assert resolve_pointer(data, '/data/attributes/title') == \ 89 | 'The Fellowship of the Ring' 90 | assert resolve_pointer(data, '/data/attributes/date_published', MISSING) \ 91 | is MISSING 92 | 93 | for relationships in ('author', 'series'): 94 | assert resolve_pointer( 95 | data, f'/data/relationships/{relationships}', MISSING 96 | ) is MISSING 97 | 98 | author = resolve_pointer(data, '/included/0') 99 | assert author['id'] == '1' 100 | assert author['type'] == 'authors' 101 | 102 | 103 | async def test_jsonapi_object_spec(fantasy_client): 104 | response = await fantasy_client.get('/api/books/1', headers=GET_HEADERS) 105 | assert response.status == 200 106 | 107 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 108 | assert resolve_pointer(data, '/jsonapi/version') == '1.0' 109 | 110 | 111 | async def test_links_spec(fantasy_client, fantasy_app): 112 | response = await fantasy_client.get('/api/books/1', headers=GET_HEADERS) 113 | assert response.status == 200 114 | 115 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 116 | book_url = ( 117 | get_router_resource(fantasy_app, 'resource') 118 | .url_for(type='books', id='1') 119 | ) 120 | book_url = fantasy_client.make_url(book_url) 121 | 122 | assert resolve_pointer(data, '/links/self') == str(book_url) 123 | 124 | 125 | async def test_meta_object(fantasy_client, fantasy_app): 126 | response = await fantasy_client.get('/api/books/1', headers=GET_HEADERS) 127 | assert response.status == 200 128 | 129 | data = await response.json(content_type=JSONAPI_CONTENT_TYPE) 130 | meta_object = fantasy_app[JSONAPI]['meta'] 131 | assert resolve_pointer(data, '/meta') == meta_object 132 | assert resolve_pointer(data, '/meta/fantasy/version') == '0.0.1' 133 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36, flake8 3 | 4 | [testenv:flake8] 5 | basepython = python 6 | deps = 7 | flake8 8 | commands = 9 | pip install -U pip setuptools 10 | pipenv install --dev 11 | flake8 aiohttp_json_api 12 | 13 | [testenv] 14 | setenv = 15 | PYTHONPATH = {toxinidir} 16 | deps = 17 | pipenv==11.10 18 | commands = 19 | pip install -U pip setuptools 20 | pipenv install --dev 21 | pytest --basetemp={envtmpdir} {toxinidir}/tests 22 | -------------------------------------------------------------------------------- /travis_pypi_setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Update encrypted deploy password in Travis config file 4 | """ 5 | 6 | 7 | from __future__ import print_function 8 | import base64 9 | import json 10 | import os 11 | from getpass import getpass 12 | import yaml 13 | from cryptography.hazmat.primitives.serialization import load_pem_public_key 14 | from cryptography.hazmat.backends import default_backend 15 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 16 | 17 | 18 | try: 19 | from urllib import urlopen 20 | except: 21 | from urllib.request import urlopen 22 | 23 | 24 | GITHUB_REPO = 'vovanbo/aiohttp_json_api' 25 | TRAVIS_CONFIG_FILE = os.path.join( 26 | os.path.dirname(os.path.abspath(__file__)), '.travis.yml') 27 | 28 | 29 | def load_key(pubkey): 30 | """Load public RSA key, with work-around for keys using 31 | incorrect header/footer format. 32 | 33 | Read more about RSA encryption with cryptography: 34 | https://cryptography.io/latest/hazmat/primitives/asymmetric/rsa/ 35 | """ 36 | try: 37 | return load_pem_public_key(pubkey.encode(), default_backend()) 38 | except ValueError: 39 | # workaround for https://github.com/travis-ci/travis-api/issues/196 40 | pubkey = pubkey.replace('BEGIN RSA', 'BEGIN').replace('END RSA', 'END') 41 | return load_pem_public_key(pubkey.encode(), default_backend()) 42 | 43 | 44 | def encrypt(pubkey, password): 45 | """Encrypt password using given RSA public key and encode it with base64. 46 | 47 | The encrypted password can only be decrypted by someone with the 48 | private key (in this case, only Travis). 49 | """ 50 | key = load_key(pubkey) 51 | encrypted_password = key.encrypt(password, PKCS1v15()) 52 | return base64.b64encode(encrypted_password) 53 | 54 | 55 | def fetch_public_key(repo): 56 | """Download RSA public key Travis will use for this repo. 57 | 58 | Travis API docs: http://docs.travis-ci.com/api/#repository-keys 59 | """ 60 | keyurl = 'https://api.travis-ci.org/repos/{0}/key'.format(repo) 61 | data = json.loads(urlopen(keyurl).read().decode()) 62 | if 'key' not in data: 63 | errmsg = "Could not find public key for repo: {}.\n".format(repo) 64 | errmsg += "Have you already added your GitHub repo to Travis?" 65 | raise ValueError(errmsg) 66 | return data['key'] 67 | 68 | 69 | def prepend_line(filepath, line): 70 | """Rewrite a file adding a line to its beginning. 71 | """ 72 | with open(filepath) as f: 73 | lines = f.readlines() 74 | 75 | lines.insert(0, line) 76 | 77 | with open(filepath, 'w') as f: 78 | f.writelines(lines) 79 | 80 | 81 | def load_yaml_config(filepath): 82 | with open(filepath) as f: 83 | return yaml.load(f) 84 | 85 | 86 | def save_yaml_config(filepath, config): 87 | with open(filepath, 'w') as f: 88 | yaml.dump(config, f, default_flow_style=False) 89 | 90 | 91 | def update_travis_deploy_password(encrypted_password): 92 | """Update the deploy section of the .travis.yml file 93 | to use the given encrypted password. 94 | """ 95 | config = load_yaml_config(TRAVIS_CONFIG_FILE) 96 | 97 | config['deploy']['password'] = dict(secure=encrypted_password) 98 | 99 | save_yaml_config(TRAVIS_CONFIG_FILE, config) 100 | 101 | line = ('# This file was autogenerated and will overwrite' 102 | ' each time you run travis_pypi_setup.py\n') 103 | prepend_line(TRAVIS_CONFIG_FILE, line) 104 | 105 | 106 | def main(args): 107 | public_key = fetch_public_key(args.repo) 108 | password = args.password or getpass('PyPI password: ') 109 | update_travis_deploy_password(encrypt(public_key, password.encode())) 110 | print("Wrote encrypted password to .travis.yml -- you're ready to deploy") 111 | 112 | 113 | if '__main__' == __name__: 114 | import argparse 115 | parser = argparse.ArgumentParser(description=__doc__) 116 | parser.add_argument('--repo', default=GITHUB_REPO, 117 | help='GitHub repo (default: %s)' % GITHUB_REPO) 118 | parser.add_argument('--password', 119 | help='PyPI password (will prompt if not provided)') 120 | 121 | args = parser.parse_args() 122 | main(args) 123 | --------------------------------------------------------------------------------