├── .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 |
10 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------