Compare commits
	
		
			109 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 5058b58991 | ||
|  | 23f08ad5a5 | ||
|  | 77cef551a1 | ||
|  | 5c5a701daa | ||
|  | 5ead48ec92 | ||
|  | 1f2ac30106 | ||
|  | 483b457b14 | ||
|  | 3126c2fd2e | ||
|  | a45101637c | ||
|  | 937c09e2b7 | ||
|  | 82c638c1e0 | ||
|  | 7ce5e5d0d4 | ||
|  | 93e391b7b2 | ||
|  | a94c9d4863 | ||
|  | 26fd6fa19f | ||
|  | be944ac98e | ||
|  | b896020a4f | ||
|  | ba0530d6b1 | ||
|  | 83739c7c8e | ||
|  | 1dd98d2752 | ||
|  | 207204fe53 | ||
|  | 7618066b7f | ||
|  | 554e76ce51 | ||
|  | 2c51e9d929 | ||
|  | ce341f8611 | ||
|  | 3529809970 | ||
|  | 1f320c1ad8 | ||
|  | c4b5c20ff4 | ||
|  | 69141302cf | ||
|  | df2ef1adc0 | ||
|  | 76dd0106be | ||
|  | 9d488db276 | ||
|  | 4d7e5b0384 | ||
|  | 6c154c76ff | ||
|  | cd3a48c27a | ||
|  | 52bb0699e6 | ||
|  | 1181e2fc47 | ||
|  | c32da605d0 | ||
|  | 40dfded213 | ||
|  | 0e991070a5 | ||
|  | bf34914a8a | ||
|  | 4aee715e48 | ||
|  | c649905e69 | ||
|  | 4015c60cfa | ||
|  | a1dcc544cf | ||
|  | f278629217 | ||
|  | 4e8fb95c52 | ||
|  | 27c0d76e16 | ||
|  | 69fb553635 | ||
|  | 3648dde1ea | ||
|  | f5f3a48ba4 | ||
|  | 799080bbd0 | ||
|  | 4a49d3b53d | ||
|  | 1b10ebbcfa | ||
|  | fa7e8d914b | ||
|  | cb996860a9 | ||
|  | dbf1eb6ac4 | ||
|  | adcf4ba902 | ||
|  | a624aba613 | ||
|  | c1a63e55b2 | ||
|  | 9a624437f4 | ||
|  | 43d2789636 | ||
|  | 258a5cddf6 | ||
|  | 7ab2d84263 | ||
|  | 81138cc1c6 | ||
|  | c9c8c6e205 | ||
|  | 17220f2840 | ||
|  | ff32f68e89 | ||
|  | 911bcbc2cd | ||
|  | 89a22f2fcd | ||
|  | 08ab4d2610 | ||
|  | c92437c624 | ||
|  | 5f86e1efda | ||
|  | 324c9b02f3 | ||
|  | beb638c0af | ||
|  | 7492af5acf | ||
|  | 145d2fc0f2 | ||
|  | 81d4e93a1d | ||
|  | c6b979dcaf | ||
|  | 4ff9739293 | ||
|  | 071395e8bd | ||
|  | cd8422bde3 | ||
|  | 070d7e7259 | ||
|  | 16ba8caa5b | ||
|  | 53357214a8 | ||
|  | cdcd526fb4 | ||
|  | 25fcac18ec | ||
|  | f2b16a46b5 | ||
|  | c4c18ee4a1 | ||
|  | 93ec0f6c80 | ||
|  | a6d96d711b | ||
|  | 8aee135f95 | ||
|  | 462d8d8b98 | ||
|  | 0d3a33c964 | ||
|  | 22979b7e59 | ||
|  | b9519bb868 | ||
|  | 913f50298c | ||
|  | 03854cf939 | ||
|  | 2db23d3328 | ||
|  | d866ce5358 | ||
|  | 13c19105d8 | ||
|  | e4b23398b8 | ||
|  | 57b50725ea | ||
|  | cda4fba4c2 | ||
|  | 236374240e | ||
|  | 635f38e33a | ||
|  | 62d871fb5c | ||
|  | 77954cdd69 | ||
|  | d6b5fc26f3 | 
							
								
								
									
										73
									
								
								.drone.jsonnet
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								.drone.jsonnet
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | /* | ||||||
|  | Code to generate the .drone.yaml. Use the command: | ||||||
|  |  | ||||||
|  | drone jsonnet --stream --format yaml | ||||||
|  | */ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | local PYTHON_VERSIONS = ["3.8", "3.9"]; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | local BuildAndTestPipeline(name, image) = { | ||||||
|  |   kind: "pipeline", | ||||||
|  |   type: "docker", | ||||||
|  |   name: name, | ||||||
|  |   steps: [ | ||||||
|  |     { | ||||||
|  |       name: "Install package and test", | ||||||
|  |       image: image, | ||||||
|  |       commands: [ | ||||||
|  |         "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1  tasks.py\"", | ||||||
|  |         "pip install -U setuptools wheel pip; pip install invoke", | ||||||
|  |         "invoke prepare-upload" | ||||||
|  |       ] | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       name: "coverage", | ||||||
|  |       image: "plugins/codecov", | ||||||
|  |       settings: { | ||||||
|  |         token: "9ea10e04-a71a-4eea-9dcc-8eaabe1479e2", | ||||||
|  |         files: ["coverage.xml"] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   trigger: { | ||||||
|  |     event: ["pull_request", "push", "tag"] | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | [ | ||||||
|  |     BuildAndTestPipeline("python-" + std.strReplace(pythonVersion, '.', '-'), | ||||||
|  |              "python:" + pythonVersion) | ||||||
|  |     for pythonVersion in PYTHON_VERSIONS | ||||||
|  | ] + [ | ||||||
|  |     { | ||||||
|  |       kind: "pipeline", | ||||||
|  |       type: "docker", | ||||||
|  |       name: "Deploy on Pypi", | ||||||
|  |       steps: [ | ||||||
|  |         { | ||||||
|  |           name: "Install twine and deploy", | ||||||
|  |           image: "python:3.8", | ||||||
|  |           environment: { | ||||||
|  |             pypi_username: { | ||||||
|  |               from_secret: 'pypi_username' | ||||||
|  |             }, | ||||||
|  |             pypi_password: { | ||||||
|  |               from_secret: 'pypi_password' | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|  |           commands: [ | ||||||
|  |             "test \"$(md5sum tasks.py)\" = \"18f864b3ac76119938e3317e49b4ffa1  tasks.py\"", | ||||||
|  |             "pip install -U setuptools wheel pip; pip install invoke", | ||||||
|  |             "invoke upload --pypi-user \"$pypi_username\" --pypi-password \"$pypi_password\"" | ||||||
|  |           ] | ||||||
|  |         }, | ||||||
|  |       ], | ||||||
|  |       trigger: { | ||||||
|  |         event: ["tag"] | ||||||
|  |       }, | ||||||
|  |       depends_on: ["python-" + std.strReplace(pythonVersion, '.', '-') for pythonVersion in PYTHON_VERSIONS] | ||||||
|  |     } | ||||||
|  | ] | ||||||
							
								
								
									
										95
									
								
								.drone.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								.drone.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | |||||||
|  | --- | ||||||
|  | kind: pipeline | ||||||
|  | type: docker | ||||||
|  | name: python-3-8 | ||||||
|  |  | ||||||
|  | platform: | ||||||
|  |   os: linux | ||||||
|  |   arch: amd64 | ||||||
|  |  | ||||||
|  | steps: | ||||||
|  | - name: Install package and test | ||||||
|  |   image: python:3.8 | ||||||
|  |   commands: | ||||||
|  |   - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1  tasks.py" | ||||||
|  |   - pip install -U setuptools wheel pip; pip install invoke | ||||||
|  |   - invoke prepare-upload | ||||||
|  |  | ||||||
|  | - name: coverage | ||||||
|  |   image: plugins/codecov | ||||||
|  |   settings: | ||||||
|  |     files: | ||||||
|  |     - coverage.xml | ||||||
|  |     token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 | ||||||
|  |  | ||||||
|  | trigger: | ||||||
|  |   event: | ||||||
|  |   - pull_request | ||||||
|  |   - push | ||||||
|  |   - tag | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | kind: pipeline | ||||||
|  | type: docker | ||||||
|  | name: python-3-9 | ||||||
|  |  | ||||||
|  | platform: | ||||||
|  |   os: linux | ||||||
|  |   arch: amd64 | ||||||
|  |  | ||||||
|  | steps: | ||||||
|  | - name: Install package and test | ||||||
|  |   image: python:3.9 | ||||||
|  |   commands: | ||||||
|  |   - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1  tasks.py" | ||||||
|  |   - pip install -U setuptools wheel pip; pip install invoke | ||||||
|  |   - invoke prepare-upload | ||||||
|  |  | ||||||
|  | - name: coverage | ||||||
|  |   image: plugins/codecov | ||||||
|  |   settings: | ||||||
|  |     files: | ||||||
|  |     - coverage.xml | ||||||
|  |     token: 9ea10e04-a71a-4eea-9dcc-8eaabe1479e2 | ||||||
|  |  | ||||||
|  | trigger: | ||||||
|  |   event: | ||||||
|  |   - pull_request | ||||||
|  |   - push | ||||||
|  |   - tag | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | kind: pipeline | ||||||
|  | type: docker | ||||||
|  | name: Deploy on Pypi | ||||||
|  |  | ||||||
|  | platform: | ||||||
|  |   os: linux | ||||||
|  |   arch: amd64 | ||||||
|  |  | ||||||
|  | steps: | ||||||
|  | - name: Install twine and deploy | ||||||
|  |   image: python:3.8 | ||||||
|  |   commands: | ||||||
|  |   - test "$(md5sum tasks.py)" = "18f864b3ac76119938e3317e49b4ffa1  tasks.py" | ||||||
|  |   - pip install -U setuptools wheel pip; pip install invoke | ||||||
|  |   - invoke upload --pypi-user "$pypi_username" --pypi-password "$pypi_password" | ||||||
|  |   environment: | ||||||
|  |     pypi_password: | ||||||
|  |       from_secret: pypi_password | ||||||
|  |     pypi_username: | ||||||
|  |       from_secret: pypi_username | ||||||
|  |  | ||||||
|  | trigger: | ||||||
|  |   event: | ||||||
|  |   - tag | ||||||
|  |  | ||||||
|  | depends_on: | ||||||
|  | - python-3-8 | ||||||
|  | - python-3-9 | ||||||
|  |  | ||||||
|  | --- | ||||||
|  | kind: signature | ||||||
|  | hmac: 9a24ccae6182723af71257495d7843fd40874006c5e867cdebf363f497ddb839 | ||||||
|  |  | ||||||
|  | ... | ||||||
							
								
								
									
										24
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.gitea/workflows/release.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | name: Release | ||||||
|  | run-name: ${{ gitea.actor }} is runs ci pipeline | ||||||
|  |  | ||||||
|  | on: | ||||||
|  |   push: | ||||||
|  |     tags: | ||||||
|  |       - 'v*.*.*' | ||||||
|  |  | ||||||
|  | jobs: | ||||||
|  |   packaging: | ||||||
|  |     name: Distribution | ||||||
|  |     runs-on: ubuntu-latest | ||||||
|  |     steps: | ||||||
|  |       - uses: actions/checkout@v4 | ||||||
|  |       - name: Set up Python 3.11 | ||||||
|  |         uses: actions/setup-python@v5 | ||||||
|  |         with: | ||||||
|  |           python-version: '3.11' | ||||||
|  |       - name: Update version | ||||||
|  |         run: VSN=${{ gitea.ref_name }} && sed -i -e "s/1.12.1/${VSN:1}/g" aiohttp_pydantic/__init__.py | ||||||
|  |       - name: Install invoke | ||||||
|  |         run: python -m pip install setuptools wheel invoke | ||||||
|  |       - name: Push to PyPi | ||||||
|  |         run: invoke upload --pypi-user ${{ secrets.REPO_USER }} --pypi-password ${{ secrets.REPO_PASS }} --pypi-url https://git.ahax86.ru/api/packages/pub/pypi | ||||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,6 +1,11 @@ | |||||||
|  | .coverage | ||||||
| .idea/ | .idea/ | ||||||
|  | .pypirc | ||||||
| .pytest_cache | .pytest_cache | ||||||
| __pycache__ | __pycache__ | ||||||
| aiohttp_pydantic.egg-info/ | aiohttp_pydantic.egg-info/ | ||||||
| build/ | build/ | ||||||
|  | coverage.xml | ||||||
| dist/ | dist/ | ||||||
|  | dist_venv/ | ||||||
|  | venv/ | ||||||
							
								
								
									
										12
									
								
								.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | stages: | ||||||
|  |   - package | ||||||
|  |  | ||||||
|  | publish-pypi: | ||||||
|  |   stage: package | ||||||
|  |   image: python:3.11 | ||||||
|  |   script: | ||||||
|  |     - sed -i -e "s/1.12.1/${CI_COMMIT_TAG:1}/g" aiohttp_pydantic/__init__.py | ||||||
|  |     - pip install -U setuptools wheel pip; pip install invoke | ||||||
|  |     - invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL} | ||||||
|  |   only: | ||||||
|  |     - tags | ||||||
							
								
								
									
										17
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,17 +0,0 @@ | |||||||
| language: python |  | ||||||
| python: |  | ||||||
| - '3.8' |  | ||||||
| script: |  | ||||||
| - pytest tests/ |  | ||||||
| install: |  | ||||||
| - pip install -r test_requirements.txt |  | ||||||
| deploy: |  | ||||||
|   provider: pypi |  | ||||||
|   username: __token__ |  | ||||||
|   password: |  | ||||||
|     secure: ki81Limjj8UgsX1GNpOF2+vYjc6GEPY1V9BbJkQl+5WVTynqKTDEi+jekx8Id0jYEGGQ8/PfTiXe7dY/MqfQ0oWQ5+UNmGZIQJwYCft4FJWrI5QoL1LE0tqKpXCzBX7rGr1BOdvToS9zwf3RDr1u7ib16V/xakX55raVpQ37ttE0cKEPzvq6MqZTfYvq0VnhPmTDbTDBd9krHHAAG5lVhm9oAbp9TkhKsWDuA+wGzgKt2tuPX6+Le4op/wiiBhAnhvcVzjDWaX8dxd3Ac0XlnPtl8EMe5lJJez/ahGedydwGDJC75TOl1b7WP9AqogvNISVN+2VYUVxkgoK9yC9zEjhCSWKHSz+t8ZddB+itYHvj9lMf04iObq8OSUcD71R4rASWMZ89YdksWb6qvD+md1oEl/M6JSyZAkv+aedFL5iyKS4oJpZT3fYYloUqhF3/aDVgC3mlnXVsxC2cCIdpvu2EVjpFqFJ+9qGpp3ZlhRfDkjbQA0IA6KXKaWkIadQouJ4Wr1WtXjN4w0QlAvGV/q3m4bQ3ZZGxYipS9MQwDnUoRYtrX6j7bsaXjBdfhPNlwzgHQDPbD//oX9ZI1Oe6+kT/WKQvBrtvftv+TUhQ49uePHn5o/eYAKh35IwYTBxLgk2t483k0ZI5cjVXd2zGRgAxPdB/XyGW84dJGPJNn8o= |  | ||||||
|   distributions: "bdist_wheel" |  | ||||||
|   on: |  | ||||||
|     tags: true |  | ||||||
|     branch: main |  | ||||||
|     python: '3.8' |  | ||||||
							
								
								
									
										314
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										314
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,6 +1,29 @@ | |||||||
| Aiohttp pydantic - Aiohttp View to validate and parse request | Aiohttp pydantic - Aiohttp View to validate and parse request | ||||||
| ============================================================= | ============================================================= | ||||||
|  |  | ||||||
|  | .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg | ||||||
|  |   :target: https://cloud.drone.io/Maillol/aiohttp-pydantic | ||||||
|  |   :alt: Build status for master branch | ||||||
|  |  | ||||||
|  | .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||||
|  |   :target: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||||
|  |   :alt: Latest PyPI package version | ||||||
|  |  | ||||||
|  | .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg | ||||||
|  |   :target: https://codecov.io/gh/Maillol/aiohttp-pydantic | ||||||
|  |   :alt: codecov.io status for master branch | ||||||
|  |  | ||||||
|  | Aiohttp pydantic is an `aiohttp view`_ to easily parse and validate request. | ||||||
|  | You define using the function annotations what your methods for handling HTTP verbs expects and Aiohttp pydantic parses the HTTP request | ||||||
|  | for you, validates the data, and injects that you want as parameters. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Features: | ||||||
|  |  | ||||||
|  | - Query string, request body, URL path and HTTP headers validation. | ||||||
|  | - Open API Specification generation. | ||||||
|  |  | ||||||
|  |  | ||||||
| How to install | How to install | ||||||
| -------------- | -------------- | ||||||
|  |  | ||||||
| @@ -32,7 +55,7 @@ Example: | |||||||
|             return web.json_response({'name': article.name, |             return web.json_response({'name': article.name, | ||||||
|                                       'number_of_page': article.nb_page}) |                                       'number_of_page': article.nb_page}) | ||||||
|  |  | ||||||
|         async def get(self, with_comments: Optional[bool]): |         async def get(self, with_comments: bool=False): | ||||||
|             return web.json_response({'with_comments': with_comments}) |             return web.json_response({'with_comments': with_comments}) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -46,6 +69,7 @@ Example: | |||||||
|     $ curl -X GET http://127.0.0.1:8080/article?with_comments=a |     $ curl -X GET http://127.0.0.1:8080/article?with_comments=a | ||||||
|     [ |     [ | ||||||
|       { |       { | ||||||
|  |         "in": "query string", | ||||||
|         "loc": [ |         "loc": [ | ||||||
|           "with_comments" |           "with_comments" | ||||||
|         ], |         ], | ||||||
| @@ -60,6 +84,7 @@ Example: | |||||||
|     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' |     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' | ||||||
|     [ |     [ | ||||||
|       { |       { | ||||||
|  |         "in": "body", | ||||||
|         "loc": [ |         "loc": [ | ||||||
|           "name" |           "name" | ||||||
|         ], |         ], | ||||||
| @@ -77,7 +102,7 @@ API: | |||||||
| Inject Path Parameters | Inject Path Parameters | ||||||
| ~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| To declare a path parameters, you must declare your argument as a `positional-only parameters`_: | To declare a path parameter, you must declare your argument as a `positional-only parameters`_: | ||||||
|  |  | ||||||
|  |  | ||||||
| Example: | Example: | ||||||
| @@ -94,22 +119,40 @@ Example: | |||||||
| Inject Query String Parameters | Inject Query String Parameters | ||||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| To declare a query parameters, you must declare your argument as simple argument: | To declare a query parameter, you must declare your argument as a simple argument: | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
|  |  | ||||||
|     class AccountView(PydanticView): |     class AccountView(PydanticView): | ||||||
|         async def get(self, customer_id: str): |         async def get(self, customer_id: Optional[str] = None): | ||||||
|             ... |             ... | ||||||
|  |  | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_get('/customers', AccountView) |     app.router.add_get('/customers', AccountView) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | A query string parameter is generally optional and we do not want to force the user to set it in the URL. | ||||||
|  | It's recommended to define a default value. It's possible to get a multiple value for the same parameter using | ||||||
|  | the List type | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     from typing import List | ||||||
|  |     from pydantic import Field | ||||||
|  |  | ||||||
|  |     class AccountView(PydanticView): | ||||||
|  |         async def get(self, tags: List[str] = Field(default_factory=list)): | ||||||
|  |             ... | ||||||
|  |  | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_get('/customers', AccountView) | ||||||
|  |  | ||||||
|  |  | ||||||
| Inject Request Body | Inject Request Body | ||||||
| ~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| To declare a body parameters, you must declare your argument as a simple argument annotated with `pydantic Model`_. | To declare a body parameter, you must declare your argument as a simple argument annotated with `pydantic Model`_. | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
| @@ -128,7 +171,7 @@ To declare a body parameters, you must declare your argument as a simple argumen | |||||||
| Inject HTTP headers | Inject HTTP headers | ||||||
| ~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_. | To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_. | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
| @@ -144,3 +187,262 @@ To declare a HTTP headers parameters, you must declare your argument as a `keywo | |||||||
| .. _positional-only parameters: https://www.python.org/dev/peps/pep-0570/ | .. _positional-only parameters: https://www.python.org/dev/peps/pep-0570/ | ||||||
| .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ | .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ | ||||||
| .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | ||||||
|  |  | ||||||
|  | Add route to generate Open Api Specification (OAS) | ||||||
|  | -------------------------------------------------- | ||||||
|  |  | ||||||
|  | aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification | ||||||
|  | reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     from aiohttp import web | ||||||
|  |     from aiohttp_pydantic import oas | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     app = web.Application() | ||||||
|  |     oas.setup(app) | ||||||
|  |  | ||||||
|  | By default, the route to display the Open Api Specification is /oas but you can change it using | ||||||
|  | *url_prefix* parameter | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     oas.setup(app, url_prefix='/spec-api') | ||||||
|  |  | ||||||
|  | If you want generate the Open Api Specification from specific aiohttp sub-applications. | ||||||
|  | on the same route, you must use *apps_to_expose* parameter. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     from aiohttp import web | ||||||
|  |     from aiohttp_pydantic import oas | ||||||
|  |  | ||||||
|  |     app = web.Application() | ||||||
|  |     sub_app_1 = web.Application() | ||||||
|  |     sub_app_2 = web.Application() | ||||||
|  |  | ||||||
|  |     oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | You can change the title or the version of the generated open api specification using | ||||||
|  | *title_spec* and *version_spec* parameters: | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     oas.setup(app, title_spec="My application", version_spec="1.2.3") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Add annotation to define response content | ||||||
|  | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
|  | The module aiohttp_pydantic.oas.typing provides class to annotate a | ||||||
|  | response content. | ||||||
|  |  | ||||||
|  | For example *r200[List[Pet]]* means the server responses with | ||||||
|  | the status code 200 and the response content is a List of Pet where Pet will be | ||||||
|  | defined using a pydantic.BaseModel | ||||||
|  |  | ||||||
|  | The docstring of methods will be parsed to fill the descriptions in the | ||||||
|  | Open Api Specification. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     from aiohttp_pydantic import PydanticView | ||||||
|  |     from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class Pet(BaseModel): | ||||||
|  |         id: int | ||||||
|  |         name: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class Error(BaseModel): | ||||||
|  |         error: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class PetCollectionView(PydanticView): | ||||||
|  |         async def get(self) -> r200[List[Pet]]: | ||||||
|  |             """ | ||||||
|  |             Find all pets | ||||||
|  |  | ||||||
|  |             Tags: pet | ||||||
|  |             """ | ||||||
|  |             pets = self.request.app["model"].list_pets() | ||||||
|  |             return web.json_response([pet.dict() for pet in pets]) | ||||||
|  |  | ||||||
|  |         async def post(self, pet: Pet) -> r201[Pet]: | ||||||
|  |             """ | ||||||
|  |             Add a new pet to the store | ||||||
|  |  | ||||||
|  |             Tags: pet | ||||||
|  |             Status Codes: | ||||||
|  |                 201: The pet is created | ||||||
|  |             """ | ||||||
|  |             self.request.app["model"].add_pet(pet) | ||||||
|  |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class PetItemView(PydanticView): | ||||||
|  |         async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: | ||||||
|  |             """ | ||||||
|  |             Find a pet by ID | ||||||
|  |  | ||||||
|  |             Tags: pet | ||||||
|  |             Status Codes: | ||||||
|  |                 200: Successful operation | ||||||
|  |                 404: Pet not found | ||||||
|  |             """ | ||||||
|  |             pet = self.request.app["model"].find_pet(id) | ||||||
|  |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |         async def put(self, id: int, /, pet: Pet) -> r200[Pet]: | ||||||
|  |             """ | ||||||
|  |             Update an existing pet | ||||||
|  |  | ||||||
|  |             Tags: pet | ||||||
|  |             Status Codes: | ||||||
|  |                 200: successful operation | ||||||
|  |             """ | ||||||
|  |             self.request.app["model"].update_pet(id, pet) | ||||||
|  |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |         async def delete(self, id: int, /) -> r204: | ||||||
|  |             self.request.app["model"].remove_pet(id) | ||||||
|  |             return web.Response(status=204) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Group parameters | ||||||
|  | ---------------- | ||||||
|  |  | ||||||
|  | If your method has lot of parameters you can group them together inside one or several Groups. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     from aiohttp_pydantic.injectors import Group | ||||||
|  |  | ||||||
|  |     class Pagination(Group): | ||||||
|  |         page_num: int = 1 | ||||||
|  |         page_size: int = 15 | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|  |         async def get(self, page: Pagination): | ||||||
|  |             articles = Article.get(page.page_num, page.page_size) | ||||||
|  |             ... | ||||||
|  |  | ||||||
|  |  | ||||||
|  | The parameters page_num and page_size are expected in the query string, and | ||||||
|  | set inside a Pagination object passed as page parameter. | ||||||
|  |  | ||||||
|  | The code above is equivalent to: | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|  |         async def get(self, page_num: int = 1, page_size: int = 15): | ||||||
|  |             articles = Article.get(page_num, page_size) | ||||||
|  |             ... | ||||||
|  |  | ||||||
|  |  | ||||||
|  | You can add methods or properties to your Group. | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     class Pagination(Group): | ||||||
|  |         page_num: int = 1 | ||||||
|  |         page_size: int = 15 | ||||||
|  |  | ||||||
|  |         @property | ||||||
|  |         def num(self): | ||||||
|  |             return self.page_num | ||||||
|  |  | ||||||
|  |         @property | ||||||
|  |         def size(self): | ||||||
|  |             return self.page_size | ||||||
|  |  | ||||||
|  |         def slice(self): | ||||||
|  |             return slice(self.num, self.size) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|  |         async def get(self, page: Pagination): | ||||||
|  |             articles = Article.get(page.num, page.size) | ||||||
|  |             ... | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Custom Validation error | ||||||
|  | ----------------------- | ||||||
|  |  | ||||||
|  | You can redefine the on_validation_error hook in your PydanticView | ||||||
|  |  | ||||||
|  | .. code-block:: python3 | ||||||
|  |  | ||||||
|  |     class PetView(PydanticView): | ||||||
|  |  | ||||||
|  |         async def on_validation_error(self, | ||||||
|  |                                       exception: ValidationError, | ||||||
|  |                                       context: str): | ||||||
|  |             errors = exception.errors() | ||||||
|  |             for error in errors: | ||||||
|  |                 error["in"] = context  # context is "body", "headers", "path" or "query string" | ||||||
|  |                 error["custom"] = "your custom field ..." | ||||||
|  |             return json_response(data=errors, status=400) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | Demo | ||||||
|  | ---- | ||||||
|  |  | ||||||
|  | Have a look at `demo`_ for a complete example | ||||||
|  |  | ||||||
|  | .. code-block:: bash | ||||||
|  |  | ||||||
|  |     git clone https://github.com/Maillol/aiohttp-pydantic.git | ||||||
|  |     cd aiohttp-pydantic | ||||||
|  |     pip install . | ||||||
|  |     python -m demo | ||||||
|  |  | ||||||
|  | Go to http://127.0.0.1:8080/oas | ||||||
|  |  | ||||||
|  | You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command: | ||||||
|  |  | ||||||
|  | .. code-block:: bash | ||||||
|  |  | ||||||
|  |     python -m aiohttp_pydantic.oas demo.main | ||||||
|  |  | ||||||
|  | .. code-block:: bash | ||||||
|  |  | ||||||
|  |     $ python3 -m aiohttp_pydantic.oas  --help | ||||||
|  |     usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]] | ||||||
|  |  | ||||||
|  |     Generate Open API Specification | ||||||
|  |  | ||||||
|  |     positional arguments: | ||||||
|  |       APP                   The name of the module containing the asyncio.web.Application. By default the variable named | ||||||
|  |                             'app' is loaded but you can define an other variable name ending the name of module with : | ||||||
|  |                             characters and the name of variable. Example: my_package.my_module:my_app If your | ||||||
|  |                             asyncio.web.Application is returned by a function, you can use the syntax: | ||||||
|  |                             my_package.my_module:my_app() | ||||||
|  |  | ||||||
|  |     optional arguments: | ||||||
|  |       -h, --help            show this help message and exit | ||||||
|  |       -b FILE, --base-oas-file FILE | ||||||
|  |                             A file that will be used as base to generate OAS | ||||||
|  |       -o FILE, --output FILE | ||||||
|  |                             File to write the output | ||||||
|  |       -f FORMAT, --format FORMAT | ||||||
|  |                             The output format, can be 'json' or 'yaml' (default is json) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo | ||||||
|  | .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
| from .view import PydanticView | from .view import PydanticView | ||||||
|  |  | ||||||
| __all__ = ("PydanticView",) | __version__ = "1.12.1" | ||||||
|  |  | ||||||
|  | __all__ = ("PydanticView", "__version__") | ||||||
| @@ -1,11 +1,18 @@ | |||||||
| from typing import Callable, Tuple |  | ||||||
|  |  | ||||||
| from aiohttp.web_request import BaseRequest |  | ||||||
| from pydantic import BaseModel |  | ||||||
| from inspect import signature |  | ||||||
|  |  | ||||||
|  |  | ||||||
| import abc | import abc | ||||||
|  | import typing | ||||||
|  | from inspect import signature, getmro | ||||||
|  | from json.decoder import JSONDecodeError | ||||||
|  | from types import SimpleNamespace | ||||||
|  | from typing import Callable, Tuple, Literal, Type, get_type_hints | ||||||
|  |  | ||||||
|  | from aiohttp.web_exceptions import HTTPBadRequest | ||||||
|  | from aiohttp.web_request import BaseRequest | ||||||
|  | from multidict import MultiDict | ||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
|  | from .utils import is_pydantic_base_model, robuste_issubclass | ||||||
|  |  | ||||||
|  | CONTEXT = Literal["body", "headers", "path", "query string"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class AbstractInjector(metaclass=abc.ABCMeta): | class AbstractInjector(metaclass=abc.ABCMeta): | ||||||
| @@ -13,8 +20,18 @@ class AbstractInjector(metaclass=abc.ABCMeta): | |||||||
|     An injector parse HTTP request and inject params to the view. |     An injector parse HTTP request and inject params to the view. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     model: Type[BaseModel] | ||||||
|  |  | ||||||
|  |     @property | ||||||
|     @abc.abstractmethod |     @abc.abstractmethod | ||||||
|     def __init__(self, args_spec: dict): |     def context(self) -> CONTEXT: | ||||||
|  |         """ | ||||||
|  |         The name of part of parsed request | ||||||
|  |         i.e "HTTP header", "URL path", ... | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     @abc.abstractmethod | ||||||
|  |     def __init__(self, args_spec: dict, default_values: dict): | ||||||
|         """ |         """ | ||||||
|         args_spec - ordered mapping: arg_name -> type |         args_spec - ordered mapping: arg_name -> type | ||||||
|         """ |         """ | ||||||
| @@ -31,11 +48,15 @@ class MatchInfoGetter(AbstractInjector): | |||||||
|     Validates and injects the part of URL path inside the view positional args. |     Validates and injects the part of URL path inside the view positional args. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, args_spec: dict): |     context = "path" | ||||||
|         self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec}) |  | ||||||
|  |     def __init__(self, args_spec: dict, default_values: dict): | ||||||
|  |         attrs = {"__annotations__": args_spec} | ||||||
|  |         attrs.update(default_values) | ||||||
|  |         self.model = type("PathModel", (BaseModel,), attrs) | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         args_view.extend(self.model(**request.match_info).dict().values()) |         args_view.extend(self.model(**request.match_info).model_dump().values()) | ||||||
|  |  | ||||||
|  |  | ||||||
| class BodyGetter(AbstractInjector): | class BodyGetter(AbstractInjector): | ||||||
| @@ -43,12 +64,33 @@ class BodyGetter(AbstractInjector): | |||||||
|     Validates and injects the content of request body inside the view kwargs. |     Validates and injects the content of request body inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, args_spec: dict): |     context = "body" | ||||||
|  |  | ||||||
|  |     def __init__(self, args_spec: dict, default_values: dict): | ||||||
|         self.arg_name, self.model = next(iter(args_spec.items())) |         self.arg_name, self.model = next(iter(args_spec.items())) | ||||||
|  |         schema = self.model.model_json_schema() | ||||||
|  |         if "type" not in schema: | ||||||
|  |             schema["type"] = "object" | ||||||
|  |         self._expect_object = schema["type"] == "object" | ||||||
|  |  | ||||||
|     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|  |         try: | ||||||
|             body = await request.json() |             body = await request.json() | ||||||
|         kwargs_view[self.arg_name] = self.model(**body) |         except JSONDecodeError: | ||||||
|  |             raise HTTPBadRequest( | ||||||
|  |                 text='{"error": "Malformed JSON"}', content_type="application/json" | ||||||
|  |             ) from None | ||||||
|  |  | ||||||
|  |         # Pydantic tries to cast certain structures, such as a list of 2-tuples, | ||||||
|  |         # to a dict. Prevent this by requiring the body to be a dict for object models. | ||||||
|  |         if self._expect_object and not isinstance(body, dict): | ||||||
|  |             raise HTTPBadRequest( | ||||||
|  |                 text='[{"loc_in": "body", "loc": ["root"], "msg": "value is not a ' | ||||||
|  |                 'valid dict", "type": "type_error.dict"}]', | ||||||
|  |                 content_type="application/json", | ||||||
|  |             ) from None | ||||||
|  |  | ||||||
|  |         kwargs_view[self.arg_name] = self.model.parse_obj(body) | ||||||
|  |  | ||||||
|  |  | ||||||
| class QueryGetter(AbstractInjector): | class QueryGetter(AbstractInjector): | ||||||
| @@ -56,11 +98,49 @@ class QueryGetter(AbstractInjector): | |||||||
|     Validates and injects the query string inside the view kwargs. |     Validates and injects the query string inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, args_spec: dict): |     context = "query string" | ||||||
|         self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec}) |  | ||||||
|  |     def __init__(self, args_spec: dict, default_values: dict): | ||||||
|  |         args_spec = args_spec.copy() | ||||||
|  |  | ||||||
|  |         self._groups = {} | ||||||
|  |         for group_name, group in args_spec.items(): | ||||||
|  |             if robuste_issubclass(group, Group): | ||||||
|  |                 self._groups[group_name] = (group, _get_group_signature(group)[0]) | ||||||
|  |  | ||||||
|  |         _unpack_group_in_signature(args_spec, default_values) | ||||||
|  |         attrs = {"__annotations__": args_spec} | ||||||
|  |         attrs.update(default_values) | ||||||
|  |  | ||||||
|  |         self.model = type("QueryModel", (BaseModel,), attrs) | ||||||
|  |         self.args_spec = args_spec | ||||||
|  |         self._is_multiple = frozenset( | ||||||
|  |             name for name, spec in args_spec.items() if typing.get_origin(spec) is list | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         kwargs_view.update(self.model(**request.query).dict()) |         data = self._query_to_dict(request.query) | ||||||
|  |         cleaned = self.model(**data).model_dump() | ||||||
|  |         for group_name, (group_cls, group_attrs) in self._groups.items(): | ||||||
|  |             group = group_cls() | ||||||
|  |             for attr_name in group_attrs: | ||||||
|  |                 setattr(group, attr_name, cleaned.pop(attr_name)) | ||||||
|  |             cleaned[group_name] = group | ||||||
|  |         kwargs_view.update(**cleaned) | ||||||
|  |  | ||||||
|  |     def _query_to_dict(self, query: MultiDict): | ||||||
|  |         """ | ||||||
|  |         Return a dict with list as value from the MultiDict. | ||||||
|  |  | ||||||
|  |         The value will be wrapped in a list if the args spec is define as a list or if | ||||||
|  |         the multiple values are sent (i.e ?foo=1&foo=2) | ||||||
|  |         """ | ||||||
|  |         return { | ||||||
|  |             key: values | ||||||
|  |             if len(values := query.getall(key)) > 1 or key in self._is_multiple | ||||||
|  |             else value | ||||||
|  |             for key, value in query.items() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class HeadersGetter(AbstractInjector): | class HeadersGetter(AbstractInjector): | ||||||
| @@ -68,42 +148,162 @@ class HeadersGetter(AbstractInjector): | |||||||
|     Validates and injects the HTTP headers inside the view kwargs. |     Validates and injects the HTTP headers inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, args_spec: dict): |     context = "headers" | ||||||
|         self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec}) |  | ||||||
|  |     def __init__(self, args_spec: dict, default_values: dict): | ||||||
|  |         args_spec = args_spec.copy() | ||||||
|  |  | ||||||
|  |         self._groups = {} | ||||||
|  |         for group_name, group in args_spec.items(): | ||||||
|  |             if robuste_issubclass(group, Group): | ||||||
|  |                 self._groups[group_name] = (group, _get_group_signature(group)[0]) | ||||||
|  |  | ||||||
|  |         _unpack_group_in_signature(args_spec, default_values) | ||||||
|  |  | ||||||
|  |         attrs = {"__annotations__": args_spec} | ||||||
|  |         attrs.update(default_values) | ||||||
|  |         self.model = type("HeaderModel", (BaseModel,), attrs) | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} |         header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} | ||||||
|         kwargs_view.update(self.model(**header).dict()) |         cleaned = self.model(**header).model_dump() | ||||||
|  |         for group_name, (group_cls, group_attrs) in self._groups.items(): | ||||||
|  |             group = group_cls() | ||||||
|  |             for attr_name in group_attrs: | ||||||
|  |                 setattr(group, attr_name, cleaned.pop(attr_name)) | ||||||
|  |             cleaned[group_name] = group | ||||||
|  |         kwargs_view.update(cleaned) | ||||||
|  |  | ||||||
|  |  | ||||||
| def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]: | class Group(SimpleNamespace): | ||||||
|     """ |     """ | ||||||
|     Analyse function signature and returns 4-tuple: |     Class to group header or query string parameters. | ||||||
|  |  | ||||||
|  |     The parameter from query string or header will be set in the group | ||||||
|  |     and the group will be passed as function parameter. | ||||||
|  |  | ||||||
|  |     Example: | ||||||
|  |  | ||||||
|  |     class Pagination(Group): | ||||||
|  |         current_page: int = 1 | ||||||
|  |         page_size: int = 15 | ||||||
|  |  | ||||||
|  |     class PetView(PydanticView): | ||||||
|  |         def get(self, page: Pagination): | ||||||
|  |             ... | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _get_group_signature(cls) -> Tuple[dict, dict]: | ||||||
|  |     """ | ||||||
|  |     Analyse Group subclass annotations and return them with default values. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     sig = {} | ||||||
|  |     defaults = {} | ||||||
|  |     mro = getmro(cls) | ||||||
|  |     for base in reversed(mro[: mro.index(Group)]): | ||||||
|  |         attrs = vars(base) | ||||||
|  |  | ||||||
|  |         # Use __annotations__ to know if an attribute is | ||||||
|  |         # overwrite to remove the default value. | ||||||
|  |         for attr_name, type_ in base.__annotations__.items(): | ||||||
|  |             if (default := attrs.get(attr_name)) is None: | ||||||
|  |                 defaults.pop(attr_name, None) | ||||||
|  |             else: | ||||||
|  |                 defaults[attr_name] = default | ||||||
|  |  | ||||||
|  |         # Use get_type_hints to have postponed annotations. | ||||||
|  |         for attr_name, type_ in get_type_hints(base).items(): | ||||||
|  |             sig[attr_name] = type_ | ||||||
|  |  | ||||||
|  |     return sig, defaults | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _parse_func_signature( | ||||||
|  |     func: Callable, unpack_group: bool = False | ||||||
|  | ) -> Tuple[dict, dict, dict, dict, dict]: | ||||||
|  |     """ | ||||||
|  |     Analyse function signature and returns 5-tuple: | ||||||
|         0 - arguments will be set from the url path |         0 - arguments will be set from the url path | ||||||
|         1 - argument will be set from the request body. |         1 - argument will be set from the request body. | ||||||
|         2 - argument will be set from the query string. |         2 - argument will be set from the query string. | ||||||
|         3 - argument will be set from the HTTP headers. |         3 - argument will be set from the HTTP headers. | ||||||
|  |         4 - Default value for each parameters | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     path_args = {} |     path_args = {} | ||||||
|     body_args = {} |     body_args = {} | ||||||
|     qs_args = {} |     qs_args = {} | ||||||
|     header_args = {} |     header_args = {} | ||||||
|  |     defaults = {} | ||||||
|  |  | ||||||
|  |     annotations = get_type_hints(func) | ||||||
|     for param_name, param_spec in signature(func).parameters.items(): |     for param_name, param_spec in signature(func).parameters.items(): | ||||||
|  |  | ||||||
|         if param_name == "self": |         if param_name == "self": | ||||||
|             continue |             continue | ||||||
|  |  | ||||||
|  |         if param_spec.annotation == param_spec.empty: | ||||||
|  |             raise RuntimeError(f"The parameter {param_name} must have an annotation") | ||||||
|  |  | ||||||
|  |         annotation = annotations[param_name] | ||||||
|  |         if param_spec.default is not param_spec.empty: | ||||||
|  |             defaults[param_name] = param_spec.default | ||||||
|  |  | ||||||
|         if param_spec.kind is param_spec.POSITIONAL_ONLY: |         if param_spec.kind is param_spec.POSITIONAL_ONLY: | ||||||
|             path_args[param_name] = param_spec.annotation |             path_args[param_name] = annotation | ||||||
|  |  | ||||||
|         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: |         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: | ||||||
|             if issubclass(param_spec.annotation, BaseModel): |             if is_pydantic_base_model(annotation): | ||||||
|                 body_args[param_name] = param_spec.annotation |                 body_args[param_name] = annotation | ||||||
|             else: |             else: | ||||||
|                 qs_args[param_name] = param_spec.annotation |                 qs_args[param_name] = annotation | ||||||
|         elif param_spec.kind is param_spec.KEYWORD_ONLY: |         elif param_spec.kind is param_spec.KEYWORD_ONLY: | ||||||
|             header_args[param_name] = param_spec.annotation |             header_args[param_name] = annotation | ||||||
|         else: |         else: | ||||||
|             raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") |             raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") | ||||||
|  |  | ||||||
|     return path_args, body_args, qs_args, header_args |     if unpack_group: | ||||||
|  |         try: | ||||||
|  |             _unpack_group_in_signature(qs_args, defaults) | ||||||
|  |             _unpack_group_in_signature(header_args, defaults) | ||||||
|  |         except DuplicateNames as error: | ||||||
|  |             raise TypeError( | ||||||
|  |                 f"Parameters conflict in function {func}," | ||||||
|  |                 f" the group {error.group} has an attribute named {error.attr_name}" | ||||||
|  |             ) from None | ||||||
|  |  | ||||||
|  |     return path_args, body_args, qs_args, header_args, defaults | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DuplicateNames(Exception): | ||||||
|  |     """ | ||||||
|  |     Raised when a same parameter name is used in group and function signature. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     group: Type[Group] | ||||||
|  |     attr_name: str | ||||||
|  |  | ||||||
|  |     def __init__(self, group: Type[Group], attr_name: str): | ||||||
|  |         self.group = group | ||||||
|  |         self.attr_name = attr_name | ||||||
|  |         super().__init__( | ||||||
|  |             f"Conflict with {group}.{attr_name} and function parameter name" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _unpack_group_in_signature(args: dict, defaults: dict) -> None: | ||||||
|  |     """ | ||||||
|  |     Unpack in place each Group found in args. | ||||||
|  |     """ | ||||||
|  |     for group_name, group in args.copy().items(): | ||||||
|  |         if robuste_issubclass(group, Group): | ||||||
|  |             group_sig, group_default = _get_group_signature(group) | ||||||
|  |             for attr_name in group_sig: | ||||||
|  |                 if attr_name in args and attr_name != group_name: | ||||||
|  |                     raise DuplicateNames(group, attr_name) | ||||||
|  |  | ||||||
|  |             del args[group_name] | ||||||
|  |             args.update(group_sig) | ||||||
|  |             defaults.update(group_default) | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								aiohttp_pydantic/oas/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								aiohttp_pydantic/oas/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | from importlib import resources | ||||||
|  | from typing import Iterable, Optional | ||||||
|  |  | ||||||
|  | import jinja2 | ||||||
|  | from aiohttp import web | ||||||
|  | from swagger_ui_bundle import swagger_ui_path | ||||||
|  |  | ||||||
|  | from .view import get_oas, oas_ui | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def setup( | ||||||
|  |     app: web.Application, | ||||||
|  |     apps_to_expose: Iterable[web.Application] = (), | ||||||
|  |     url_prefix: str = "/oas", | ||||||
|  |     enable: bool = True, | ||||||
|  |     version_spec: Optional[str] = None, | ||||||
|  |     title_spec: Optional[str] = None, | ||||||
|  |     custom_template: Optional[jinja2.Template] = None | ||||||
|  | ): | ||||||
|  |     if enable: | ||||||
|  |         oas_app = web.Application() | ||||||
|  |         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) | ||||||
|  |         oas_app["index template"] = custom_template or jinja2.Template( | ||||||
|  |             resources.read_text("aiohttp_pydantic.oas", "index.j2") | ||||||
|  |         ) | ||||||
|  |         oas_app["version_spec"] = version_spec | ||||||
|  |         oas_app["title_spec"] = title_spec | ||||||
|  |  | ||||||
|  |         oas_app.router.add_get("/spec", get_oas, name="spec") | ||||||
|  |         oas_app.router.add_static("/static", swagger_ui_path, name="static") | ||||||
|  |         oas_app.router.add_get("", oas_ui, name="index") | ||||||
|  |  | ||||||
|  |         app.add_subapp(url_prefix, oas_app) | ||||||
							
								
								
									
										8
									
								
								aiohttp_pydantic/oas/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								aiohttp_pydantic/oas/__main__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | |||||||
|  | import argparse | ||||||
|  |  | ||||||
|  | from .cmd import setup | ||||||
|  |  | ||||||
|  | parser = argparse.ArgumentParser(description="Generate Open API Specification") | ||||||
|  | setup(parser) | ||||||
|  | args = parser.parse_args() | ||||||
|  | args.func(args) | ||||||
							
								
								
									
										133
									
								
								aiohttp_pydantic/oas/cmd.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								aiohttp_pydantic/oas/cmd.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | |||||||
|  | import argparse | ||||||
|  | import importlib | ||||||
|  | import json | ||||||
|  | from typing import Dict, Protocol, Optional, Callable | ||||||
|  | import sys | ||||||
|  | from .view import generate_oas | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class YamlModule(Protocol): | ||||||
|  |     """ | ||||||
|  |     Yaml Module type hint | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def dump(self, data) -> str: | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | yaml: Optional[YamlModule] | ||||||
|  |  | ||||||
|  | try: | ||||||
|  |     import yaml | ||||||
|  | except ImportError: | ||||||
|  |     yaml = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def application_type(value): | ||||||
|  |     """ | ||||||
|  |     Return aiohttp application defined in the value. | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         module_name, app_name = value.split(":") | ||||||
|  |     except ValueError: | ||||||
|  |         module_name, app_name = value, "app" | ||||||
|  |  | ||||||
|  |     module = importlib.import_module(module_name) | ||||||
|  |     try: | ||||||
|  |         if app_name.endswith("()"): | ||||||
|  |             app_name = app_name.strip("()") | ||||||
|  |             factory_app = getattr(module, app_name) | ||||||
|  |             return factory_app() | ||||||
|  |         return getattr(module, app_name) | ||||||
|  |  | ||||||
|  |     except AttributeError as error: | ||||||
|  |         raise argparse.ArgumentTypeError(error) from error | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def base_oas_file_type(value) -> Dict: | ||||||
|  |     """ | ||||||
|  |     Load base oas file | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         with open(value) as oas_file: | ||||||
|  |             data = oas_file.read() | ||||||
|  |     except OSError as error: | ||||||
|  |         raise argparse.ArgumentTypeError(error) from error | ||||||
|  |  | ||||||
|  |     return json.loads(data) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def format_type(value) -> Callable: | ||||||
|  |     """ | ||||||
|  |     Date Dumper one of (json, yaml) | ||||||
|  |     """ | ||||||
|  |     dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)} | ||||||
|  |     if yaml is not None: | ||||||
|  |         dumpers["yaml"] = yaml.dump | ||||||
|  |  | ||||||
|  |     try: | ||||||
|  |         return dumpers[value] | ||||||
|  |     except KeyError: | ||||||
|  |         raise argparse.ArgumentTypeError( | ||||||
|  |             f"Wrong format value. (allowed values: {tuple(dumpers.keys())})" | ||||||
|  |         ) from None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def setup(parser: argparse.ArgumentParser): | ||||||
|  |     parser.add_argument( | ||||||
|  |         "apps", | ||||||
|  |         metavar="APP", | ||||||
|  |         type=application_type, | ||||||
|  |         nargs="*", | ||||||
|  |         help="The name of the module containing the asyncio.web.Application." | ||||||
|  |         " By default the variable named 'app' is loaded but you can define" | ||||||
|  |         " an other variable name ending the name of module with : characters" | ||||||
|  |         " and the name of variable. Example: my_package.my_module:my_app" | ||||||
|  |         " If your asyncio.web.Application is returned by a function, you can" | ||||||
|  |         " use the syntax: my_package.my_module:my_app()", | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-b", | ||||||
|  |         "--base-oas-file", | ||||||
|  |         metavar="FILE", | ||||||
|  |         dest="base", | ||||||
|  |         type=base_oas_file_type, | ||||||
|  |         help="A file that will be used as base to generate OAS", | ||||||
|  |         default={}, | ||||||
|  |     ) | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-o", | ||||||
|  |         "--output", | ||||||
|  |         metavar="FILE", | ||||||
|  |         type=argparse.FileType("w"), | ||||||
|  |         help="File to write the output", | ||||||
|  |         default=sys.stdout, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     if yaml: | ||||||
|  |         help_output_format = ( | ||||||
|  |             "The output format, can be 'json' or 'yaml' (default is json)" | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format" | ||||||
|  |  | ||||||
|  |     parser.add_argument( | ||||||
|  |         "-f", | ||||||
|  |         "--format", | ||||||
|  |         metavar="FORMAT", | ||||||
|  |         dest="formatter", | ||||||
|  |         type=format_type, | ||||||
|  |         help=help_output_format, | ||||||
|  |         default=format_type("json"), | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     parser.set_defaults(func=show_oas) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def show_oas(args: argparse.Namespace): | ||||||
|  |     """ | ||||||
|  |     Display Open API Specification on the stdout. | ||||||
|  |     """ | ||||||
|  |     spec = args.base | ||||||
|  |     spec.update(generate_oas(args.apps)) | ||||||
|  |     print(args.formatter(spec), file=args.output) | ||||||
							
								
								
									
										149
									
								
								aiohttp_pydantic/oas/docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								aiohttp_pydantic/oas/docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,149 @@ | |||||||
|  | """ | ||||||
|  | Utility to extract extra OAS description from docstring. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | import re | ||||||
|  | import textwrap | ||||||
|  | from typing import Dict, List | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class LinesIterator: | ||||||
|  |     def __init__(self, lines: str): | ||||||
|  |         self._lines = lines.splitlines() | ||||||
|  |         self._i = -1 | ||||||
|  |  | ||||||
|  |     def next_line(self) -> str: | ||||||
|  |         if self._i == len(self._lines) - 1: | ||||||
|  |             raise StopIteration from None | ||||||
|  |         self._i += 1 | ||||||
|  |         return self._lines[self._i] | ||||||
|  |  | ||||||
|  |     def rewind(self) -> str: | ||||||
|  |         if self._i == -1: | ||||||
|  |             raise StopIteration from None | ||||||
|  |         self._i -= 1 | ||||||
|  |         return self._lines[self._i] | ||||||
|  |  | ||||||
|  |     def __iter__(self): | ||||||
|  |         return self | ||||||
|  |  | ||||||
|  |     def __next__(self): | ||||||
|  |         return self.next_line() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _i_extract_block(lines: LinesIterator): | ||||||
|  |     """ | ||||||
|  |     Iter the line within an indented block and dedent them. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     # Go to the first not empty or not white space line. | ||||||
|  |     try: | ||||||
|  |         line = next(lines) | ||||||
|  |     except StopIteration: | ||||||
|  |         return  # No block to extract. | ||||||
|  |     while line.strip() == "": | ||||||
|  |         try: | ||||||
|  |             line = next(lines) | ||||||
|  |         except StopIteration: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |     indent = re.fullmatch("( *).*", line).groups()[0] | ||||||
|  |     indentation = len(indent) | ||||||
|  |     start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*") | ||||||
|  |     yield line[indentation:] | ||||||
|  |  | ||||||
|  |     # Yield lines until the indentation is the same or is greater than | ||||||
|  |     # the first block line. | ||||||
|  |     try: | ||||||
|  |         line = next(lines) | ||||||
|  |     except StopIteration: | ||||||
|  |         return | ||||||
|  |     while not start_of_other_block.fullmatch(line): | ||||||
|  |         yield line[indentation:] | ||||||
|  |         try: | ||||||
|  |             line = next(lines) | ||||||
|  |         except StopIteration: | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |     lines.rewind() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _dedent_under_first_line(text: str) -> str: | ||||||
|  |     """ | ||||||
|  |     Apply textwrap.dedent ignoring the first line. | ||||||
|  |     """ | ||||||
|  |     lines = text.splitlines() | ||||||
|  |     other_lines = "\n".join(lines[1:]) | ||||||
|  |     if other_lines: | ||||||
|  |         return f"{lines[0]}\n{textwrap.dedent(other_lines)}" | ||||||
|  |     return text | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def status_code(docstring: str) -> Dict[int, str]: | ||||||
|  |     """ | ||||||
|  |     Extract the "Status Code:" block of the docstring. | ||||||
|  |     """ | ||||||
|  |     iterator = LinesIterator(docstring) | ||||||
|  |     for line in iterator: | ||||||
|  |         if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): | ||||||
|  |             iterator.rewind() | ||||||
|  |             blocks = [] | ||||||
|  |             lines = [] | ||||||
|  |             i_block = _i_extract_block(iterator) | ||||||
|  |             next(i_block) | ||||||
|  |             for line_of_block in i_block: | ||||||
|  |                 if re.search("^\\s*\\d{3}\\s*:", line_of_block): | ||||||
|  |                     if lines: | ||||||
|  |                         blocks.append("\n".join(lines)) | ||||||
|  |                         lines = [] | ||||||
|  |                 lines.append(line_of_block) | ||||||
|  |             if lines: | ||||||
|  |                 blocks.append("\n".join(lines)) | ||||||
|  |  | ||||||
|  |             return { | ||||||
|  |                 int(status.strip()): _dedent_under_first_line(desc.strip()) | ||||||
|  |                 for status, desc in (block.split(":", 1) for block in blocks) | ||||||
|  |             } | ||||||
|  |     return {} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def tags(docstring: str) -> List[str]: | ||||||
|  |     """ | ||||||
|  |     Extract the "Tags:" block of the docstring. | ||||||
|  |     """ | ||||||
|  |     iterator = LinesIterator(docstring) | ||||||
|  |     for line in iterator: | ||||||
|  |         if re.fullmatch("tags\\s*:.*", line, re.IGNORECASE): | ||||||
|  |             iterator.rewind() | ||||||
|  |             lines = " ".join(_i_extract_block(iterator)) | ||||||
|  |             return [" ".join(e.split()) for e in re.split("[,;]", lines.split(":")[1])] | ||||||
|  |     return [] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def operation_id(docstring: str) -> str | None: | ||||||
|  |     """ | ||||||
|  |     Extract the "OperationId:" block of the docstring. | ||||||
|  |     """ | ||||||
|  |     iterator = LinesIterator(docstring) | ||||||
|  |     for line in iterator: | ||||||
|  |         if re.fullmatch("operation_?id\\s*:.*", line, re.IGNORECASE): | ||||||
|  |             iterator.rewind() | ||||||
|  |             return line.split(":")[1].strip(' ') | ||||||
|  |  | ||||||
|  |     return None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def operation(docstring: str) -> str: | ||||||
|  |     """ | ||||||
|  |     Extract all docstring except the "Status Code:" block. | ||||||
|  |     """ | ||||||
|  |     lines = LinesIterator(docstring) | ||||||
|  |     ret = [] | ||||||
|  |     for line in lines: | ||||||
|  |         if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*|operation_?id\\s*:.*", line, re.IGNORECASE): | ||||||
|  |             lines.rewind() | ||||||
|  |             for _ in _i_extract_block(lines): | ||||||
|  |                 pass | ||||||
|  |         else: | ||||||
|  |             ret.append(line) | ||||||
|  |     return ("\n".join(ret)).strip() | ||||||
							
								
								
									
										50
									
								
								aiohttp_pydantic/oas/index.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								aiohttp_pydantic/oas/index.j2
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | |||||||
|  | {# This updated file is part of swagger_ui_bundle (https://github.com/bartsanchez/swagger_ui_bundle) #} | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <title>{{ title | default('Swagger UI') }}</title> | ||||||
|  |     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" /> | ||||||
|  |     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/index.css" /> | ||||||
|  |     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" /> | ||||||
|  |     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" /> | ||||||
|  |   </head> | ||||||
|  |  | ||||||
|  |   <body> | ||||||
|  |     <div id="swagger-ui"></div> | ||||||
|  |     <script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js" charset="UTF-8"> </script> | ||||||
|  |     <script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js" charset="UTF-8"> </script> | ||||||
|  |     <script> | ||||||
|  |     window.onload = function() { | ||||||
|  |       // Begin Swagger UI call region | ||||||
|  |       const ui = SwaggerUIBundle({ | ||||||
|  |         url: "{{ openapi_spec_url }}", | ||||||
|  |         {% if urls is defined %} | ||||||
|  |         urls: {{ urls|tojson|safe }}, | ||||||
|  |         {% endif %} | ||||||
|  |         validatorUrl: {{ validatorUrl | default('null') }}, | ||||||
|  |         {% if configUrl is defined %} | ||||||
|  |         configUrl: "{{ configUrl }}", | ||||||
|  |         {% endif %} | ||||||
|  |         dom_id: '#swagger-ui', | ||||||
|  |         deepLinking: true, | ||||||
|  |         presets: [ | ||||||
|  |           SwaggerUIBundle.presets.apis, | ||||||
|  |           SwaggerUIStandalonePreset | ||||||
|  |         ], | ||||||
|  |         plugins: [ | ||||||
|  |           SwaggerUIBundle.plugins.DownloadUrl | ||||||
|  |         ], | ||||||
|  |         layout: "StandaloneLayout" | ||||||
|  |       }); | ||||||
|  |       {% if initOAuth is defined %} | ||||||
|  |       ui.initOAuth( | ||||||
|  |         {{ initOAuth|tojson|safe }} | ||||||
|  |       ) | ||||||
|  |       {% endif %} | ||||||
|  |       // End Swagger UI call region | ||||||
|  |       window.ui = ui; | ||||||
|  |     }; | ||||||
|  |   </script> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										353
									
								
								aiohttp_pydantic/oas/struct.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								aiohttp_pydantic/oas/struct.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | |||||||
|  | """ | ||||||
|  | Utility to write Open Api Specifications using the Python language. | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | from typing import Union, List | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Info: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec.setdefault("info", {}) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def title(self): | ||||||
|  |         return self._spec.get("title") | ||||||
|  |  | ||||||
|  |     @title.setter | ||||||
|  |     def title(self, title): | ||||||
|  |         self._spec["title"] = title | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self): | ||||||
|  |         return self._spec.get("description") | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def version(self): | ||||||
|  |         return self._spec.get("version") | ||||||
|  |  | ||||||
|  |     @version.setter | ||||||
|  |     def version(self, version): | ||||||
|  |         self._spec["version"] = version | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def terms_of_service(self): | ||||||
|  |         return self._spec.get("termsOfService") | ||||||
|  |  | ||||||
|  |     @terms_of_service.setter | ||||||
|  |     def terms_of_service(self, terms_of_service): | ||||||
|  |         self._spec["termsOfService"] = terms_of_service | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class RequestBody: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec.setdefault("requestBody", {}) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self): | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def required(self) -> bool: | ||||||
|  |         return self._spec.get("required", False) | ||||||
|  |  | ||||||
|  |     @required.setter | ||||||
|  |     def required(self, required: bool): | ||||||
|  |         self._spec["required"] = required | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def content(self): | ||||||
|  |         return self._spec["content"] | ||||||
|  |  | ||||||
|  |     @content.setter | ||||||
|  |     def content(self, content: dict): | ||||||
|  |         self._spec["content"] = content | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Parameter: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def name(self) -> str: | ||||||
|  |         return self._spec["name"] | ||||||
|  |  | ||||||
|  |     @name.setter | ||||||
|  |     def name(self, name: str): | ||||||
|  |         self._spec["name"] = name | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def in_(self) -> str: | ||||||
|  |         return self._spec["in"] | ||||||
|  |  | ||||||
|  |     @in_.setter | ||||||
|  |     def in_(self, in_: str): | ||||||
|  |         self._spec["in"] = in_ | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self) -> str: | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def required(self) -> bool: | ||||||
|  |         return self._spec["required"] | ||||||
|  |  | ||||||
|  |     @required.setter | ||||||
|  |     def required(self, required: bool): | ||||||
|  |         self._spec["required"] = required | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def schema(self) -> dict: | ||||||
|  |         return self._spec["schema"] | ||||||
|  |  | ||||||
|  |     @schema.setter | ||||||
|  |     def schema(self, schema: dict): | ||||||
|  |         self._spec["schema"] = schema | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Parameters: | ||||||
|  |     def __init__(self, spec): | ||||||
|  |         self._spec = spec | ||||||
|  |         self._spec.setdefault("parameters", []) | ||||||
|  |  | ||||||
|  |     def __getitem__(self, item: int) -> Parameter: | ||||||
|  |         if item == len(self._spec["parameters"]): | ||||||
|  |             spec = {} | ||||||
|  |             self._spec["parameters"].append(spec) | ||||||
|  |         else: | ||||||
|  |             spec = self._spec["parameters"][item] | ||||||
|  |         return Parameter(spec) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Response: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |         self._spec.setdefault("description", "") | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self) -> str: | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def content(self): | ||||||
|  |         return self._spec["content"] | ||||||
|  |  | ||||||
|  |     @content.setter | ||||||
|  |     def content(self, content: dict): | ||||||
|  |         self._spec["content"] = content | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Responses: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec.setdefault("responses", {}) | ||||||
|  |  | ||||||
|  |     def __getitem__(self, status_code: Union[int, str]) -> Response: | ||||||
|  |         if not 100 <= int(status_code) < 600: | ||||||
|  |             raise ValueError("status_code must be between 100 and 599") | ||||||
|  |  | ||||||
|  |         spec = self._spec.setdefault(str(status_code), {}) | ||||||
|  |         return Response(spec) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OperationObject: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def summary(self) -> str: | ||||||
|  |         return self._spec["summary"] | ||||||
|  |  | ||||||
|  |     @summary.setter | ||||||
|  |     def summary(self, summary: str): | ||||||
|  |         self._spec["summary"] = summary | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self) -> str: | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def request_body(self) -> RequestBody: | ||||||
|  |         return RequestBody(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def parameters(self) -> Parameters: | ||||||
|  |         return Parameters(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def responses(self) -> Responses: | ||||||
|  |         return Responses(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def tags(self) -> List[str]: | ||||||
|  |         return self._spec.get("tags", [])[:] | ||||||
|  |  | ||||||
|  |     @tags.setter | ||||||
|  |     def tags(self, tags: List[str]): | ||||||
|  |         if tags: | ||||||
|  |             self._spec["tags"] = tags[:] | ||||||
|  |         else: | ||||||
|  |             self._spec.pop("tags", None) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def operation_id(self) -> str | None: | ||||||
|  |         return self._spec.get("operationId", None) | ||||||
|  |  | ||||||
|  |     @operation_id.setter | ||||||
|  |     def operation_id(self, operation_id: str | None) -> None: | ||||||
|  |         if operation_id: | ||||||
|  |             self._spec["operationId"] = operation_id | ||||||
|  |         else: | ||||||
|  |             self._spec.pop("operationId", None) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PathItem: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def get(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("get", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def put(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("put", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def post(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("post", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def delete(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("delete", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def options(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("options", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def head(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("head", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def patch(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("patch", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def trace(self) -> OperationObject: | ||||||
|  |         return OperationObject(self._spec.setdefault("trace", {})) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self) -> str: | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def summary(self) -> str: | ||||||
|  |         return self._spec["summary"] | ||||||
|  |  | ||||||
|  |     @summary.setter | ||||||
|  |     def summary(self, summary: str): | ||||||
|  |         self._spec["summary"] = summary | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Paths: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec.setdefault("paths", {}) | ||||||
|  |  | ||||||
|  |     def __getitem__(self, path: str) -> PathItem: | ||||||
|  |         spec = self._spec.setdefault(path, {}) | ||||||
|  |         return PathItem(spec) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Server: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def url(self) -> str: | ||||||
|  |         return self._spec["url"] | ||||||
|  |  | ||||||
|  |     @url.setter | ||||||
|  |     def url(self, url: str): | ||||||
|  |         self._spec["url"] = url | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def description(self) -> str: | ||||||
|  |         return self._spec["description"] | ||||||
|  |  | ||||||
|  |     @description.setter | ||||||
|  |     def description(self, description: str): | ||||||
|  |         self._spec["description"] = description | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Servers: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec | ||||||
|  |         self._spec.setdefault("servers", []) | ||||||
|  |  | ||||||
|  |     def __getitem__(self, item: int) -> Server: | ||||||
|  |         if item == len(self._spec["servers"]): | ||||||
|  |             spec = {} | ||||||
|  |             self._spec["servers"].append(spec) | ||||||
|  |         else: | ||||||
|  |             spec = self._spec["servers"][item] | ||||||
|  |         return Server(spec) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Components: | ||||||
|  |     def __init__(self, spec: dict): | ||||||
|  |         self._spec = spec.setdefault("components", {}) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def schemas(self) -> dict: | ||||||
|  |         return self._spec.setdefault("schemas", {}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class OpenApiSpec3: | ||||||
|  |     def __init__(self): | ||||||
|  |         self._spec = { | ||||||
|  |             "openapi": "3.0.0", | ||||||
|  |             "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def info(self) -> Info: | ||||||
|  |         return Info(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def servers(self) -> Servers: | ||||||
|  |         return Servers(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def paths(self) -> Paths: | ||||||
|  |         return Paths(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def components(self) -> Components: | ||||||
|  |         return Components(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def spec(self): | ||||||
|  |         return self._spec | ||||||
							
								
								
									
										48
									
								
								aiohttp_pydantic/oas/typing.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								aiohttp_pydantic/oas/typing.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | |||||||
|  | """ | ||||||
|  | This module provides type to annotate the content of web.Response returned by | ||||||
|  | the HTTP handlers. | ||||||
|  |  | ||||||
|  | The type are: r100, r101, ..., r599 | ||||||
|  |  | ||||||
|  | Example: | ||||||
|  |  | ||||||
|  |     class PetCollectionView(PydanticView): | ||||||
|  |         async def get(self) -> Union[r200[List[Pet]], r404]: | ||||||
|  |             ... | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | from functools import lru_cache | ||||||
|  | from types import new_class | ||||||
|  | from typing import Protocol, TypeVar | ||||||
|  |  | ||||||
|  | RespContents = TypeVar("RespContents", covariant=True) | ||||||
|  |  | ||||||
|  | _status_code = frozenset(f"r{code}" for code in range(100, 600)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @lru_cache(maxsize=len(_status_code)) | ||||||
|  | def _make_status_code_type(status_code): | ||||||
|  |     if status_code in _status_code: | ||||||
|  |         return new_class(status_code, (Protocol[RespContents],)) | ||||||
|  |     return None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def is_status_code_type(obj) -> bool: | ||||||
|  |     """ | ||||||
|  |     Return True if obj is a status code type such as _200 or _404. | ||||||
|  |     """ | ||||||
|  |     name = getattr(obj, "__name__", None) | ||||||
|  |     if name not in _status_code: | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     return obj is _make_status_code_type(name) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def __getattr__(name): | ||||||
|  |     if (status_code_type := _make_status_code_type(name)) is None: | ||||||
|  |         raise AttributeError(f"module {__name__!r} has no attribute {name!r}") | ||||||
|  |     return status_code_type | ||||||
|  |  | ||||||
|  |  | ||||||
|  | __all__ = list(_status_code) | ||||||
|  | __all__.append("is_status_code_type") | ||||||
							
								
								
									
										210
									
								
								aiohttp_pydantic/oas/view.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										210
									
								
								aiohttp_pydantic/oas/view.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,210 @@ | |||||||
|  | import typing | ||||||
|  | from inspect import getdoc | ||||||
|  | from itertools import count | ||||||
|  | from typing import List, Type, Optional, get_type_hints | ||||||
|  |  | ||||||
|  | from aiohttp.web import Response, json_response | ||||||
|  | from aiohttp.web_app import Application | ||||||
|  | from pydantic import BaseModel, RootModel | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | ||||||
|  | from . import docstring_parser | ||||||
|  |  | ||||||
|  | from ..injectors import _parse_func_signature | ||||||
|  | from ..utils import is_pydantic_base_model | ||||||
|  | from ..view import PydanticView, is_pydantic_view | ||||||
|  | from .typing import is_status_code_type | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class _OASResponseBuilder: | ||||||
|  |     """ | ||||||
|  |     Parse the type annotated as returned by a function and | ||||||
|  |     generate the OAS operation response. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): | ||||||
|  |         self._oas_operation = oas_operation | ||||||
|  |         self._oas = oas | ||||||
|  |         self._status_code_descriptions = status_code_descriptions | ||||||
|  |  | ||||||
|  |     def _handle_pydantic_base_model(self, obj): | ||||||
|  |         if is_pydantic_base_model(obj): | ||||||
|  |             response_schema = obj.schema( | ||||||
|  |                 ref_template="#/components/schemas/{model}" | ||||||
|  |             ).copy() | ||||||
|  |             if def_sub_schemas := response_schema.pop("$defs", None): | ||||||
|  |                 self._oas.components.schemas.update(def_sub_schemas) | ||||||
|  |             self._oas.components.schemas.update({response_schema['title']: response_schema}) | ||||||
|  |             return {'$ref': f'#/components/schemas/{response_schema["title"]}'} | ||||||
|  |         return {} | ||||||
|  |  | ||||||
|  |     def _handle_list(self, obj): | ||||||
|  |         if typing.get_origin(obj) is list: | ||||||
|  |             return { | ||||||
|  |                 "type": "array", | ||||||
|  |                 "items": self._handle_pydantic_base_model(typing.get_args(obj)[0]), | ||||||
|  |             } | ||||||
|  |         return self._handle_pydantic_base_model(obj) | ||||||
|  |  | ||||||
|  |     def _handle_status_code_type(self, obj): | ||||||
|  |         if is_status_code_type(typing.get_origin(obj)): | ||||||
|  |             status_code = typing.get_origin(obj).__name__[1:] | ||||||
|  |             self._oas_operation.responses[status_code].content = { | ||||||
|  |                 "application/json": { | ||||||
|  |                     "schema": self._handle_list(typing.get_args(obj)[0]) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             desc = self._status_code_descriptions.get(int(status_code)) | ||||||
|  |             if desc: | ||||||
|  |                 self._oas_operation.responses[status_code].description = desc | ||||||
|  |  | ||||||
|  |         elif is_status_code_type(obj): | ||||||
|  |             status_code = obj.__name__[1:] | ||||||
|  |             self._oas_operation.responses[status_code].content = {} | ||||||
|  |             desc = self._status_code_descriptions.get(int(status_code)) | ||||||
|  |             if desc: | ||||||
|  |                 self._oas_operation.responses[status_code].description = desc | ||||||
|  |  | ||||||
|  |     def _handle_union(self, obj): | ||||||
|  |         if typing.get_origin(obj) is typing.Union: | ||||||
|  |             for arg in typing.get_args(obj): | ||||||
|  |                 self._handle_status_code_type(arg) | ||||||
|  |         self._handle_status_code_type(obj) | ||||||
|  |  | ||||||
|  |     def build(self, obj): | ||||||
|  |         self._handle_union(obj) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def _add_http_method_to_oas( | ||||||
|  |     oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView] | ||||||
|  | ): | ||||||
|  |     http_method = http_method.lower() | ||||||
|  |     oas_operation: OperationObject = getattr(oas_path, http_method) | ||||||
|  |     handler = getattr(view, http_method) | ||||||
|  |     path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( | ||||||
|  |         handler, unpack_group=True | ||||||
|  |     ) | ||||||
|  |     description = getdoc(handler) | ||||||
|  |     if description: | ||||||
|  |         oas_operation.description = docstring_parser.operation(description) | ||||||
|  |         oas_operation.tags = docstring_parser.tags(description) | ||||||
|  |         oas_operation.operation_id = docstring_parser.operation_id(description) | ||||||
|  |         status_code_descriptions = docstring_parser.status_code(description) | ||||||
|  |     else: | ||||||
|  |         status_code_descriptions = {} | ||||||
|  |  | ||||||
|  |     if body_args: | ||||||
|  |         body_schema = ( | ||||||
|  |             next(iter(body_args.values())) | ||||||
|  |             .schema(ref_template="#/components/schemas/{model}") | ||||||
|  |             .copy() | ||||||
|  |         ) | ||||||
|  |         if def_sub_schemas := body_schema.pop("$defs", None): | ||||||
|  |             oas.components.schemas.update(def_sub_schemas) | ||||||
|  |  | ||||||
|  |         oas_operation.request_body.content = { | ||||||
|  |             "application/json": {"schema": body_schema} | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |     indexes = count() | ||||||
|  |     for args_location, args in ( | ||||||
|  |         ("path", path_args.items()), | ||||||
|  |         ("query", qs_args.items()), | ||||||
|  |         ("header", header_args.items()), | ||||||
|  |     ): | ||||||
|  |         for name, type_ in args: | ||||||
|  |             i = next(indexes) | ||||||
|  |             oas_operation.parameters[i].in_ = args_location | ||||||
|  |             oas_operation.parameters[i].name = name | ||||||
|  |  | ||||||
|  |             attrs = {"__annotations__": {"root": type_}} | ||||||
|  |             if name in defaults: | ||||||
|  |                 attrs["root"] = defaults[name] | ||||||
|  |                 oas_operation.parameters[i].required = False | ||||||
|  |             else: | ||||||
|  |                 oas_operation.parameters[i].required = True | ||||||
|  |  | ||||||
|  |             oas_operation.parameters[i].schema = type(name, (RootModel,), attrs).schema( | ||||||
|  |                 ref_template="#/components/schemas/{model}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if 'description' in oas_operation.parameters[i].schema: | ||||||
|  |                 oas_operation.parameters[i].description = oas_operation.parameters[i].schema['description'] | ||||||
|  |  | ||||||
|  |             # move definitions | ||||||
|  |             if def_sub_schemas := oas_operation.parameters[i].schema.pop("$defs", None): | ||||||
|  |                 oas.components.schemas.update(def_sub_schemas) | ||||||
|  |  | ||||||
|  |     return_type = get_type_hints(handler).get("return") | ||||||
|  |     if return_type is not None: | ||||||
|  |         _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( | ||||||
|  |             return_type | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def generate_oas( | ||||||
|  |     apps: List[Application], | ||||||
|  |     version_spec: Optional[str] = None, | ||||||
|  |     title_spec: Optional[str] = None, | ||||||
|  | ) -> dict: | ||||||
|  |     """ | ||||||
|  |     Generate and return Open Api Specification from PydanticView in application. | ||||||
|  |     """ | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |  | ||||||
|  |     if version_spec is not None: | ||||||
|  |         oas.info.version = version_spec | ||||||
|  |  | ||||||
|  |     if title_spec is not None: | ||||||
|  |         oas.info.title = title_spec | ||||||
|  |  | ||||||
|  |     for app in apps: | ||||||
|  |         for resources in app.router.resources(): | ||||||
|  |             for resource_route in resources: | ||||||
|  |                 if not is_pydantic_view(resource_route.handler): | ||||||
|  |                     continue | ||||||
|  |  | ||||||
|  |                 view: Type[PydanticView] = resource_route.handler | ||||||
|  |                 info = resource_route.get_info() | ||||||
|  |                 path = oas.paths[info.get("path", info.get("formatter"))] | ||||||
|  |                 if resource_route.method == "*": | ||||||
|  |                     for method_name in view.allowed_methods: | ||||||
|  |                         _add_http_method_to_oas(oas, path, method_name, view) | ||||||
|  |                 else: | ||||||
|  |                     _add_http_method_to_oas(oas, path, resource_route.method, view) | ||||||
|  |  | ||||||
|  |     return oas.spec | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def get_oas(request): | ||||||
|  |     """ | ||||||
|  |     View to generate the Open Api Specification from PydanticView in application. | ||||||
|  |     """ | ||||||
|  |     apps = request.app["apps to expose"] | ||||||
|  |     version_spec = request.app["version_spec"] | ||||||
|  |     title_spec = request.app["title_spec"] | ||||||
|  |     return json_response(generate_oas(apps, version_spec, title_spec)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def oas_ui(request): | ||||||
|  |     """ | ||||||
|  |     View to serve the swagger-ui to read open api specification of application. | ||||||
|  |     """ | ||||||
|  |     template = request.app["index template"] | ||||||
|  |  | ||||||
|  |     static_url = request.app.router["static"].url_for(filename="") | ||||||
|  |     spec_url = request.app.router["spec"].url_for() | ||||||
|  |     if request.scheme != request.headers.get('x-forwarded-proto', request.scheme): | ||||||
|  |         request = request.clone(scheme=request.headers['x-forwarded-proto']) | ||||||
|  |     host = request.url.origin() | ||||||
|  |  | ||||||
|  |     return Response( | ||||||
|  |         text=template.render( | ||||||
|  |             { | ||||||
|  |                 "openapi_spec_url": host.with_path(str(spec_url)), | ||||||
|  |                 "static_url": host.with_path(str(static_url)), | ||||||
|  |             } | ||||||
|  |         ), | ||||||
|  |         content_type="text/html", | ||||||
|  |         charset="utf-8", | ||||||
|  |     ) | ||||||
							
								
								
									
										19
									
								
								aiohttp_pydantic/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								aiohttp_pydantic/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def is_pydantic_base_model(obj): | ||||||
|  |     """ | ||||||
|  |     Return true is obj is a pydantic.BaseModel subclass. | ||||||
|  |     """ | ||||||
|  |     return robuste_issubclass(obj, BaseModel) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def robuste_issubclass(cls1, cls2): | ||||||
|  |     """ | ||||||
|  |     function likes issubclass but returns False instead of raise type error | ||||||
|  |     if first parameter is not a class. | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         return issubclass(cls1, cls2) | ||||||
|  |     except TypeError: | ||||||
|  |         return False | ||||||
| @@ -1,68 +1,116 @@ | |||||||
|  | from functools import update_wrapper | ||||||
| from inspect import iscoroutinefunction | from inspect import iscoroutinefunction | ||||||
|  | from typing import Any, Callable, Generator, Iterable, Set, ClassVar | ||||||
|  | import warnings | ||||||
|  |  | ||||||
| from aiohttp.abc import AbstractView | from aiohttp.abc import AbstractView | ||||||
| from aiohttp.hdrs import METH_ALL | from aiohttp.hdrs import METH_ALL | ||||||
|  | from aiohttp.web import json_response | ||||||
| from aiohttp.web_exceptions import HTTPMethodNotAllowed | from aiohttp.web_exceptions import HTTPMethodNotAllowed | ||||||
| from aiohttp.web_response import StreamResponse | from aiohttp.web_response import StreamResponse | ||||||
| from pydantic import ValidationError | from pydantic import ValidationError | ||||||
| from typing import Generator, Any, Callable, List, Iterable |  | ||||||
| from aiohttp.web import json_response |  | ||||||
| from functools import update_wrapper |  | ||||||
|  |  | ||||||
|  | from pydantic_core import ErrorDetails | ||||||
|  |  | ||||||
| from .injectors import ( | from .injectors import ( | ||||||
|     MatchInfoGetter, |  | ||||||
|     HeadersGetter, |  | ||||||
|     QueryGetter, |  | ||||||
|     BodyGetter, |  | ||||||
|     AbstractInjector, |     AbstractInjector, | ||||||
|  |     BodyGetter, | ||||||
|  |     HeadersGetter, | ||||||
|  |     MatchInfoGetter, | ||||||
|  |     QueryGetter, | ||||||
|     _parse_func_signature, |     _parse_func_signature, | ||||||
|  |     CONTEXT, | ||||||
|  |     Group, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PydanticValidationError(ErrorDetails): | ||||||
|  |     loc_in: CONTEXT | ||||||
|  |  | ||||||
|  |  | ||||||
| class PydanticView(AbstractView): | class PydanticView(AbstractView): | ||||||
|     """ |     """ | ||||||
|     An AIOHTTP View that validate request using function annotations. |     An AIOHTTP View that validate request using function annotations. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|  |     # Allowed HTTP methods; overridden when subclassed. | ||||||
|  |     allowed_methods: ClassVar[Set[str]] = {} | ||||||
|  |  | ||||||
|     async def _iter(self) -> StreamResponse: |     async def _iter(self) -> StreamResponse: | ||||||
|         method = getattr(self, self.request.method.lower(), None) |         if (method_name := self.request.method) not in self.allowed_methods: | ||||||
|         resp = await method() |             self._raise_allowed_methods() | ||||||
|         return resp |         return await getattr(self, method_name.lower())() | ||||||
|  |  | ||||||
|     def __await__(self) -> Generator[Any, None, StreamResponse]: |     def __await__(self) -> Generator[Any, None, StreamResponse]: | ||||||
|         return self._iter().__await__() |         return self._iter().__await__() | ||||||
|  |  | ||||||
|     def __init_subclass__(cls, **kwargs): |     def __init_subclass__(cls, **kwargs) -> None: | ||||||
|         allowed_methods = { |         """Define allowed methods and decorate handlers. | ||||||
|  |  | ||||||
|  |         Handlers are decorated if and only if they directly bound on the PydanticView class or | ||||||
|  |         PydanticView subclass. This prevents that methods are decorated multiple times and that method | ||||||
|  |         defined in aiohttp.View parent class is decorated. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         cls.allowed_methods = { | ||||||
|             meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) |             meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         async def raise_not_allowed(self): |  | ||||||
|             raise HTTPMethodNotAllowed(self.request.method, allowed_methods) |  | ||||||
|  |  | ||||||
|         for meth_name in METH_ALL: |         for meth_name in METH_ALL: | ||||||
|             if meth_name not in allowed_methods: |             if meth_name.lower() in vars(cls): | ||||||
|                 setattr(cls, meth_name.lower(), raise_not_allowed) |  | ||||||
|             else: |  | ||||||
|                 handler = getattr(cls, meth_name.lower()) |                 handler = getattr(cls, meth_name.lower()) | ||||||
|                 decorated_handler = inject_params(handler, cls.parse_func_signature) |                 decorated_handler = inject_params(handler, cls.parse_func_signature) | ||||||
|                 setattr(cls, meth_name.lower(), decorated_handler) |                 setattr(cls, meth_name.lower(), decorated_handler) | ||||||
|  |  | ||||||
|  |     def _raise_allowed_methods(self) -> None: | ||||||
|  |         raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) | ||||||
|  |  | ||||||
|  |     def raise_not_allowed(self) -> None: | ||||||
|  |         warnings.warn( | ||||||
|  |             "PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods", | ||||||
|  |             DeprecationWarning, | ||||||
|  |             stacklevel=2, | ||||||
|  |         ) | ||||||
|  |         self._raise_allowed_methods() | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: |     def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: | ||||||
|         path_args, body_args, qs_args, header_args = _parse_func_signature(func) |         path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( | ||||||
|  |             func | ||||||
|  |         ) | ||||||
|         injectors = [] |         injectors = [] | ||||||
|  |  | ||||||
|  |         def default_value(args: dict) -> dict: | ||||||
|  |             """ | ||||||
|  |             Returns the default values of args. | ||||||
|  |             """ | ||||||
|  |             return {name: defaults[name] for name in args if name in defaults} | ||||||
|  |  | ||||||
|         if path_args: |         if path_args: | ||||||
|             injectors.append(MatchInfoGetter(path_args)) |             injectors.append(MatchInfoGetter(path_args, default_value(path_args))) | ||||||
|         if body_args: |         if body_args: | ||||||
|             injectors.append(BodyGetter(body_args)) |             injectors.append(BodyGetter(body_args, default_value(body_args))) | ||||||
|         if qs_args: |         if qs_args: | ||||||
|             injectors.append(QueryGetter(qs_args)) |             injectors.append(QueryGetter(qs_args, default_value(qs_args))) | ||||||
|         if header_args: |         if header_args: | ||||||
|             injectors.append(HeadersGetter(header_args)) |             injectors.append(HeadersGetter(header_args, default_value(header_args))) | ||||||
|         return injectors |         return injectors | ||||||
|  |  | ||||||
|  |     async def on_validation_error( | ||||||
|  |             self, exception: ValidationError, context: CONTEXT | ||||||
|  |     ) -> StreamResponse: | ||||||
|  |         """ | ||||||
|  |         This method is a hook to intercept ValidationError. | ||||||
|  |  | ||||||
|  |         This hook can be redefined to return a custom HTTP response error. | ||||||
|  |         The exception is a pydantic.ValidationError and the context is "body", | ||||||
|  |         "headers", "path" or "query string" | ||||||
|  |         """ | ||||||
|  |         errors = exception.errors() | ||||||
|  |         own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] | ||||||
|  |  | ||||||
|  |         return json_response(data=own_errors, status=400) | ||||||
|  |  | ||||||
|  |  | ||||||
| def inject_params( | def inject_params( | ||||||
|         handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] |         handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] | ||||||
| @@ -84,9 +132,31 @@ def inject_params( | |||||||
|                 else: |                 else: | ||||||
|                     injector.inject(self.request, args, kwargs) |                     injector.inject(self.request, args, kwargs) | ||||||
|             except ValidationError as error: |             except ValidationError as error: | ||||||
|                 return json_response(text=error.json(), status=400) |                 return await self.on_validation_error(error, injector.context) | ||||||
|  |  | ||||||
|         return await handler(self, *args, **kwargs) |         return await handler(self, *args, **kwargs) | ||||||
|  |  | ||||||
|     update_wrapper(wrapped_handler, handler) |     update_wrapper(wrapped_handler, handler) | ||||||
|     return wrapped_handler |     return wrapped_handler | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def is_pydantic_view(obj) -> bool: | ||||||
|  |     """ | ||||||
|  |     Return True if obj is a PydanticView subclass else False. | ||||||
|  |     """ | ||||||
|  |     try: | ||||||
|  |         return issubclass(obj, PydanticView) | ||||||
|  |     except TypeError: | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |  | ||||||
|  | __all__ = ( | ||||||
|  |     "PydanticValidationError", | ||||||
|  |     "AbstractInjector", | ||||||
|  |     "BodyGetter", | ||||||
|  |     "HeadersGetter", | ||||||
|  |     "MatchInfoGetter", | ||||||
|  |     "QueryGetter", | ||||||
|  |     "CONTEXT", | ||||||
|  |     "Group", | ||||||
|  | ) | ||||||
|   | |||||||
							
								
								
									
										0
									
								
								demo/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								demo/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										5
									
								
								demo/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								demo/__main__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | from aiohttp import web | ||||||
|  |  | ||||||
|  | from .main import app | ||||||
|  |  | ||||||
|  | web.run_app(app) | ||||||
							
								
								
									
										22
									
								
								demo/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								demo/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | from aiohttp.web import Application, json_response, middleware | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import oas | ||||||
|  |  | ||||||
|  | from .model import Model | ||||||
|  | from .view import PetCollectionView, PetItemView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @middleware | ||||||
|  | async def pet_not_found_to_404(request, handler): | ||||||
|  |     try: | ||||||
|  |         return await handler(request) | ||||||
|  |     except Model.NotFound as key: | ||||||
|  |         return json_response({"error": f"Pet {key} does not exist"}, status=404) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | app = Application(middlewares=[pet_not_found_to_404]) | ||||||
|  | oas.setup(app, version_spec="1.0.1", title_spec="My App") | ||||||
|  |  | ||||||
|  | app["model"] = Model() | ||||||
|  | app.router.add_view("/pets", PetCollectionView) | ||||||
|  | app.router.add_view("/pets/{id}", PetItemView) | ||||||
							
								
								
									
										55
									
								
								demo/model.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								demo/model.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | from pydantic import BaseModel | ||||||
|  | from typing import List | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Friend(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     age: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Pet(BaseModel): | ||||||
|  |     id: int | ||||||
|  |     name: str | ||||||
|  |     age: int | ||||||
|  |     friends: Friend | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Error(BaseModel): | ||||||
|  |     error: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Model: | ||||||
|  |     """ | ||||||
|  |     To keep simple this demo, we use a simple dict as database to | ||||||
|  |     store the models. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     class NotFound(KeyError): | ||||||
|  |         """ | ||||||
|  |         Raised when a pet is not found. | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |     def __init__(self): | ||||||
|  |         self.storage = {} | ||||||
|  |  | ||||||
|  |     def add_pet(self, pet: Pet): | ||||||
|  |         self.storage[pet.id] = pet | ||||||
|  |  | ||||||
|  |     def remove_pet(self, id: int): | ||||||
|  |         try: | ||||||
|  |             del self.storage[id] | ||||||
|  |         except KeyError as error: | ||||||
|  |             raise self.NotFound(str(error)) | ||||||
|  |  | ||||||
|  |     def update_pet(self, id: int, pet: Pet): | ||||||
|  |         self.remove_pet(id) | ||||||
|  |         self.add_pet(pet) | ||||||
|  |  | ||||||
|  |     def find_pet(self, id: int): | ||||||
|  |         try: | ||||||
|  |             return self.storage[id] | ||||||
|  |         except KeyError as error: | ||||||
|  |             raise self.NotFound(str(error)) | ||||||
|  |  | ||||||
|  |     def list_pets(self): | ||||||
|  |         return list(self.storage.values()) | ||||||
							
								
								
									
										63
									
								
								demo/view.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								demo/view.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | from typing import List, Optional, Union | ||||||
|  |  | ||||||
|  | from aiohttp import web | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  | from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||||
|  |  | ||||||
|  | from .model import Error, Pet | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PetCollectionView(PydanticView): | ||||||
|  |     async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: | ||||||
|  |         """ | ||||||
|  |         List all pets | ||||||
|  |  | ||||||
|  |         Status Codes: | ||||||
|  |             200: Successful operation | ||||||
|  |         """ | ||||||
|  |         pets = self.request.app["model"].list_pets() | ||||||
|  |         return web.json_response( | ||||||
|  |             [pet.dict() for pet in pets if age is None or age == pet.age] | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |     async def post(self, pet: Pet) -> r201[Pet]: | ||||||
|  |         """ | ||||||
|  |         Add a new pet to the store | ||||||
|  |  | ||||||
|  |         Status Codes: | ||||||
|  |             201: Successful operation | ||||||
|  |         """ | ||||||
|  |         self.request.app["model"].add_pet(pet) | ||||||
|  |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PetItemView(PydanticView): | ||||||
|  |     async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: | ||||||
|  |         """ | ||||||
|  |         Find a pet by ID | ||||||
|  |  | ||||||
|  |         Status Codes: | ||||||
|  |             200: Successful operation | ||||||
|  |             404: Pet not found | ||||||
|  |         """ | ||||||
|  |         pet = self.request.app["model"].find_pet(id) | ||||||
|  |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |     async def put(self, id: int, /, pet: Pet) -> r200[Pet]: | ||||||
|  |         """ | ||||||
|  |         Update an existing object | ||||||
|  |  | ||||||
|  |         Status Codes: | ||||||
|  |             200: Successful operation | ||||||
|  |             404: Pet not found | ||||||
|  |         """ | ||||||
|  |         self.request.app["model"].update_pet(id, pet) | ||||||
|  |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |     async def delete(self, id: int, /) -> r204: | ||||||
|  |         """ | ||||||
|  |         Deletes a pet | ||||||
|  |         """ | ||||||
|  |         self.request.app["model"].remove_pet(id) | ||||||
|  |         return web.Response(status=204) | ||||||
							
								
								
									
										9
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								pyproject.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | [build-system] | ||||||
|  | requires = [ | ||||||
|  |   "setuptools >= 46.4.0", | ||||||
|  |   "wheel", | ||||||
|  | ] | ||||||
|  | build-backend = "setuptools.build_meta" | ||||||
|  |  | ||||||
|  | [tool.pytest.ini_options] | ||||||
|  | asyncio_mode = "auto" | ||||||
							
								
								
									
										11
									
								
								requirements/ci.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								requirements/ci.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | aiohttp==3.8.6 | ||||||
|  | pydantic==2.5.1 | ||||||
|  | jinja2==3.1.2 | ||||||
|  | swagger-4-ui-bundle==0.0.4 | ||||||
|  | pytest==7.4.3 | ||||||
|  | pytest-aiohttp==1.0.5 | ||||||
|  | pytest-asyncio==0.21.1 | ||||||
|  | pytest-cov==4.1.0 | ||||||
|  | readme-renderer==42.0 | ||||||
|  | codecov==2.1.13 | ||||||
|  | twine==4.0.2 | ||||||
							
								
								
									
										9
									
								
								requirements/test.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								requirements/test.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | aiohttp==3.8.6 | ||||||
|  | pydantic==2.5.1 | ||||||
|  | jinja2==3.1.2 | ||||||
|  | swagger-4-ui-bundle==0.0.4 | ||||||
|  | pytest==7.4.3 | ||||||
|  | pytest-aiohttp==1.0.5 | ||||||
|  | pytest-asyncio==0.21.1 | ||||||
|  | pytest-cov==4.1.0 | ||||||
|  | readme-renderer==42.0 | ||||||
							
								
								
									
										54
									
								
								setup.cfg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								setup.cfg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | [metadata] | ||||||
|  | name = aiohttp_pydantic | ||||||
|  | version = attr: aiohttp_pydantic.__version__ | ||||||
|  | url = https://github.com/Maillol/aiohttp-pydantic | ||||||
|  | author = Vincent Maillol | ||||||
|  | author_email = vincent.maillol@gmail.com | ||||||
|  | description = Aiohttp View using pydantic to validate request body and query sting regarding method annotations. | ||||||
|  | long_description = file: README.rst | ||||||
|  | keywords = | ||||||
|  |     aiohttp | ||||||
|  |     pydantic | ||||||
|  |     annotations | ||||||
|  |     validation | ||||||
|  | license = MIT | ||||||
|  | classifiers = | ||||||
|  |     Intended Audience :: Developers | ||||||
|  |     Intended Audience :: Information Technology | ||||||
|  |     Programming Language :: Python | ||||||
|  |     Programming Language :: Python :: 3 | ||||||
|  |     Programming Language :: Python :: 3 :: Only | ||||||
|  |     Programming Language :: Python :: 3.10 | ||||||
|  |     Programming Language :: Python :: 3.11 | ||||||
|  |     Topic :: Software Development :: Libraries :: Application Frameworks | ||||||
|  |     Framework :: aiohttp | ||||||
|  |     License :: OSI Approved :: MIT License | ||||||
|  |  | ||||||
|  | [options] | ||||||
|  | zip_safe = False | ||||||
|  | include_package_data = True | ||||||
|  | packages = find: | ||||||
|  | python_requires = >=3.10 | ||||||
|  | install_requires = | ||||||
|  |     aiohttp | ||||||
|  |     pydantic>=2.5.0 | ||||||
|  |     swagger-4-ui-bundle | ||||||
|  |  | ||||||
|  | [options.extras_require] | ||||||
|  | test = | ||||||
|  |     pytest==7.4.0 | ||||||
|  |     pytest-aiohttp==1.0.5 | ||||||
|  |     pytest-cov==4.1.0 | ||||||
|  |     readme-renderer==42.0 | ||||||
|  | ci = | ||||||
|  |     %(test)s | ||||||
|  |     codecov==2.1.13 | ||||||
|  |     twine==4.0.2 | ||||||
|  |  | ||||||
|  | [options.packages.find] | ||||||
|  | exclude = | ||||||
|  |     tests* | ||||||
|  |     demo* | ||||||
|  |  | ||||||
|  | [options.package_data] | ||||||
|  | aiohttp_pydantic.oas = index.j2 | ||||||
							
								
								
									
										27
									
								
								setup.py
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								setup.py
									
									
									
									
									
								
							| @@ -1,28 +1,3 @@ | |||||||
| from setuptools import setup | from setuptools import setup | ||||||
|  |  | ||||||
|  | setup() | ||||||
| setup( |  | ||||||
|     name='aiohttp_pydantic', |  | ||||||
|     version='1.0.0', |  | ||||||
|     description='Aiohttp View using pydantic to validate request body and query sting regarding method annotation', |  | ||||||
|     keywords='aiohttp pydantic annotation unpack inject validate', |  | ||||||
|     author='Vincent Maillol', |  | ||||||
|     author_email='vincent.maillol@gmail.com', |  | ||||||
|     url='https://github.com/Maillol/aiohttp-pydantic', |  | ||||||
|     license='MIT', |  | ||||||
|     packages=['aiohttp_pydantic'], |  | ||||||
|     classifiers=[ |  | ||||||
|         'Intended Audience :: Developers', |  | ||||||
|         'Intended Audience :: Information Technology', |  | ||||||
|         'Programming Language :: Python', |  | ||||||
|         'Programming Language :: Python :: 3', |  | ||||||
|         'Programming Language :: Python :: 3 :: Only', |  | ||||||
|         'Programming Language :: Python :: 3.8', |  | ||||||
|         'Programming Language :: Python :: 3.9', |  | ||||||
|         'Topic :: Software Development :: Libraries :: Application Frameworks', |  | ||||||
|         'Framework :: AsyncIO', |  | ||||||
|         'License :: OSI Approved :: MIT License' |  | ||||||
|     ], |  | ||||||
|     python_requires='>=3.6', |  | ||||||
|     install_requires=['aiohttp', 'pydantic'] |  | ||||||
| ) |  | ||||||
|   | |||||||
							
								
								
									
										175
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										175
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,175 @@ | |||||||
|  | """ | ||||||
|  | To use this module, install invoke and type invoke -l | ||||||
|  | """ | ||||||
|  |  | ||||||
|  | from functools import partial | ||||||
|  | import os | ||||||
|  | from pathlib import Path | ||||||
|  | from setuptools.config import read_configuration | ||||||
|  |  | ||||||
|  | from invoke import task, Exit, Task as Task_, call | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def activate_venv(c, venv: str): | ||||||
|  |     """ | ||||||
|  |     Activate a virtualenv | ||||||
|  |     """ | ||||||
|  |     virtual_env = Path().absolute() / venv | ||||||
|  |     if original_path := os.environ.get("PATH"): | ||||||
|  |         path = f'{virtual_env / "bin"}:{original_path}' | ||||||
|  |     else: | ||||||
|  |         path = str(virtual_env / "bin") | ||||||
|  |     c.config.run.env["PATH"] = path | ||||||
|  |     c.config.run.env["VIRTUAL_ENV"] = str(virtual_env) | ||||||
|  |     os.environ.pop("PYTHONHOME", "") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def title(text, underline_char="#"): | ||||||
|  |     """ | ||||||
|  |     Display text as a title. | ||||||
|  |     """ | ||||||
|  |     template = f"{{:{underline_char}^80}}" | ||||||
|  |     text = template.format(f" {text.strip()} ") | ||||||
|  |     print(f"\033[1m{text}\033[0m") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Task(Task_): | ||||||
|  |     """ | ||||||
|  |     This task add 'skip_if_recent' feature. | ||||||
|  |  | ||||||
|  |     >>> @task(skip_if_recent=['./target', './dependency']) | ||||||
|  |     >>> def my_tash(c): | ||||||
|  |     >>>    ... | ||||||
|  |  | ||||||
|  |     target is file created by the task | ||||||
|  |     dependency is file used by the task | ||||||
|  |  | ||||||
|  |     The task is ran only if the dependency is more recent than the target file. | ||||||
|  |     The target or the dependency can be a tuple of files. | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     def __init__(self, *args, **kwargs): | ||||||
|  |         self.skip_if_recent = kwargs.pop("skip_if_recent", None) | ||||||
|  |         super().__init__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |     def __call__(self, *args, **kwargs): | ||||||
|  |         title(self.__doc__ or self.name) | ||||||
|  |  | ||||||
|  |         if self.skip_if_recent: | ||||||
|  |             targets, dependencies = self.skip_if_recent | ||||||
|  |             if isinstance(targets, str): | ||||||
|  |                 targets = (targets,) | ||||||
|  |             if isinstance(dependencies, str): | ||||||
|  |                 dependencies = (dependencies,) | ||||||
|  |  | ||||||
|  |             target_mtime = min( | ||||||
|  |                 ((Path(file).exists() and Path(file).lstat().st_mtime) or 0) | ||||||
|  |                 for file in targets | ||||||
|  |             ) | ||||||
|  |             dependency_mtime = max(Path(file).lstat().st_mtime for file in dependencies) | ||||||
|  |  | ||||||
|  |             if dependency_mtime < target_mtime: | ||||||
|  |                 print(f"{self.name}, nothing to do") | ||||||
|  |                 return None | ||||||
|  |  | ||||||
|  |         return super().__call__(*args, **kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | task = partial(task, klass=Task) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task() | ||||||
|  | def venv(c): | ||||||
|  |     """ | ||||||
|  |     Create a virtual environment for dev | ||||||
|  |     """ | ||||||
|  |     c.run("python -m venv --clear venv") | ||||||
|  |     c.run("venv/bin/pip install -U setuptools wheel pip") | ||||||
|  |     c.run("venv/bin/pip install -e .") | ||||||
|  |     c.run("venv/bin/pip install -r requirements/test.txt") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task() | ||||||
|  | def check_readme(c): | ||||||
|  |     """ | ||||||
|  |     Check the README.rst render | ||||||
|  |     """ | ||||||
|  |     c.run("python -m readme_renderer -o /dev/null README.rst") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task() | ||||||
|  | def test(c, isolate=False): | ||||||
|  |     """ | ||||||
|  |     Launch tests | ||||||
|  |     """ | ||||||
|  |     #opt = "I" if isolate else "" | ||||||
|  |     #c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/") | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task() | ||||||
|  | def tag_eq_version(c): | ||||||
|  |     """ | ||||||
|  |     Ensure that the last git tag matches the package version | ||||||
|  |     """ | ||||||
|  |     git_tag = c.run("git describe --tags HEAD", hide=True).stdout.strip() | ||||||
|  |     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||||
|  |     if git_tag != f"v{package_version}": | ||||||
|  |         raise Exit( | ||||||
|  |             f"ERROR: The git tag {git_tag!r} does not matches" | ||||||
|  |             f" the package version {package_version!r}" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task() | ||||||
|  | def prepare_ci_env(c): | ||||||
|  |     """ | ||||||
|  |     Prepare CI environment | ||||||
|  |     """ | ||||||
|  |     title("Creating virtual env", "=") | ||||||
|  |     c.run("python -m venv --clear dist_venv") | ||||||
|  |     activate_venv(c, "dist_venv") | ||||||
|  |  | ||||||
|  |     c.run("dist_venv/bin/python -m pip install -U setuptools wheel pip") | ||||||
|  |  | ||||||
|  |     title("Building wheel", "=") | ||||||
|  |     c.run("dist_venv/bin/python setup.py build bdist_wheel") | ||||||
|  |  | ||||||
|  |     title("Installing wheel", "=") | ||||||
|  |     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||||
|  |     print([x for x in Path("dist").glob('*')]) | ||||||
|  |     dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) | ||||||
|  |     c.run(f"dist_venv/bin/python -m pip install {dist}") | ||||||
|  |  | ||||||
|  |     # We verify that aiohttp-pydantic module is importable before installing CI tools. | ||||||
|  |     package_names = read_configuration("./setup.cfg")["options"]["packages"] | ||||||
|  |     for package_name in package_names: | ||||||
|  |         c.run(f"dist_venv/bin/python -I -c 'import {package_name}'") | ||||||
|  |  | ||||||
|  |     title("Installing CI tools", "=") | ||||||
|  |     c.run("dist_venv/bin/python -m pip install -r requirements/ci.txt") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task(prepare_ci_env, check_readme, call(test, isolate=True), klass=Task_) | ||||||
|  | def prepare_upload(c): | ||||||
|  |     """ | ||||||
|  |     Launch all tests and verifications | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @task(tag_eq_version, prepare_upload) | ||||||
|  | def upload(c, pypi_user=None, pypi_password=None, pypi_url=None): | ||||||
|  |     """ | ||||||
|  |     Upload on pypi | ||||||
|  |     """ | ||||||
|  |     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||||
|  |     dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) | ||||||
|  |     if pypi_user is not None and pypi_password is not None: | ||||||
|  |         c.run( | ||||||
|  |             f"dist_venv/bin/twine upload --non-interactive" | ||||||
|  |             f" -u {pypi_user} -p {pypi_password} {dist}" | ||||||
|  |             f" --repository-url {pypi_url}", | ||||||
|  |             hide=True, | ||||||
|  |         ) | ||||||
|  |     else: | ||||||
|  |         c.run(f"dist_venv/bin/twine upload --repository-url {pypi_url} --repository aiohttp-pydantic {dist}") | ||||||
| @@ -1,3 +0,0 @@ | |||||||
| . |  | ||||||
| pytest==6.1.1 |  | ||||||
| pytest-aiohttp==0.3.0 |  | ||||||
							
								
								
									
										76
									
								
								tests/test_group.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								tests/test_group.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.injectors import ( | ||||||
|  |     Group, | ||||||
|  |     _get_group_signature, | ||||||
|  |     _unpack_group_in_signature, | ||||||
|  |     DuplicateNames, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_get_group_signature_with_a2b2(): | ||||||
|  |     class A(Group): | ||||||
|  |         a: int = 1 | ||||||
|  |  | ||||||
|  |     class B(Group): | ||||||
|  |         b: str = "b" | ||||||
|  |  | ||||||
|  |     class B2(B): | ||||||
|  |         b: str = "b2"  # Overwrite default value | ||||||
|  |  | ||||||
|  |     class A2(A): | ||||||
|  |         a: int  # Remove default value | ||||||
|  |  | ||||||
|  |     class A2B2(A2, B2): | ||||||
|  |         ab2: float | ||||||
|  |  | ||||||
|  |     assert ({"ab2": float, "a": int, "b": str}, {"b": "b2"}) == _get_group_signature( | ||||||
|  |         A2B2 | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unpack_group_in_signature(): | ||||||
|  |     class PaginationGroup(Group): | ||||||
|  |         page: int | ||||||
|  |         page_size: int = 20 | ||||||
|  |  | ||||||
|  |     args = {"pagination": PaginationGroup, "name": str, "age": int} | ||||||
|  |  | ||||||
|  |     default = {"age": 18} | ||||||
|  |  | ||||||
|  |     _unpack_group_in_signature(args, default) | ||||||
|  |  | ||||||
|  |     assert args == {"page": int, "page_size": int, "name": str, "age": int} | ||||||
|  |  | ||||||
|  |     assert default == {"age": 18, "page_size": 20} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unpack_group_in_signature_with_duplicate_error(): | ||||||
|  |     class PaginationGroup(Group): | ||||||
|  |         page: int | ||||||
|  |         page_size: int = 20 | ||||||
|  |  | ||||||
|  |     args = {"pagination": PaginationGroup, "page": int, "age": int} | ||||||
|  |  | ||||||
|  |     with pytest.raises(DuplicateNames) as e_info: | ||||||
|  |         _unpack_group_in_signature(args, {}) | ||||||
|  |  | ||||||
|  |     assert e_info.value.group is PaginationGroup | ||||||
|  |     assert e_info.value.attr_name == "page" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_unpack_group_in_signature_with_parameters_overwrite(): | ||||||
|  |     class PaginationGroup(Group): | ||||||
|  |         page: int = 0 | ||||||
|  |         page_size: int = 20 | ||||||
|  |  | ||||||
|  |     args = {"page": PaginationGroup, "age": int} | ||||||
|  |  | ||||||
|  |     default = {} | ||||||
|  |     _unpack_group_in_signature(args, default) | ||||||
|  |  | ||||||
|  |     assert args == {"page": int, "page_size": int, "age": int} | ||||||
|  |  | ||||||
|  |     assert default == {"page": 0, "page_size": 20} | ||||||
							
								
								
									
										59
									
								
								tests/test_hook_to_custom_response.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								tests/test_hook_to_custom_response.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Iterator, List, Optional | ||||||
|  |  | ||||||
|  | from aiohttp import web | ||||||
|  | from aiohttp.web_response import json_response | ||||||
|  | from pydantic import BaseModel, RootModel | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  | from aiohttp_pydantic.view import PydanticValidationError | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModel(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     nb_page: Optional[int] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModels(RootModel): | ||||||
|  |     root: List[ArticleModel] | ||||||
|  |  | ||||||
|  |     def __iter__(self) -> Iterator[ArticleModel]: | ||||||
|  |         return iter(self.root) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleView(PydanticView): | ||||||
|  |     async def post(self, article: ArticleModel): | ||||||
|  |         return web.json_response(article.dict()) | ||||||
|  |  | ||||||
|  |     async def put(self, articles: ArticleModels): | ||||||
|  |         return web.json_response([article.dict() for article in articles]) | ||||||
|  |  | ||||||
|  |     async def on_validation_error(self, exception, context): | ||||||
|  |         errors = exception.errors() | ||||||
|  |         own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] | ||||||
|  |         return json_response(data=own_errors, status=400) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"}) | ||||||
|  |  | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'loc_in': 'body', | ||||||
|  |             'input': 'foo', | ||||||
|  |             'loc': ['nb_page'], | ||||||
|  |             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||||
|  |             'type': 'int_parsing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/int_parsing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
							
								
								
									
										73
									
								
								tests/test_inheritance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								tests/test_inheritance.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  | from aiohttp.web import View | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def count_wrappers(obj: Any) -> int: | ||||||
|  |     """Count the number of times that an object is wrapped.""" | ||||||
|  |     i = 0 | ||||||
|  |     while i < 10: | ||||||
|  |         try: | ||||||
|  |             obj = obj.__wrapped__ | ||||||
|  |         except AttributeError: | ||||||
|  |             return i | ||||||
|  |         else: | ||||||
|  |             i += 1 | ||||||
|  |     raise RuntimeError("Too many wrappers") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AiohttpViewParent(View): | ||||||
|  |     async def put(self): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PydanticViewParent(PydanticView): | ||||||
|  |     async def get(self, id: int, /): | ||||||
|  |         pass | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_allowed_methods_get_decorated_exactly_once(): | ||||||
|  |     class ChildView(PydanticViewParent): | ||||||
|  |         async def post(self, id: int, /): | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     class SubChildView(ChildView): | ||||||
|  |         async def get(self, id: int, /): | ||||||
|  |             return super().get(id) | ||||||
|  |  | ||||||
|  |     assert count_wrappers(ChildView.post) == 1 | ||||||
|  |     assert count_wrappers(ChildView.get) == 1 | ||||||
|  |     assert count_wrappers(SubChildView.post) == 1 | ||||||
|  |     assert count_wrappers(SubChildView.get) == 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_methods_inherited_from_aiohttp_view_should_not_be_decorated(): | ||||||
|  |     class ChildView(AiohttpViewParent, PydanticView): | ||||||
|  |         async def post(self, id: int, /): | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     assert count_wrappers(ChildView.put) == 0 | ||||||
|  |     assert count_wrappers(ChildView.post) == 1 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_allowed_methods_are_set_correctly(): | ||||||
|  |     class ChildView(AiohttpViewParent, PydanticView): | ||||||
|  |         async def post(self, id: int, /): | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     assert ChildView.allowed_methods == {"POST", "PUT"} | ||||||
|  |  | ||||||
|  |     class ChildView(PydanticViewParent): | ||||||
|  |         async def post(self, id: int, /): | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     assert ChildView.allowed_methods == {"POST", "GET"} | ||||||
|  |  | ||||||
|  |     class ChildView(AiohttpViewParent, PydanticViewParent): | ||||||
|  |         async def post(self, id: int, /): | ||||||
|  |             pass | ||||||
|  |  | ||||||
|  |     assert ChildView.allowed_methods == {"POST", "PUT", "GET"} | ||||||
							
								
								
									
										0
									
								
								tests/test_oas/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_oas/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								tests/test_oas/test_cmd/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_oas/test_cmd/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										1
									
								
								tests/test_oas/test_cmd/oas_base.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								tests/test_oas/test_cmd/oas_base.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | {"info": {"title": "MyApp",  "version": "1.0.0"}} | ||||||
							
								
								
									
										29
									
								
								tests/test_oas/test_cmd/sample.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								tests/test_oas/test_cmd/sample.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from aiohttp import web | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class View1(PydanticView): | ||||||
|  |     async def get(self, a: int, /): | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class View2(PydanticView): | ||||||
|  |     async def post(self, b: int, /): | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | sub_app = web.Application() | ||||||
|  | sub_app.router.add_view("/route-2/{b}", View2) | ||||||
|  |  | ||||||
|  | app = web.Application() | ||||||
|  | app.router.add_view("/route-1/{a}", View1) | ||||||
|  | app.add_subapp("/sub-app", sub_app) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def make_app(): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/route-3/{a}", View1) | ||||||
|  |     return app | ||||||
							
								
								
									
										148
									
								
								tests/test_oas/test_cmd/test_cmd.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								tests/test_oas/test_cmd/test_cmd.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import argparse | ||||||
|  | from textwrap import dedent | ||||||
|  | from io import StringIO | ||||||
|  | from pathlib import Path | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas import cmd | ||||||
|  |  | ||||||
|  | PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | def cmd_line(): | ||||||
|  |     parser = argparse.ArgumentParser() | ||||||
|  |     cmd.setup(parser) | ||||||
|  |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_show_oas_of_app(cmd_line): | ||||||
|  |     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) | ||||||
|  |     args.output = StringIO() | ||||||
|  |     args.func(args) | ||||||
|  |  | ||||||
|  |     expected = dedent( | ||||||
|  |         """ | ||||||
|  |     { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "paths": { | ||||||
|  |             "/route-1/{a}": { | ||||||
|  |                 "get": { | ||||||
|  |                     "parameters": [ | ||||||
|  |                         { | ||||||
|  |                             "in": "path", | ||||||
|  |                             "name": "a", | ||||||
|  |                             "required": true, | ||||||
|  |                             "schema": { | ||||||
|  |                                 "title": "a", | ||||||
|  |                                 "type": "integer" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             "/sub-app/route-2/{b}": { | ||||||
|  |                 "post": { | ||||||
|  |                     "parameters": [ | ||||||
|  |                         { | ||||||
|  |                             "in": "path", | ||||||
|  |                             "name": "b", | ||||||
|  |                             "required": true, | ||||||
|  |                             "schema": { | ||||||
|  |                                 "title": "b", | ||||||
|  |                                 "type": "integer" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert args.output.getvalue().strip() == expected.strip() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_show_oas_of_sub_app(cmd_line): | ||||||
|  |     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) | ||||||
|  |     args.output = StringIO() | ||||||
|  |     args.func(args) | ||||||
|  |     expected = dedent( | ||||||
|  |         """ | ||||||
|  |     { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "paths": { | ||||||
|  |             "/sub-app/route-2/{b}": { | ||||||
|  |                 "post": { | ||||||
|  |                     "parameters": [ | ||||||
|  |                         { | ||||||
|  |                             "in": "path", | ||||||
|  |                             "name": "b", | ||||||
|  |                             "required": true, | ||||||
|  |                             "schema": { | ||||||
|  |                                 "title": "b", | ||||||
|  |                                 "type": "integer" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert args.output.getvalue().strip() == expected.strip() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_show_oas_of_a_callable(cmd_line): | ||||||
|  |     args = cmd_line.parse_args( | ||||||
|  |         [ | ||||||
|  |             "tests.test_oas.test_cmd.sample:make_app()", | ||||||
|  |             "--base-oas-file", | ||||||
|  |             PATH_TO_BASE_JSON_FILE, | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     args.output = StringIO() | ||||||
|  |     args.func(args) | ||||||
|  |     expected = dedent( | ||||||
|  |         """ | ||||||
|  |         { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "paths": { | ||||||
|  |             "/route-3/{a}": { | ||||||
|  |                 "get": { | ||||||
|  |                     "parameters": [ | ||||||
|  |                         { | ||||||
|  |                             "in": "path", | ||||||
|  |                             "name": "a", | ||||||
|  |                             "required": true, | ||||||
|  |                             "schema": { | ||||||
|  |                                 "title": "a", | ||||||
|  |                                 "type": "integer" | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert args.output.getvalue().strip() == expected.strip() | ||||||
							
								
								
									
										157
									
								
								tests/test_oas/test_docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										157
									
								
								tests/test_oas/test_docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,157 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from textwrap import dedent | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas.docstring_parser import ( | ||||||
|  |     status_code, | ||||||
|  |     tags, | ||||||
|  |     operation, | ||||||
|  |     _i_extract_block, | ||||||
|  |     LinesIterator, | ||||||
|  | ) | ||||||
|  | from inspect import getdoc | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def web_handler(): | ||||||
|  |     """ | ||||||
|  |     bla bla bla | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     Tags: tag1,  tag2 | ||||||
|  |       , tag3, | ||||||
|  |  | ||||||
|  |       t   a | ||||||
|  |       g | ||||||
|  |          4 | ||||||
|  |  | ||||||
|  |     Status Codes: | ||||||
|  |         200: line 1 | ||||||
|  |  | ||||||
|  |           line 2: | ||||||
|  |             - line 3 | ||||||
|  |             - line 4 | ||||||
|  |  | ||||||
|  |           line 5 | ||||||
|  |  | ||||||
|  |         300: line A 1 | ||||||
|  |  | ||||||
|  |         301: line B 1 | ||||||
|  |           line B 2 | ||||||
|  |         400: line C 1 | ||||||
|  |  | ||||||
|  |              line C 2 | ||||||
|  |  | ||||||
|  |                line C 3 | ||||||
|  |  | ||||||
|  |     bla bla | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def web_handler_2(): | ||||||
|  |     """ | ||||||
|  |     bla bla bla | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     Tags: tag1 | ||||||
|  |     Status Codes: | ||||||
|  |         200: line 1 | ||||||
|  |  | ||||||
|  |     bla bla | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_lines_iterator(): | ||||||
|  |     lines_iterator = LinesIterator("AAAA\nBBBB") | ||||||
|  |     with pytest.raises(StopIteration): | ||||||
|  |         lines_iterator.rewind() | ||||||
|  |  | ||||||
|  |     assert lines_iterator.next_line() == "AAAA" | ||||||
|  |     assert lines_iterator.rewind() | ||||||
|  |     assert lines_iterator.next_line() == "AAAA" | ||||||
|  |     assert lines_iterator.next_line() == "BBBB" | ||||||
|  |     with pytest.raises(StopIteration): | ||||||
|  |         lines_iterator.next_line() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_status_code(): | ||||||
|  |  | ||||||
|  |     expected = { | ||||||
|  |         200: "line 1\n\nline 2:\n  - line 3\n  - line 4\n\nline 5", | ||||||
|  |         300: "line A 1", | ||||||
|  |         301: "line B 1\nline B 2", | ||||||
|  |         400: "line C 1\n\nline C 2\n\n  line C 3", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     assert status_code(getdoc(web_handler)) == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_tags(): | ||||||
|  |     expected = ["tag1", "tag2", "tag3", "t a g 4"] | ||||||
|  |     assert tags(getdoc(web_handler)) == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_operation(): | ||||||
|  |     expected = "bla bla bla\n\n\nbla bla" | ||||||
|  |     assert operation(getdoc(web_handler)) == expected | ||||||
|  |     assert operation(getdoc(web_handler_2)) == expected | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_i_extract_block(): | ||||||
|  |  | ||||||
|  |     blocks = dedent( | ||||||
|  |         """ | ||||||
|  |     aaaa: | ||||||
|  |  | ||||||
|  |       bbbb | ||||||
|  |      | ||||||
|  |       cccc | ||||||
|  |     dddd | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     lines = LinesIterator(blocks) | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == """aaaa:\n\n  bbbb\n\n  cccc""" | ||||||
|  |  | ||||||
|  |     blocks = dedent( | ||||||
|  |         """ | ||||||
|  |     aaaa: | ||||||
|  |  | ||||||
|  |       bbbb | ||||||
|  |  | ||||||
|  |       cccc | ||||||
|  |  | ||||||
|  |     dddd | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     lines = LinesIterator(blocks) | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == """aaaa:\n\n  bbbb\n\n  cccc\n""" | ||||||
|  |  | ||||||
|  |     blocks = dedent( | ||||||
|  |         """ | ||||||
|  |     aaaa: | ||||||
|  |  | ||||||
|  |       bbbb | ||||||
|  |  | ||||||
|  |       cccc | ||||||
|  |     """ | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     lines = LinesIterator(blocks) | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == """aaaa:\n\n  bbbb\n\n  cccc""" | ||||||
|  |  | ||||||
|  |     lines = LinesIterator("") | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == "" | ||||||
|  |  | ||||||
|  |     lines = LinesIterator("\n") | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == "" | ||||||
|  |  | ||||||
|  |     lines = LinesIterator("aaaa:") | ||||||
|  |     text = "\n".join(_i_extract_block(lines)) | ||||||
|  |     assert text == "aaaa:" | ||||||
							
								
								
									
										0
									
								
								tests/test_oas/test_struct/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/test_oas/test_struct/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										76
									
								
								tests/test_oas/test_struct/test_info.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								tests/test_oas/test_struct/test_info.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_info_title(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     assert oas.info.title == "Aiohttp pydantic application" | ||||||
|  |     oas.info.title = "Info Title" | ||||||
|  |     assert oas.info.title == "Info Title" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Info Title", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_info_description(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     assert oas.info.description is None | ||||||
|  |     oas.info.description = "info description" | ||||||
|  |     assert oas.info.description == "info description" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "description": "info description", | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_info_version(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     assert oas.info.version == "1.0.0" | ||||||
|  |     oas.info.version = "3.14" | ||||||
|  |     assert oas.info.version == "3.14" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "info": {"version": "3.14", "title": "Aiohttp pydantic application"}, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_info_terms_of_service(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     assert oas.info.terms_of_service is None | ||||||
|  |     oas.info.terms_of_service = "http://example.com/terms/" | ||||||
|  |     assert oas.info.terms_of_service == "http://example.com/terms/" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |             "termsOfService": "http://example.com/terms/", | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skip("Not yet implemented") | ||||||
|  | def test_info_license(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     oas.info.license.name = "Apache 2.0" | ||||||
|  |     oas.info.license.url = "https://www.apache.org/licenses/LICENSE-2.0.html" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "license": { | ||||||
|  |                 "name": "Apache 2.0", | ||||||
|  |                 "url": "https://www.apache.org/licenses/LICENSE-2.0.html", | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
							
								
								
									
										147
									
								
								tests/test_oas/test_struct/test_paths.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										147
									
								
								tests/test_oas/test_struct/test_paths.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,147 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_description(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     oas.paths["/users/{id}"].description = "This route ..." | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": {"/users/{id}": {"description": "This route ..."}}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_get(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     oas.paths["/users/{id}"].get | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": {"/users/{id}": {"get": {}}}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_description(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     operation = oas.paths["/users/{id}"].get | ||||||
|  |     operation.description = "Long descriptions ..." | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_summary(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     operation = oas.paths["/users/{id}"].get | ||||||
|  |     operation.summary = "Updates a pet in the store with form data" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": { | ||||||
|  |             "/users/{id}": { | ||||||
|  |                 "get": {"summary": "Updates a pet in the store with form data"} | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_parameters(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     operation = oas.paths["/users/{petId}"].get | ||||||
|  |     parameter = operation.parameters[0] | ||||||
|  |     parameter.name = "petId" | ||||||
|  |     parameter.description = "ID of pet that needs to be updated" | ||||||
|  |     parameter.in_ = "path" | ||||||
|  |     parameter.required = True | ||||||
|  |  | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": { | ||||||
|  |             "/users/{petId}": { | ||||||
|  |                 "get": { | ||||||
|  |                     "parameters": [ | ||||||
|  |                         { | ||||||
|  |                             "description": "ID of pet that needs to be updated", | ||||||
|  |                             "in": "path", | ||||||
|  |                             "name": "petId", | ||||||
|  |                             "required": True, | ||||||
|  |                         } | ||||||
|  |                     ] | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_requestBody(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     request_body = oas.paths["/users/{petId}"].get.request_body | ||||||
|  |     request_body.description = "user to add to the system" | ||||||
|  |     request_body.content = { | ||||||
|  |         "application/json": { | ||||||
|  |             "schema": {"$ref": "#/components/schemas/User"}, | ||||||
|  |             "examples": { | ||||||
|  |                 "user": { | ||||||
|  |                     "summary": "User Example", | ||||||
|  |                     "externalValue": "http://foo.bar/examples/user-example.json", | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     request_body.required = True | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "paths": { | ||||||
|  |             "/users/{petId}": { | ||||||
|  |                 "get": { | ||||||
|  |                     "requestBody": { | ||||||
|  |                         "content": { | ||||||
|  |                             "application/json": { | ||||||
|  |                                 "examples": { | ||||||
|  |                                     "user": { | ||||||
|  |                                         "externalValue": "http://foo.bar/examples/user-example.json", | ||||||
|  |                                         "summary": "User Example", | ||||||
|  |                                     } | ||||||
|  |                                 }, | ||||||
|  |                                 "schema": {"$ref": "#/components/schemas/User"}, | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                         "description": "user to add to the system", | ||||||
|  |                         "required": True, | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_tags(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     operation = oas.paths["/users/{petId}"].get | ||||||
|  |     assert operation.tags == [] | ||||||
|  |     operation.tags = ["pets"] | ||||||
|  |  | ||||||
|  |     assert oas.spec["paths"]["/users/{petId}"] == {"get": {"tags": ["pets"]}} | ||||||
|  |  | ||||||
|  |     operation.tags = [] | ||||||
|  |     assert oas.spec["paths"]["/users/{petId}"] == {"get": {}} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_paths_operation_responses(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     response = oas.paths["/users/{petId}"].get.responses[200] | ||||||
|  |     response.description = "A complex object array response" | ||||||
|  |     response.content = { | ||||||
|  |         "application/json": { | ||||||
|  |             "schema": { | ||||||
|  |                 "type": "array", | ||||||
|  |                 "items": {"$ref": "#/components/schemas/VeryComplexType"}, | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
							
								
								
									
										40
									
								
								tests/test_oas/test_struct/test_servers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								tests/test_oas/test_struct/test_servers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_sever_url(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     oas.servers[0].url = "https://development.gigantic-server.com/v1" | ||||||
|  |     oas.servers[1].url = "https://development.gigantic-server.com/v2" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "servers": [ | ||||||
|  |             {"url": "https://development.gigantic-server.com/v1"}, | ||||||
|  |             {"url": "https://development.gigantic-server.com/v2"}, | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def test_sever_description(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
|  |     oas.servers[0].url = "https://development.gigantic-server.com/v1" | ||||||
|  |     oas.servers[0].description = "Development server" | ||||||
|  |     assert oas.spec == { | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "servers": [ | ||||||
|  |             { | ||||||
|  |                 "url": "https://development.gigantic-server.com/v1", | ||||||
|  |                 "description": "Development server", | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.mark.skip("Not yet implemented") | ||||||
|  | def test_sever_variables(): | ||||||
|  |     oas = OpenApiSpec3() | ||||||
							
								
								
									
										485
									
								
								tests/test_oas/test_view.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										485
									
								
								tests/test_oas/test_view.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,485 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  | from typing import List, Optional, Union, Literal, Annotated | ||||||
|  | from uuid import UUID | ||||||
|  |  | ||||||
|  | import pytest | ||||||
|  | from aiohttp import web | ||||||
|  | from pydantic import Field, RootModel | ||||||
|  | from pydantic.main import BaseModel | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView, oas | ||||||
|  | from aiohttp_pydantic.injectors import Group | ||||||
|  | from aiohttp_pydantic.oas.typing import r200, r201, r204, r404, r400 | ||||||
|  | from aiohttp_pydantic.oas.view import generate_oas | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Color(str, Enum): | ||||||
|  |     """ | ||||||
|  |     Pet color | ||||||
|  |     """ | ||||||
|  |     RED = "red" | ||||||
|  |     GREEN = "green" | ||||||
|  |     PINK = "pink" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Lang(str, Enum): | ||||||
|  |     EN = 'en' | ||||||
|  |     FR = 'fr' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Toy(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     color: Color | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Pet(BaseModel): | ||||||
|  |     id: int | ||||||
|  |     name: Optional[str] = Field(None) | ||||||
|  |     toys: List[Toy] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Error(BaseModel): | ||||||
|  |     code: int | ||||||
|  |     text: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Cat(BaseModel): | ||||||
|  |     pet_type: Literal['cat'] | ||||||
|  |     meows: int | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Dog(BaseModel): | ||||||
|  |     pet_type: Literal['dog'] | ||||||
|  |     barks: float | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Animal(RootModel): | ||||||
|  |     root: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PetCollectionView(PydanticView): | ||||||
|  |     async def get( | ||||||
|  |             self, format: str, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None | ||||||
|  |     ) -> r200[List[Pet]]: | ||||||
|  |         """ | ||||||
|  |         Get a list of pets | ||||||
|  |  | ||||||
|  |         Tags: pet | ||||||
|  |         Status Codes: | ||||||
|  |           200: Successful operation | ||||||
|  |         OperationId: createPet | ||||||
|  |         """ | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |     async def post(self, pet: Pet) -> r201[Pet]: | ||||||
|  |         """Create a Pet""" | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PetItemView(PydanticView): | ||||||
|  |     async def get( | ||||||
|  |             self, | ||||||
|  |             id: int, | ||||||
|  |             /, | ||||||
|  |             size: Union[int, Literal["x", "l", "s"]], | ||||||
|  |             day: Union[int, Literal["now"]] = "now", | ||||||
|  |     ) -> Union[r200[Pet], r404[Error], r400[Error]]: | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |     async def put(self, id: int, /, pet: Pet): | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |     async def delete(self, id: int, /) -> r204: | ||||||
|  |         """ | ||||||
|  |         Status Code: | ||||||
|  |           204: Empty but OK | ||||||
|  |         """ | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ViewResponseReturnASimpleType(PydanticView): | ||||||
|  |     async def get(self) -> r200[int]: | ||||||
|  |         """ | ||||||
|  |         Status Codes: | ||||||
|  |           200: The new number | ||||||
|  |         """ | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DiscriminatedView(PydanticView): | ||||||
|  |     async def post(self, /, request: Animal) -> r200[int]: | ||||||
|  |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def ensure_content_durability(client): | ||||||
|  |     """ | ||||||
|  |     Reload the page 2 times to ensure that content is always the same | ||||||
|  |     note: pydantic can return a cached dict, if a view updates the dict the | ||||||
|  |     output will be incoherent | ||||||
|  |     """ | ||||||
|  |     response_1 = await client.get("/oas/spec") | ||||||
|  |     assert response_1.status == 200 | ||||||
|  |     assert response_1.content_type == "application/json" | ||||||
|  |     content_1 = await response_1.json() | ||||||
|  |  | ||||||
|  |     response_2 = await client.get("/oas/spec") | ||||||
|  |     content_2 = await response_2.json() | ||||||
|  |     assert content_1 == content_2 | ||||||
|  |  | ||||||
|  |     return content_2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @pytest.fixture | ||||||
|  | async def generated_oas(aiohttp_client, event_loop) -> web.Application: | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/pets", PetCollectionView) | ||||||
|  |     app.router.add_view("/pets/{id}", PetItemView) | ||||||
|  |     app.router.add_view("/simple-type", ViewResponseReturnASimpleType) | ||||||
|  |     app.router.add_view("/animals", DiscriminatedView) | ||||||
|  |     oas.setup(app) | ||||||
|  |  | ||||||
|  |     return await ensure_content_durability(await aiohttp_client(app)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_oas_should_have_components_schemas(generated_oas): | ||||||
|  |     assert generated_oas["components"]["schemas"] == { | ||||||
|  |         'Cat': {'properties': {'meows': {'title': 'Meows', 'type': 'integer'}, | ||||||
|  |                                'pet_type': {'const': 'cat', 'title': 'Pet Type'}}, | ||||||
|  |                 'required': ['pet_type', 'meows'], | ||||||
|  |                 'title': 'Cat', | ||||||
|  |                 'type': 'object'}, | ||||||
|  |         "Color": { | ||||||
|  |             "description": "Pet color", | ||||||
|  |             "enum": ["red", "green", "pink"], | ||||||
|  |             "title": "Color", | ||||||
|  |             "type": "string", | ||||||
|  |         }, | ||||||
|  |         'Dog': {'properties': {'barks': {'title': 'Barks', 'type': 'number'}, | ||||||
|  |                                'pet_type': {'const': 'dog', 'title': 'Pet Type'}}, | ||||||
|  |                 'required': ['pet_type', 'barks'], | ||||||
|  |                 'title': 'Dog', | ||||||
|  |                 'type': 'object'}, | ||||||
|  |         'Error': { | ||||||
|  |             'properties': { | ||||||
|  |                 'code': {'title': 'Code', 'type': 'integer'}, | ||||||
|  |                 'text': {'title': 'Text', 'type': 'string'}}, | ||||||
|  |             'required': ['code', 'text'], | ||||||
|  |             'title': 'Error', | ||||||
|  |             'type': 'object' | ||||||
|  |         }, | ||||||
|  |         'Lang': { | ||||||
|  |             'enum': ['en', 'fr'], | ||||||
|  |             'title': 'Lang', | ||||||
|  |             'type': 'string' | ||||||
|  |         }, | ||||||
|  |         "Toy": { | ||||||
|  |             "properties": { | ||||||
|  |                 "color": {"$ref": "#/components/schemas/Color"}, | ||||||
|  |                 "name": {"title": "Name", "type": "string"}, | ||||||
|  |             }, | ||||||
|  |             "required": ["name", "color"], | ||||||
|  |             "title": "Toy", | ||||||
|  |             "type": "object", | ||||||
|  |         }, | ||||||
|  |         'Pet': { | ||||||
|  |             'properties': { | ||||||
|  |                 'id': {'title': 'Id', 'type': 'integer'}, | ||||||
|  |                 'name': { | ||||||
|  |                     'anyOf': [ | ||||||
|  |                         {'type': 'string'}, | ||||||
|  |                         {'type': 'null'} | ||||||
|  |                     ], | ||||||
|  |                     'default': None, | ||||||
|  |                     'title': 'Name'}, | ||||||
|  |                 'toys': { | ||||||
|  |                     'items': {'$ref': '#/components/schemas/Toy'}, | ||||||
|  |                     'title': 'Toys', | ||||||
|  |                     'type': 'array' | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             'required': ['id', 'toys'], | ||||||
|  |             'title': 'Pet', | ||||||
|  |             'type': 'object' | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_oas_should_have_pets_paths(generated_oas): | ||||||
|  |     assert "/pets" in generated_oas["paths"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_pets_route_should_have_get_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/pets"]["get"] == { | ||||||
|  |         "description": "Get a list of pets", | ||||||
|  |         "operationId": "createPet", | ||||||
|  |         "tags": ["pet"], | ||||||
|  |         "parameters": [ | ||||||
|  |             { | ||||||
|  |                 "in": "query", | ||||||
|  |                 "name": "format", | ||||||
|  |                 "required": True, | ||||||
|  |                 "schema": {"title": "format", "type": "string"}, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 'in': 'query', | ||||||
|  |                 'name': 'lang', | ||||||
|  |                 'required': False, | ||||||
|  |                 'schema': { | ||||||
|  |                     'allOf': [{'$ref': '#/components/schemas/Lang'}], | ||||||
|  |                     'default': 'en', | ||||||
|  |                     'title': 'lang' | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "in": "query", | ||||||
|  |                 "name": "name", | ||||||
|  |                 "required": False, | ||||||
|  |                 "schema": { | ||||||
|  |                     'anyOf': [{'type': 'string'}, {'type': 'null'}], | ||||||
|  |                     'default': None, | ||||||
|  |                     'title': 'name' | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "in": "header", | ||||||
|  |                 "name": "promo", | ||||||
|  |                 "required": False, | ||||||
|  |                 "schema": { | ||||||
|  |                     'anyOf': [ | ||||||
|  |                         {'format': 'uuid', 'type': 'string'}, | ||||||
|  |                         {'type': 'null'} | ||||||
|  |                     ], | ||||||
|  |                     'default': None, | ||||||
|  |                     'title': 'promo' | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |         "responses": { | ||||||
|  |             "200": { | ||||||
|  |                 "description": "Successful operation", | ||||||
|  |                 "content": { | ||||||
|  |                     "application/json": { | ||||||
|  |                         "schema": { | ||||||
|  |                             "items": {'$ref': '#/components/schemas/Pet'}, | ||||||
|  |                             "type": "array", | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_pets_route_should_have_post_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/pets"]["post"] == { | ||||||
|  |         "description": "Create a Pet", | ||||||
|  |         "requestBody": { | ||||||
|  |             "content": { | ||||||
|  |                 "application/json": { | ||||||
|  |                     "schema": { | ||||||
|  |                         "properties": { | ||||||
|  |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|  |                             "name": { | ||||||
|  |                                 'anyOf': [ | ||||||
|  |                                     {'type': 'string'}, | ||||||
|  |                                     {'type': 'null'} | ||||||
|  |                                 ], | ||||||
|  |                                 'default': None, | ||||||
|  |                                 'title': 'Name' | ||||||
|  |                             }, | ||||||
|  |                             "toys": { | ||||||
|  |                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                 "title": "Toys", | ||||||
|  |                                 "type": "array", | ||||||
|  |                             }, | ||||||
|  |                         }, | ||||||
|  |                         "required": ["id", "toys"], | ||||||
|  |                         "title": "Pet", | ||||||
|  |                         "type": "object", | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         "responses": { | ||||||
|  |             "201": { | ||||||
|  |                 "description": "", | ||||||
|  |                 "content": { | ||||||
|  |                     "application/json": { | ||||||
|  |                         "schema": {'$ref': '#/components/schemas/Pet'} | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_oas_should_have_pets_id_paths(generated_oas): | ||||||
|  |     assert "/pets/{id}" in generated_oas["paths"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_pets_id_route_should_have_delete_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { | ||||||
|  |         "description": "", | ||||||
|  |         "parameters": [ | ||||||
|  |             { | ||||||
|  |                 "in": "path", | ||||||
|  |                 "name": "id", | ||||||
|  |                 "required": True, | ||||||
|  |                 "schema": {"title": "id", "type": "integer"}, | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "responses": {"204": {"content": {}, "description": "Empty but OK"}}, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_pets_id_route_should_have_get_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/pets/{id}"]["get"] == { | ||||||
|  |         "parameters": [ | ||||||
|  |             { | ||||||
|  |                 "in": "path", | ||||||
|  |                 "name": "id", | ||||||
|  |                 "required": True, | ||||||
|  |                 "schema": {"title": "id", "type": "integer"}, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "in": "query", | ||||||
|  |                 "name": "size", | ||||||
|  |                 "required": True, | ||||||
|  |                 "schema": { | ||||||
|  |                     "anyOf": [ | ||||||
|  |                         {"type": "integer"}, | ||||||
|  |                         {"enum": ["x", "l", "s"], "type": "string"}, | ||||||
|  |                     ], | ||||||
|  |                     "title": "size", | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |             { | ||||||
|  |                 "in": "query", | ||||||
|  |                 "name": "day", | ||||||
|  |                 "required": False, | ||||||
|  |                 "schema": { | ||||||
|  |                     'anyOf': [{'type': 'integer'}, {'const': 'now'}], | ||||||
|  |                     'default': 'now', | ||||||
|  |                     'title': 'day' | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ], | ||||||
|  |         'responses': { | ||||||
|  |             '200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}}, | ||||||
|  |                     'description': ''}, | ||||||
|  |             '400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, | ||||||
|  |                     'description': ''}, | ||||||
|  |             '404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, | ||||||
|  |                     'description': ''} | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_pets_id_route_should_have_put_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/pets/{id}"]["put"] == { | ||||||
|  |         "parameters": [ | ||||||
|  |             { | ||||||
|  |                 "in": "path", | ||||||
|  |                 "name": "id", | ||||||
|  |                 "required": True, | ||||||
|  |                 "schema": {"title": "id", "type": "integer"}, | ||||||
|  |             } | ||||||
|  |         ], | ||||||
|  |         "requestBody": { | ||||||
|  |             "content": { | ||||||
|  |                 "application/json": { | ||||||
|  |                     "schema": { | ||||||
|  |                         "properties": { | ||||||
|  |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|  |                             "name": { | ||||||
|  |                                 'anyOf': [ | ||||||
|  |                                     {'type': 'string'}, | ||||||
|  |                                     {'type': 'null'} | ||||||
|  |                                 ], | ||||||
|  |                                 'default': None, | ||||||
|  |                                 'title': 'Name'}, | ||||||
|  |                             "toys": { | ||||||
|  |                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                 "title": "Toys", | ||||||
|  |                                 "type": "array", | ||||||
|  |                             }, | ||||||
|  |                         }, | ||||||
|  |                         "required": ["id", "toys"], | ||||||
|  |                         "title": "Pet", | ||||||
|  |                         "type": "object", | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_simple_type_route_should_have_get_method(generated_oas): | ||||||
|  |     assert generated_oas["paths"]["/simple-type"]["get"] == { | ||||||
|  |         "description": "", | ||||||
|  |         "responses": { | ||||||
|  |             "200": { | ||||||
|  |                 "content": {"application/json": {"schema": {}}}, | ||||||
|  |                 "description": "The new number", | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_view_info_default(): | ||||||
|  |     apps = (web.Application(),) | ||||||
|  |     spec = generate_oas(apps) | ||||||
|  |  | ||||||
|  |     assert spec == { | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_view_info_as_version(): | ||||||
|  |     apps = (web.Application(),) | ||||||
|  |     spec = generate_oas(apps, version_spec="test version") | ||||||
|  |  | ||||||
|  |     assert spec == { | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "test version"}, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_generated_view_info_as_title(): | ||||||
|  |     apps = (web.Application(),) | ||||||
|  |     spec = generate_oas(apps, title_spec="test title") | ||||||
|  |  | ||||||
|  |     assert spec == { | ||||||
|  |         "info": {"title": "test title", "version": "1.0.0"}, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Pagination(Group): | ||||||
|  |     page: int = 1 | ||||||
|  |     page_size: int = 20 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_use_parameters_group_should_not_impact_the_oas(aiohttp_client): | ||||||
|  |     class PetCollectionView1(PydanticView): | ||||||
|  |         async def get(self, page: int = 1, page_size: int = 20) -> r200[List[Pet]]: | ||||||
|  |             return web.json_response() | ||||||
|  |  | ||||||
|  |     class PetCollectionView2(PydanticView): | ||||||
|  |         async def get(self, pagination: Pagination) -> r200[List[Pet]]: | ||||||
|  |             return web.json_response() | ||||||
|  |  | ||||||
|  |     app1 = web.Application() | ||||||
|  |     app1.router.add_view("/pets", PetCollectionView1) | ||||||
|  |     oas.setup(app1) | ||||||
|  |  | ||||||
|  |     app2 = web.Application() | ||||||
|  |     app2.router.add_view("/pets", PetCollectionView2) | ||||||
|  |     oas.setup(app2) | ||||||
|  |  | ||||||
|  |     assert await ensure_content_durability( | ||||||
|  |         await aiohttp_client(app1) | ||||||
|  |     ) == await ensure_content_durability(await aiohttp_client(app2)) | ||||||
| @@ -1,7 +1,11 @@ | |||||||
| from aiohttp_pydantic.injectors import _parse_func_signature | from __future__ import annotations | ||||||
| from pydantic import BaseModel |  | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic.injectors import _parse_func_signature | ||||||
|  |  | ||||||
|  |  | ||||||
| class User(BaseModel): | class User(BaseModel): | ||||||
|     firstname: str |     firstname: str | ||||||
| @@ -9,7 +13,6 @@ class User(BaseModel): | |||||||
|  |  | ||||||
|  |  | ||||||
| def test_parse_func_signature(): | def test_parse_func_signature(): | ||||||
|  |  | ||||||
|     def body_only(self, user: User): |     def body_only(self, user: User): | ||||||
|         pass |         pass | ||||||
|  |  | ||||||
| @@ -37,13 +40,42 @@ def test_parse_func_signature(): | |||||||
|     def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID): |     def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID): | ||||||
|         pass |         pass | ||||||
|  |  | ||||||
|     assert _parse_func_signature(body_only) == ({}, {'user': User}, {},  {}) |     assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {}) | ||||||
|     assert _parse_func_signature(path_only) == ({'id': str}, {}, {},  {}) |     assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {}) | ||||||
|     assert _parse_func_signature(qs_only) == ({}, {}, {'page': int},  {}) |     assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {}) | ||||||
|     assert _parse_func_signature(header_only) == ({}, {}, {},  {'auth': UUID}) |     assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}, {}) | ||||||
|     assert _parse_func_signature(path_and_qs) == ({'id': str}, {}, {'page': int},  {}) |     assert _parse_func_signature(path_and_qs) == ( | ||||||
|     assert _parse_func_signature(path_and_header) == ({'id': str}, {}, {},  {'auth': UUID}) |         {"id": str}, | ||||||
|     assert _parse_func_signature(qs_and_header) == ({}, {}, {'page': int},  {'auth': UUID}) |         {}, | ||||||
|     assert _parse_func_signature(path_qs_and_header) == ({'id': str}, {}, {'page': int},  {'auth': UUID}) |         {"page": int}, | ||||||
|     assert _parse_func_signature(path_body_qs_and_header) == ({'id': str}, {'user': User}, {'page': int},  {'auth': UUID}) |         {}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|  |     assert _parse_func_signature(path_and_header) == ( | ||||||
|  |         {"id": str}, | ||||||
|  |         {}, | ||||||
|  |         {}, | ||||||
|  |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|  |     assert _parse_func_signature(qs_and_header) == ( | ||||||
|  |         {}, | ||||||
|  |         {}, | ||||||
|  |         {"page": int}, | ||||||
|  |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|  |     assert _parse_func_signature(path_qs_and_header) == ( | ||||||
|  |         {"id": str}, | ||||||
|  |         {}, | ||||||
|  |         {"page": int}, | ||||||
|  |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|  |     assert _parse_func_signature(path_body_qs_and_header) == ( | ||||||
|  |         {"id": str}, | ||||||
|  |         {"user": User}, | ||||||
|  |         {"page": int}, | ||||||
|  |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|   | |||||||
| @@ -1,52 +1,139 @@ | |||||||
| from pydantic import BaseModel | from __future__ import annotations | ||||||
| from typing import Optional |  | ||||||
|  | from typing import Iterator, List, Optional | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  | from pydantic import BaseModel, RootModel | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleModel(BaseModel): | class ArticleModel(BaseModel): | ||||||
|     name: str |     name: str | ||||||
|     nb_page: Optional[int] |     nb_page: Optional[int] = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModels(RootModel): | ||||||
|  |     root: List[ArticleModel] | ||||||
|  |  | ||||||
|  |     def __iter__(self) -> Iterator[ArticleModel]: | ||||||
|  |         return iter(self.root) | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|     async def post(self, article: ArticleModel): |     async def post(self, article: ArticleModel): | ||||||
|         return web.json_response(article.dict()) |         return web.json_response(article.dict()) | ||||||
|  |  | ||||||
|  |     async def put(self, articles: ArticleModels): | ||||||
|  |         return web.json_response([article.dict() for article in articles]) | ||||||
|  |  | ||||||
| async def test_post_an_article_without_required_field_should_return_an_error_message(aiohttp_client, loop): |  | ||||||
|  | async def test_post_an_article_without_required_field_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.post('/article', json={}) |     resp = await client.post("/article", json={}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['name'], |  | ||||||
|                                   'msg': 'field required', |     assert await resp.json() == [ | ||||||
|                                   'type': 'value_error.missing'}] |         { | ||||||
|  |             'input': {}, | ||||||
|  |             'loc': ['name'], | ||||||
|  |             'loc_in': 'body', | ||||||
|  |             'msg': 'Field required', | ||||||
|  |             'type': 'missing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/missing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_an_article_with_wrong_type_field_should_return_an_error_message(aiohttp_client, loop): | async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.post('/article', json={'name': 'foo', 'nb_page': 'foo'}) |     resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['nb_page'], |     assert await resp.json() == [ | ||||||
|                                   'msg': 'value is not a valid integer', |         { | ||||||
|                                   'type': 'type_error.integer'}] |             'input': 'foo', | ||||||
|  |             'loc': ['nb_page'], | ||||||
|  |             'loc_in': 'body', | ||||||
|  |             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||||
|  |             'type': 'int_parsing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/int_parsing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): | async def test_post_an_array_json_is_supported(aiohttp_client, event_loop): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.post('/article', json={'name': 'foo', 'nb_page': 3}) |     body = [{"name": "foo", "nb_page": 3}] * 2 | ||||||
|  |     resp = await client.put("/article", json=body) | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {'name': 'foo', 'nb_page': 3} |     assert await resp.json() == body | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_array_json_to_an_object_model_should_return_an_error( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.post("/article", json=[{"name": "foo", "nb_page": 3}]) | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'loc': ['root'], | ||||||
|  |             'loc_in': 'body', | ||||||
|  |             'msg': 'value is not a valid dict', | ||||||
|  |             'type': 'type_error.dict' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_object_json_to_a_list_model_should_return_an_error( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.put("/article", json={"name": "foo", "nb_page": 3}) | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'input': {'name': 'foo', 'nb_page': 3}, | ||||||
|  |             'loc': [], | ||||||
|  |             'loc_in': 'body', | ||||||
|  |             'msg': 'Input should be a valid list', | ||||||
|  |             'type': 'list_type', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/list_type' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.post("/article", json={"name": "foo", "nb_page": 3}) | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |     assert await resp.json() == {"name": "foo", "nb_page": 3} | ||||||
|   | |||||||
| @@ -1,11 +1,16 @@ | |||||||
| from aiohttp import web | from __future__ import annotations | ||||||
| from aiohttp_pydantic import PydanticView |  | ||||||
| from datetime import datetime |  | ||||||
| import json | import json | ||||||
|  | from datetime import datetime | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
|  | from aiohttp import web | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  | from aiohttp_pydantic.injectors import Group | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONEncoder(json.JSONEncoder): | class JSONEncoder(json.JSONEncoder): | ||||||
|  |  | ||||||
|     def default(self, o): |     def default(self, o): | ||||||
|         if isinstance(o, datetime): |         if isinstance(o, datetime): | ||||||
|             return o.isoformat() |             return o.isoformat() | ||||||
| @@ -14,54 +19,176 @@ class JSONEncoder(json.JSONEncoder): | |||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|     async def get(self, *, signature_expired: datetime): |     async def get(self, *, signature_expired: datetime): | ||||||
|         return web.json_response({'signature': signature_expired}, dumps=JSONEncoder().encode) |         return web.json_response( | ||||||
|  |             {"signature": signature_expired}, dumps=JSONEncoder().encode | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_without_required_header_should_return_an_error_message(aiohttp_client, loop): | class FormatEnum(str, Enum): | ||||||
|  |     UTM = "UMT" | ||||||
|  |     MGRS = "MGRS" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ViewWithEnumType(PydanticView): | ||||||
|  |     async def get(self, *, format: FormatEnum): | ||||||
|  |         return web.json_response({"format": format}, dumps=JSONEncoder().encode) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Signature(Group): | ||||||
|  |     signature_expired: datetime | ||||||
|  |     signature_scope: str = "read" | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def expired(self) -> datetime: | ||||||
|  |         return self.signature_expired | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def scope(self) -> str: | ||||||
|  |         return self.signature_scope | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleViewWithSignatureGroup(PydanticView): | ||||||
|  |     async def get( | ||||||
|  |             self, | ||||||
|  |             *, | ||||||
|  |             signature: Signature, | ||||||
|  |     ): | ||||||
|  |         return web.json_response( | ||||||
|  |             {"expired": signature.expired, "scope": signature.scope}, | ||||||
|  |             dumps=JSONEncoder().encode, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_without_required_header_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', headers={}) |     resp = await client.get("/article", headers={}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['signature_expired'], |  | ||||||
|                                   'msg': 'field required', |     result = await resp.json() | ||||||
|                                   'type': 'value_error.missing'}] |     assert len(result) == 1 | ||||||
|  |     result[0].pop('input') | ||||||
|  |  | ||||||
|  |     assert result == [ | ||||||
|  |         { | ||||||
|  |             'type': 'missing', | ||||||
|  |             'loc': ['signature_expired'], | ||||||
|  |             'msg': 'Field required', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/missing', | ||||||
|  |             'loc_in': 'headers' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_wrong_header_type_should_return_an_error_message(aiohttp_client, loop): | async def test_get_article_with_wrong_header_type_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', headers={'signature_expired': 'foo'}) |     resp = await client.get("/article", headers={"signature_expired": "foo"}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['signature_expired'], |  | ||||||
|                                   'msg': 'invalid datetime format', |     assert await resp.json() == [ | ||||||
|                                   'type': 'value_error.datetime'}] |         { | ||||||
|  |             'type': 'datetime_parsing', | ||||||
|  |             'loc': ['signature_expired'], | ||||||
|  |             'msg': 'Input should be a valid datetime, input is too short', | ||||||
|  |             'input': 'foo', | ||||||
|  |             'ctx': {'error': 'input is too short'}, | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/datetime_parsing', | ||||||
|  |             'loc_in': 'headers' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_valid_header_should_return_the_parsed_type(aiohttp_client, loop): | async def test_get_article_with_valid_header_should_return_the_parsed_type( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', headers={'signature_expired': '2020-10-04T18:01:00'}) |     resp = await client.get( | ||||||
|  |         "/article", headers={"signature_expired": "2020-10-04T18:01:00"} | ||||||
|  |     ) | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {'signature': '2020-10-04T18:01:00'} |     assert await resp.json() == {"signature": "2020-10-04T18:01:00"} | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_valid_header_containing_hyphen_should_be_returned(aiohttp_client, loop): | async def test_get_article_with_valid_header_containing_hyphen_should_be_returned( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', headers={'Signature-Expired': '2020-10-04T18:01:00'}) |     resp = await client.get( | ||||||
|  |         "/article", headers={"Signature-Expired": "2020-10-04T18:01:00"} | ||||||
|  |     ) | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {'signature': '2020-10-04T18:01:00'} |     assert await resp.json() == {"signature": "2020-10-04T18:01:00"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/coord", ViewWithEnumType) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.get("/coord", headers={"format": "WGS84"}) | ||||||
|  |  | ||||||
|  |     assert ( | ||||||
|  |             await resp.json() | ||||||
|  |             == [ | ||||||
|  |                 { | ||||||
|  |                     'ctx': {'expected': "'UMT' or 'MGRS'"}, | ||||||
|  |                     'input': 'WGS84', | ||||||
|  |                     'loc': ['format'], | ||||||
|  |                     'loc_in': 'headers', | ||||||
|  |                     'msg': "Input should be 'UMT' or 'MGRS'", | ||||||
|  |                     'type': 'enum' | ||||||
|  |                 } | ||||||
|  |             ] | ||||||
|  |             != {"signature": "2020-10-04T18:01:00"} | ||||||
|  |     ) | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/coord", ViewWithEnumType) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.get("/coord", headers={"format": "UMT"}) | ||||||
|  |     assert await resp.json() == {"format": "UMT"} | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_with_signature_group(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithSignatureGroup) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", | ||||||
|  |         headers={ | ||||||
|  |             "signature_expired": "2020-10-04T18:01:00", | ||||||
|  |             "signature.scope": "write", | ||||||
|  |         }, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"} | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|   | |||||||
| @@ -1,20 +1,46 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|  |  | ||||||
|     async def get(self, author_id: str, tag: str, date: int, /): |     async def get(self, author_id: str, tag: str, date: int, /): | ||||||
|         return web.json_response({'path': [author_id, tag, date]}) |         return web.json_response({"path": [author_id, tag, date]}) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_without_required_qs_should_return_an_error_message(aiohttp_client, loop): | async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path( | ||||||
|  |     aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article/{author_id}/tag/{tag}/before/{date}', ArticleView) |     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article/1234/tag/music/before/1980') |     resp = await client.get("/article/1234/tag/music/before/1980") | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {'path': ['1234', 'music', 1980]} |     assert await resp.json() == {"path": ["1234", "music", 1980]} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_wrong_path_parameters_should_return_error( | ||||||
|  |     aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     resp = await client.get("/article/1234/tag/music/before/now") | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'input': 'now', | ||||||
|  |             'loc': ['date'], | ||||||
|  |             'loc_in': 'path', | ||||||
|  |             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||||
|  |             'type': 'int_parsing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/int_parsing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|   | |||||||
| @@ -1,45 +1,298 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  | from typing import Optional, List | ||||||
|  | from pydantic import Field | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|  | from aiohttp_pydantic.injectors import Group | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|  |     async def get( | ||||||
|     async def get(self, with_comments: bool): |             self, | ||||||
|         return web.json_response({'with_comments': with_comments}) |             with_comments: bool, | ||||||
|  |             age: Optional[int] = None, | ||||||
|  |             nb_items: int = 7, | ||||||
|  |             tags: List[str] = Field(default_factory=list), | ||||||
|  |     ): | ||||||
|  |         return web.json_response( | ||||||
|  |             { | ||||||
|  |                 "with_comments": with_comments, | ||||||
|  |                 "age": age, | ||||||
|  |                 "nb_items": nb_items, | ||||||
|  |                 "tags": tags, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_without_required_qs_should_return_an_error_message(aiohttp_client, loop): | class Pagination(Group): | ||||||
|  |     page_num: int | ||||||
|  |     page_size: int = 20 | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def num(self) -> int: | ||||||
|  |         return self.page_num | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def size(self) -> int: | ||||||
|  |         return self.page_size | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleViewWithPaginationGroup(PydanticView): | ||||||
|  |     async def get( | ||||||
|  |             self, | ||||||
|  |             with_comments: bool, | ||||||
|  |             page: Pagination, | ||||||
|  |     ): | ||||||
|  |         return web.json_response( | ||||||
|  |             { | ||||||
|  |                 "with_comments": with_comments, | ||||||
|  |                 "page_num": page.num, | ||||||
|  |                 "page_size": page.size, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Lang(str, Enum): | ||||||
|  |     EN = 'en' | ||||||
|  |     FR = 'fr' | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleViewWithEnumInQuery(PydanticView): | ||||||
|  |     async def get(self, lang: Lang): | ||||||
|  |         return web.json_response( | ||||||
|  |             { | ||||||
|  |                 "lang": lang | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_without_required_qs_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article') |     resp = await client.get("/article") | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['with_comments'], |  | ||||||
|                                   'msg': 'field required', |     assert await resp.json() == [ | ||||||
|                                   'type': 'value_error.missing'}] |         { | ||||||
|  |             'input': {}, | ||||||
|  |             'loc': ['with_comments'], | ||||||
|  |             'loc_in': 'query string', | ||||||
|  |             'msg': 'Field required', | ||||||
|  |             'type': 'missing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/missing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_wrong_qs_type_should_return_an_error_message(aiohttp_client, loop): | async def test_get_article_with_wrong_qs_type_should_return_an_error_message( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', params={'with_comments': 'foo'}) |     resp = await client.get("/article", params={"with_comments": "foo"}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [{'loc': ['with_comments'], |     assert await resp.json() == [ | ||||||
|                                   'msg': 'value could not be parsed to a boolean', |         { | ||||||
|                                   'type': 'type_error.bool'}] |             'input': 'foo', | ||||||
|  |             'loc': ['with_comments'], | ||||||
|  |             'loc_in': 'query string', | ||||||
|  |             'msg': 'Input should be a valid boolean, unable to interpret input', | ||||||
|  |             'type': 'bool_parsing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/bool_parsing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_valid_qs_should_return_the_parsed_type(aiohttp_client, loop): | async def test_get_article_with_valid_qs_should_return_the_parsed_type( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view('/article', ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|     resp = await client.get('/article', params={'with_comments': 'yes'}) |  | ||||||
|  |     resp = await client.get("/article", params={"with_comments": "yes", "age": 3}) | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == 'application/json' |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {'with_comments': True} |     assert await resp.json() == { | ||||||
|  |         "with_comments": True, | ||||||
|  |         "age": 3, | ||||||
|  |         "nb_items": 7, | ||||||
|  |         "tags": [], | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get("/article", params={"with_comments": "yes"}) | ||||||
|  |     assert await resp.json() == { | ||||||
|  |         "with_comments": True, | ||||||
|  |         "age": None, | ||||||
|  |         "nb_items": 7, | ||||||
|  |         "tags": [], | ||||||
|  |     } | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_multiple_value_for_qs_age_must_failed( | ||||||
|  |         aiohttp_client, event_loop | ||||||
|  | ): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1}) | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'input': ['2', '3'], | ||||||
|  |             'loc': ['age'], | ||||||
|  |             'loc_in': 'query string', | ||||||
|  |             'msg': 'Input should be a valid integer', | ||||||
|  |             'type': 'int_type', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/int_type' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_multiple_value_of_tags(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]} | ||||||
|  |     ) | ||||||
|  |     assert await resp.json() == { | ||||||
|  |         "age": 2, | ||||||
|  |         "nb_items": 7, | ||||||
|  |         "tags": ["aa", "bb"], | ||||||
|  |         "with_comments": True, | ||||||
|  |     } | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]} | ||||||
|  |     ) | ||||||
|  |     assert await resp.json() == { | ||||||
|  |         "age": 2, | ||||||
|  |         "nb_items": 7, | ||||||
|  |         "tags": ["aa"], | ||||||
|  |         "with_comments": True, | ||||||
|  |     } | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_without_required_field_page(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get("/article", params={"with_comments": 1}) | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'input': {'with_comments': '1'}, | ||||||
|  |             'loc': ['page_num'], | ||||||
|  |             'loc_in': 'query string', | ||||||
|  |             'msg': 'Field required', | ||||||
|  |             'type': 'missing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/missing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_page(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get("/article", params={"with_comments": 1, "page_num": 2}) | ||||||
|  |     assert await resp.json() == {"page_num": 2, "page_size": 20, "with_comments": True} | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_page_and_page_size(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", params={"with_comments": 1, "page_num": 1, "page_size": 10} | ||||||
|  |     ) | ||||||
|  |     assert await resp.json() == {"page_num": 1, "page_size": 10, "with_comments": True} | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_enum_in_query(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithEnumInQuery) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", params={"lang": Lang.EN.value} | ||||||
|  |     ) | ||||||
|  |     assert await resp.json() == {'lang': Lang.EN} | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|  |     resp = await client.get( | ||||||
|  |         "/article", params={"with_comments": 1, "page_num": 1, "page_size": "large"} | ||||||
|  |     ) | ||||||
|  |     assert await resp.json() == [ | ||||||
|  |         { | ||||||
|  |             'input': 'large', | ||||||
|  |             'loc': ['page_size'], | ||||||
|  |             'loc_in': 'query string', | ||||||
|  |             'msg': 'Input should be a valid integer, unable to parse string as an ' | ||||||
|  |                    'integer', | ||||||
|  |             'type': 'int_parsing', | ||||||
|  |             'url': 'https://errors.pydantic.dev/2.5/v/int_parsing' | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user