Compare commits
	
		
			53 Commits
		
	
	
		
			fix-defini
			...
			v1.12.1_fi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 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 | 
							
								
								
									
										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 | ||||||
|  |  | ||||||
|  | ... | ||||||
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,9 +1,11 @@ | |||||||
| .coverage | .coverage | ||||||
| .idea/ | .idea/ | ||||||
|  | .pypirc | ||||||
| .pytest_cache | .pytest_cache | ||||||
| __pycache__ | __pycache__ | ||||||
| aiohttp_pydantic.egg-info/ | aiohttp_pydantic.egg-info/ | ||||||
| build/ | build/ | ||||||
| coverage.xml | coverage.xml | ||||||
| dist/ | dist/ | ||||||
|  | dist_venv/ | ||||||
|  | venv/ | ||||||
							
								
								
									
										11
									
								
								.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								.gitlab-ci.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | stages: | ||||||
|  |   - package | ||||||
|  |  | ||||||
|  | publish-pypi: | ||||||
|  |   stage: package | ||||||
|  |   image: python:3.8 | ||||||
|  |   script: | ||||||
|  |     - 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 | ||||||
							
								
								
									
										22
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,22 +0,0 @@ | |||||||
| language: python |  | ||||||
| python: |  | ||||||
| - '3.8' |  | ||||||
| script: |  | ||||||
| - pytest --cov-report=xml --cov=aiohttp_pydantic tests/ |  | ||||||
| install: |  | ||||||
| - pip install -U setuptools wheel pip |  | ||||||
| - pip install -r requirements/test.txt |  | ||||||
| - pip install -r requirements/ci.txt |  | ||||||
| - pip install . |  | ||||||
| after_success: |  | ||||||
|   - codecov |  | ||||||
| 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' |  | ||||||
							
								
								
									
										187
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										187
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,8 +1,9 @@ | |||||||
| Aiohttp pydantic - Aiohttp View to validate and parse request | Aiohttp pydantic - Aiohttp View to validate and parse request | ||||||
| ============================================================= | ============================================================= | ||||||
|  |  | ||||||
| .. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main | .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg | ||||||
|   :target: https://travis-ci.org/Maillol/aiohttp-pydantic |   :target: https://cloud.drone.io/Maillol/aiohttp-pydantic | ||||||
|  |   :alt: Build status for master branch | ||||||
|  |  | ||||||
| .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic | .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||||
|   :target: https://img.shields.io/pypi/v/aiohttp-pydantic |   :target: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||||
| @@ -54,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}) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -68,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" | ||||||
|         ], |         ], | ||||||
| @@ -82,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" | ||||||
|         ], |         ], | ||||||
| @@ -99,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: | ||||||
| @@ -116,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 | ||||||
| @@ -150,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 | ||||||
| @@ -205,6 +226,16 @@ on the same route, you must use *apps_to_expose* parameter. | |||||||
|  |  | ||||||
|     oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) |     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 | Add annotation to define response content | ||||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| @@ -215,6 +246,9 @@ 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 | the status code 200 and the response content is a List of Pet where Pet will be | ||||||
| defined using a pydantic.BaseModel | defined using a pydantic.BaseModel | ||||||
|  |  | ||||||
|  | The docstring of methods will be parsed to fill the descriptions in the | ||||||
|  | Open Api Specification. | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
|  |  | ||||||
| @@ -233,20 +267,47 @@ defined using a pydantic.BaseModel | |||||||
|  |  | ||||||
|     class PetCollectionView(PydanticView): |     class PetCollectionView(PydanticView): | ||||||
|         async def get(self) -> r200[List[Pet]]: |         async def get(self) -> r200[List[Pet]]: | ||||||
|  |             """ | ||||||
|  |             Find all pets | ||||||
|  |  | ||||||
|  |             Tags: pet | ||||||
|  |             """ | ||||||
|             pets = self.request.app["model"].list_pets() |             pets = self.request.app["model"].list_pets() | ||||||
|             return web.json_response([pet.dict() for pet in pets]) |             return web.json_response([pet.dict() for pet in pets]) | ||||||
|  |  | ||||||
|         async def post(self, pet: Pet) -> r201[Pet]: |         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) |             self.request.app["model"].add_pet(pet) | ||||||
|             return web.json_response(pet.dict()) |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |  | ||||||
|     class PetItemView(PydanticView): |     class PetItemView(PydanticView): | ||||||
|         async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: |         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) |             pet = self.request.app["model"].find_pet(id) | ||||||
|             return web.json_response(pet.dict()) |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|         async def put(self, id: int, /, pet: Pet) -> r200[Pet]: |         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) |             self.request.app["model"].update_pet(id, pet) | ||||||
|             return web.json_response(pet.dict()) |             return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
| @@ -254,6 +315,91 @@ defined using a pydantic.BaseModel | |||||||
|             self.request.app["model"].remove_pet(id) |             self.request.app["model"].remove_pet(id) | ||||||
|             return web.Response(status=204) |             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 | Demo | ||||||
| ---- | ---- | ||||||
|  |  | ||||||
| @@ -268,12 +414,35 @@ Have a look at `demo`_ for a complete example | |||||||
|  |  | ||||||
| Go to http://127.0.0.1:8080/oas | Go to http://127.0.0.1:8080/oas | ||||||
|  |  | ||||||
| You can generate the OAS in a json file using the command: | You can generate the OAS in a json or yaml file using the aiohttp_pydantic.oas command: | ||||||
|  |  | ||||||
| .. code-block:: bash | .. code-block:: bash | ||||||
|  |  | ||||||
|     python -m aiohttp_pydantic.oas demo.main |     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 | .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo | ||||||
| .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views | .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| from .view import PydanticView | from .view import PydanticView | ||||||
|  |  | ||||||
| __version__ = "1.5.1" | __version__ = "1.12.1_fixed" | ||||||
|  |  | ||||||
| __all__ = ("PydanticView", "__version__") | __all__ = ("PydanticView", "__version__") | ||||||
|   | |||||||
| @@ -1,13 +1,18 @@ | |||||||
| import abc | import abc | ||||||
| from inspect import signature | import typing | ||||||
|  | from inspect import signature, getmro | ||||||
| from json.decoder import JSONDecodeError | from json.decoder import JSONDecodeError | ||||||
| from typing import Callable, Tuple | from types import SimpleNamespace | ||||||
|  | from typing import Callable, Tuple, Literal, Type, get_type_hints | ||||||
|  |  | ||||||
| from aiohttp.web_exceptions import HTTPBadRequest | from aiohttp.web_exceptions import HTTPBadRequest | ||||||
| from aiohttp.web_request import BaseRequest | from aiohttp.web_request import BaseRequest | ||||||
|  | from multidict import MultiDict | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
|  |  | ||||||
| from .utils import is_pydantic_base_model | 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): | ||||||
| @@ -15,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 | ||||||
|         """ |         """ | ||||||
| @@ -33,8 +48,12 @@ 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).dict().values()) | ||||||
| @@ -45,8 +64,11 @@ 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())) | ||||||
|  |         self._expect_object = self.model.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: |         try: | ||||||
| @@ -54,9 +76,18 @@ class BodyGetter(AbstractInjector): | |||||||
|         except JSONDecodeError: |         except JSONDecodeError: | ||||||
|             raise HTTPBadRequest( |             raise HTTPBadRequest( | ||||||
|                 text='{"error": "Malformed JSON"}', content_type="application/json" |                 text='{"error": "Malformed JSON"}', content_type="application/json" | ||||||
|             ) |             ) from None | ||||||
|  |  | ||||||
|         kwargs_view[self.arg_name] = self.model(**body) |         # 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='[{"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): | ||||||
| @@ -64,11 +95,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).dict() | ||||||
|  |         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): | ||||||
| @@ -76,45 +145,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).dict() | ||||||
|  |         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: |         if param_spec.annotation == param_spec.empty: | ||||||
|             raise RuntimeError(f"The parameter {param_name} must have an annotation") |             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 is_pydantic_base_model(param_spec.annotation): |             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) | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| from importlib import resources | from importlib import resources | ||||||
| from typing import Iterable | from typing import Iterable, Optional | ||||||
|  |  | ||||||
| import jinja2 | import jinja2 | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
| @@ -13,13 +13,21 @@ def setup( | |||||||
|     apps_to_expose: Iterable[web.Application] = (), |     apps_to_expose: Iterable[web.Application] = (), | ||||||
|     url_prefix: str = "/oas", |     url_prefix: str = "/oas", | ||||||
|     enable: bool = True, |     enable: bool = True, | ||||||
|  |     version_spec: Optional[str] = None, | ||||||
|  |     title_spec: Optional[str] = None, | ||||||
|  |     raise_validation_errors: bool = False, | ||||||
| ): | ): | ||||||
|     if enable: |     if enable: | ||||||
|         oas_app = web.Application() |         oas_app = web.Application() | ||||||
|         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) |         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) | ||||||
|  |         for a in oas_app["apps to expose"]: | ||||||
|  |             a['raise_validation_errors'] = raise_validation_errors | ||||||
|         oas_app["index template"] = jinja2.Template( |         oas_app["index template"] = jinja2.Template( | ||||||
|             resources.read_text("aiohttp_pydantic.oas", "index.j2") |             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_get("/spec", get_oas, name="spec") | ||||||
|         oas_app.router.add_static("/static", swagger_ui_path, name="static") |         oas_app.router.add_static("/static", swagger_ui_path, name="static") | ||||||
|         oas_app.router.add_get("", oas_ui, name="index") |         oas_app.router.add_get("", oas_ui, name="index") | ||||||
|   | |||||||
| @@ -1,10 +1,28 @@ | |||||||
| import argparse | import argparse | ||||||
| import importlib | import importlib | ||||||
| import json | import json | ||||||
|  | from typing import Dict, Protocol, Optional, Callable | ||||||
|  | import sys | ||||||
| from .view import generate_oas | 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): | def application_type(value): | ||||||
|     """ |     """ | ||||||
|     Return aiohttp application defined in the value. |     Return aiohttp application defined in the value. | ||||||
| @@ -26,6 +44,35 @@ def application_type(value): | |||||||
|         raise argparse.ArgumentTypeError(error) from 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): | def setup(parser: argparse.ArgumentParser): | ||||||
|     parser.add_argument( |     parser.add_argument( | ||||||
|         "apps", |         "apps", | ||||||
| @@ -35,11 +82,52 @@ def setup(parser: argparse.ArgumentParser): | |||||||
|         help="The name of the module containing the asyncio.web.Application." |         help="The name of the module containing the asyncio.web.Application." | ||||||
|         " By default the variable named 'app' is loaded but you can define" |         " By default the variable named 'app' is loaded but you can define" | ||||||
|         " an other variable name ending the name of module with : characters" |         " an other variable name ending the name of module with : characters" | ||||||
|         " and the name of variable. Example: my_package.my_module:my_app", |         " 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) |     parser.set_defaults(func=show_oas) | ||||||
|  |  | ||||||
|  |  | ||||||
| def show_oas(args: argparse.Namespace): | def show_oas(args: argparse.Namespace): | ||||||
|     print(json.dumps(generate_oas(args.apps), sort_keys=True, indent=4)) |     """ | ||||||
|  |     Display Open API Specification on the stdout. | ||||||
|  |     """ | ||||||
|  |     spec = args.base | ||||||
|  |     spec.update(generate_oas(args.apps)) | ||||||
|  |     print(args.formatter(spec), file=args.output) | ||||||
|   | |||||||
							
								
								
									
										136
									
								
								aiohttp_pydantic/oas/docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								aiohttp_pydantic/oas/docstring_parser.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,136 @@ | |||||||
|  | """ | ||||||
|  | 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(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*:.*", line, re.IGNORECASE): | ||||||
|  |             lines.rewind() | ||||||
|  |             for _ in _i_extract_block(lines): | ||||||
|  |                 pass | ||||||
|  |         else: | ||||||
|  |             ret.append(line) | ||||||
|  |     return ("\n".join(ret)).strip() | ||||||
| @@ -2,7 +2,7 @@ | |||||||
| Utility to write Open Api Specifications using the Python language. | Utility to write Open Api Specifications using the Python language. | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from typing import Union | from typing import Union, List | ||||||
|  |  | ||||||
|  |  | ||||||
| class Info: | class Info: | ||||||
| @@ -133,6 +133,7 @@ class Parameters: | |||||||
| class Response: | class Response: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
|         self._spec = spec |         self._spec = spec | ||||||
|  |         self._spec.setdefault("description", "") | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def description(self) -> str: |     def description(self) -> str: | ||||||
| @@ -156,7 +157,7 @@ class Responses: | |||||||
|         self._spec = spec.setdefault("responses", {}) |         self._spec = spec.setdefault("responses", {}) | ||||||
|  |  | ||||||
|     def __getitem__(self, status_code: Union[int, str]) -> Response: |     def __getitem__(self, status_code: Union[int, str]) -> Response: | ||||||
|         if not (100 <= int(status_code) < 600): |         if not 100 <= int(status_code) < 600: | ||||||
|             raise ValueError("status_code must be between 100 and 599") |             raise ValueError("status_code must be between 100 and 599") | ||||||
|  |  | ||||||
|         spec = self._spec.setdefault(str(status_code), {}) |         spec = self._spec.setdefault(str(status_code), {}) | ||||||
| @@ -195,6 +196,17 @@ class OperationObject: | |||||||
|     def responses(self) -> Responses: |     def responses(self) -> Responses: | ||||||
|         return Responses(self._spec) |         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) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PathItem: | class PathItem: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
| @@ -293,9 +305,21 @@ class Servers: | |||||||
|         return Server(spec) |         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: | class OpenApiSpec3: | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         self._spec = {"openapi": "3.0.0"} |         self._spec = { | ||||||
|  |             "openapi": "3.0.0", | ||||||
|  |             "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, | ||||||
|  |         } | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def info(self) -> Info: |     def info(self) -> Info: | ||||||
| @@ -309,6 +333,10 @@ class OpenApiSpec3: | |||||||
|     def paths(self) -> Paths: |     def paths(self) -> Paths: | ||||||
|         return Paths(self._spec) |         return Paths(self._spec) | ||||||
|  |  | ||||||
|  |     @property | ||||||
|  |     def components(self) -> Components: | ||||||
|  |         return Components(self._spec) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def spec(self): |     def spec(self): | ||||||
|         return self._spec |         return self._spec | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ Example: | |||||||
|  |  | ||||||
| from functools import lru_cache | from functools import lru_cache | ||||||
| from types import new_class | from types import new_class | ||||||
| from typing import Protocol, TypeVar, Optional, Type | from typing import Protocol, TypeVar | ||||||
|  |  | ||||||
| RespContents = TypeVar("RespContents", covariant=True) | RespContents = TypeVar("RespContents", covariant=True) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,45 +1,20 @@ | |||||||
| import typing | import typing | ||||||
| from datetime import date, datetime |  | ||||||
| from inspect import getdoc | from inspect import getdoc | ||||||
| from itertools import count | from itertools import count | ||||||
| from typing import List, Type | from typing import List, Type, Optional, get_type_hints | ||||||
| from uuid import UUID |  | ||||||
|  |  | ||||||
| from aiohttp.web import Response, json_response | from aiohttp.web import Response, json_response | ||||||
| from aiohttp.web_app import Application | from aiohttp.web_app import Application | ||||||
|  | from pydantic import BaseModel | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | ||||||
|  | from . import docstring_parser | ||||||
|  |  | ||||||
| from ..injectors import _parse_func_signature | from ..injectors import _parse_func_signature | ||||||
| from ..utils import is_pydantic_base_model | from ..utils import is_pydantic_base_model | ||||||
| from ..view import PydanticView, is_pydantic_view | from ..view import PydanticView, is_pydantic_view | ||||||
| from .typing import is_status_code_type | from .typing import is_status_code_type | ||||||
|  |  | ||||||
| JSON_SCHEMA_TYPES = { |  | ||||||
|     float: {"type": "number"}, |  | ||||||
|     str: {"type": "string"}, |  | ||||||
|     int: {"type": "integer"}, |  | ||||||
|     UUID: {"type": "string", "format": "uuid"}, |  | ||||||
|     bool: {"type": "boolean"}, |  | ||||||
|     datetime: {"type": "string", "format": "date-time"}, |  | ||||||
|     date: {"type": "string", "format": "date"}, |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _handle_optional(type_): |  | ||||||
|     """ |  | ||||||
|     Returns the type wrapped in Optional or None. |  | ||||||
|  |  | ||||||
|     >>>  _handle_optional(int) |  | ||||||
|     >>>  _handle_optional(Optional[str]) |  | ||||||
|     <class 'str'> |  | ||||||
|     """ |  | ||||||
|     if typing.get_origin(type_) is typing.Union: |  | ||||||
|         args = typing.get_args(type_) |  | ||||||
|         if len(args) == 2 and type(None) in args: |  | ||||||
|             return next(iter(set(args) - {type(None)})) |  | ||||||
|     return None |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class _OASResponseBuilder: | class _OASResponseBuilder: | ||||||
|     """ |     """ | ||||||
| @@ -47,13 +22,19 @@ class _OASResponseBuilder: | |||||||
|     generate the OAS operation response. |     generate the OAS operation response. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     def __init__(self, oas_operation): |     def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): | ||||||
|         self._oas_operation = oas_operation |         self._oas_operation = oas_operation | ||||||
|  |         self._oas = oas | ||||||
|  |         self._status_code_descriptions = status_code_descriptions | ||||||
|  |  | ||||||
|     @staticmethod |     def _handle_pydantic_base_model(self, obj): | ||||||
|     def _handle_pydantic_base_model(obj): |  | ||||||
|         if is_pydantic_base_model(obj): |         if is_pydantic_base_model(obj): | ||||||
|             return obj.schema() |             response_schema = obj.schema( | ||||||
|  |                 ref_template="#/components/schemas/{model}" | ||||||
|  |             ).copy() | ||||||
|  |             if def_sub_schemas := response_schema.pop("definitions", None): | ||||||
|  |                 self._oas.components.schemas.update(def_sub_schemas) | ||||||
|  |             return response_schema | ||||||
|         return {} |         return {} | ||||||
|  |  | ||||||
|     def _handle_list(self, obj): |     def _handle_list(self, obj): | ||||||
| @@ -72,10 +53,16 @@ class _OASResponseBuilder: | |||||||
|                     "schema": self._handle_list(typing.get_args(obj)[0]) |                     "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): |         elif is_status_code_type(obj): | ||||||
|             status_code = obj.__name__[1:] |             status_code = obj.__name__[1:] | ||||||
|             self._oas_operation.responses[status_code].content = {} |             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): |     def _handle_union(self, obj): | ||||||
|         if typing.get_origin(obj) is typing.Union: |         if typing.get_origin(obj) is typing.Union: | ||||||
| @@ -88,19 +75,33 @@ class _OASResponseBuilder: | |||||||
|  |  | ||||||
|  |  | ||||||
| def _add_http_method_to_oas( | def _add_http_method_to_oas( | ||||||
|     oas_path: PathItem, http_method: str, view: Type[PydanticView] |     oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView] | ||||||
| ): | ): | ||||||
|     http_method = http_method.lower() |     http_method = http_method.lower() | ||||||
|     oas_operation: OperationObject = getattr(oas_path, http_method) |     oas_operation: OperationObject = getattr(oas_path, http_method) | ||||||
|     handler = getattr(view, http_method) |     handler = getattr(view, http_method) | ||||||
|     path_args, body_args, qs_args, header_args = _parse_func_signature(handler) |     path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( | ||||||
|  |         handler, unpack_group=True | ||||||
|  |     ) | ||||||
|     description = getdoc(handler) |     description = getdoc(handler) | ||||||
|     if description: |     if description: | ||||||
|         oas_operation.description = description |         oas_operation.description = docstring_parser.operation(description) | ||||||
|  |         oas_operation.tags = docstring_parser.tags(description) | ||||||
|  |         status_code_descriptions = docstring_parser.status_code(description) | ||||||
|  |     else: | ||||||
|  |         status_code_descriptions = {} | ||||||
|  |  | ||||||
|     if body_args: |     if body_args: | ||||||
|  |         body_schema = ( | ||||||
|  |             next(iter(body_args.values())) | ||||||
|  |             .schema(ref_template="#/components/schemas/{model}") | ||||||
|  |             .copy() | ||||||
|  |         ) | ||||||
|  |         if def_sub_schemas := body_schema.pop("definitions", None): | ||||||
|  |             oas.components.schemas.update(def_sub_schemas) | ||||||
|  |  | ||||||
|         oas_operation.request_body.content = { |         oas_operation.request_body.content = { | ||||||
|             "application/json": {"schema": next(iter(body_args.values())).schema()} |             "application/json": {"schema": body_schema} | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     indexes = count() |     indexes = count() | ||||||
| @@ -113,36 +114,55 @@ def _add_http_method_to_oas( | |||||||
|             i = next(indexes) |             i = next(indexes) | ||||||
|             oas_operation.parameters[i].in_ = args_location |             oas_operation.parameters[i].in_ = args_location | ||||||
|             oas_operation.parameters[i].name = name |             oas_operation.parameters[i].name = name | ||||||
|             optional_type = _handle_optional(type_) |  | ||||||
|             if optional_type is None: |             attrs = {"__annotations__": {"__root__": type_}} | ||||||
|                 oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_] |             if name in defaults: | ||||||
|                 oas_operation.parameters[i].required = True |                 attrs["__root__"] = defaults[name] | ||||||
|             else: |  | ||||||
|                 oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type] |  | ||||||
|                 oas_operation.parameters[i].required = False |                 oas_operation.parameters[i].required = False | ||||||
|  |             else: | ||||||
|  |                 oas_operation.parameters[i].required = True | ||||||
|  |  | ||||||
|     return_type = handler.__annotations__.get("return") |             oas_operation.parameters[i].schema = type(name, (BaseModel,), attrs).schema( | ||||||
|  |                 ref_template="#/components/schemas/{model}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |     return_type = get_type_hints(handler).get("return") | ||||||
|     if return_type is not None: |     if return_type is not None: | ||||||
|         _OASResponseBuilder(oas_operation).build(return_type) |         _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( | ||||||
|  |             return_type | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| def generate_oas(apps: List[Application]) -> dict: | 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. |     Generate and return Open Api Specification from PydanticView in application. | ||||||
|     """ |     """ | ||||||
|     oas = OpenApiSpec3() |     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 app in apps: | ||||||
|         for resources in app.router.resources(): |         for resources in app.router.resources(): | ||||||
|             for resource_route in resources: |             for resource_route in resources: | ||||||
|                 if is_pydantic_view(resource_route.handler): |                 if not is_pydantic_view(resource_route.handler): | ||||||
|                     view: Type[PydanticView] = resource_route.handler |                     continue | ||||||
|                     info = resource_route.get_info() |  | ||||||
|                     path = oas.paths[info.get("path", info.get("formatter"))] |                 view: Type[PydanticView] = resource_route.handler | ||||||
|                     if resource_route.method == "*": |                 info = resource_route.get_info() | ||||||
|                         for method_name in view.allowed_methods: |                 path = oas.paths[info.get("path", info.get("formatter"))] | ||||||
|                             _add_http_method_to_oas(path, method_name, view) |                 if resource_route.method == "*": | ||||||
|                     else: |                     for method_name in view.allowed_methods: | ||||||
|                         _add_http_method_to_oas(path, resource_route.method, view) |                         _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 |     return oas.spec | ||||||
|  |  | ||||||
| @@ -152,7 +172,9 @@ async def get_oas(request): | |||||||
|     View to generate the Open Api Specification from PydanticView in application. |     View to generate the Open Api Specification from PydanticView in application. | ||||||
|     """ |     """ | ||||||
|     apps = request.app["apps to expose"] |     apps = request.app["apps to expose"] | ||||||
|     return json_response(generate_oas(apps)) |     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): | async def oas_ui(request): | ||||||
| @@ -163,6 +185,8 @@ async def oas_ui(request): | |||||||
|  |  | ||||||
|     static_url = request.app.router["static"].url_for(filename="") |     static_url = request.app.router["static"].url_for(filename="") | ||||||
|     spec_url = request.app.router["spec"].url_for() |     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() |     host = request.url.origin() | ||||||
|  |  | ||||||
|     return Response( |     return Response( | ||||||
|   | |||||||
| @@ -5,7 +5,15 @@ def is_pydantic_base_model(obj): | |||||||
|     """ |     """ | ||||||
|     Return true is obj is a pydantic.BaseModel subclass. |     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: |     try: | ||||||
|         return issubclass(obj, BaseModel) |         return issubclass(cls1, cls2) | ||||||
|     except TypeError: |     except TypeError: | ||||||
|         return False |         return False | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| from functools import update_wrapper | from functools import update_wrapper | ||||||
| from inspect import iscoroutinefunction | from inspect import iscoroutinefunction | ||||||
| from typing import Any, Callable, Generator, Iterable | 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 | ||||||
| @@ -16,6 +17,8 @@ from .injectors import ( | |||||||
|     MatchInfoGetter, |     MatchInfoGetter, | ||||||
|     QueryGetter, |     QueryGetter, | ||||||
|     _parse_func_signature, |     _parse_func_signature, | ||||||
|  |     CONTEXT, | ||||||
|  |     Group, | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -24,44 +27,85 @@ 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: | ||||||
|  |         """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 = { |         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()) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         for meth_name in METH_ALL: |         for meth_name in METH_ALL: | ||||||
|             if meth_name not in cls.allowed_methods: |             if meth_name.lower() in vars(cls): | ||||||
|                 setattr(cls, meth_name.lower(), cls.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) | ||||||
|  |  | ||||||
|     async def raise_not_allowed(self): |     def _raise_allowed_methods(self) -> None: | ||||||
|         raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) |         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() | ||||||
|  |         for error in errors: | ||||||
|  |             error["in"] = context | ||||||
|  |  | ||||||
|  |         return json_response(data=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]] | ||||||
| @@ -83,7 +127,10 @@ 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) |                 if self.request.app['raise_validation_errors']: | ||||||
|  |                     raise | ||||||
|  |                 else: | ||||||
|  |                     return await self.on_validation_error(error, injector.context) | ||||||
|  |  | ||||||
|         return await handler(self, *args, **kwargs) |         return await handler(self, *args, **kwargs) | ||||||
|  |  | ||||||
| @@ -99,3 +146,14 @@ def is_pydantic_view(obj) -> bool: | |||||||
|         return issubclass(obj, PydanticView) |         return issubclass(obj, PydanticView) | ||||||
|     except TypeError: |     except TypeError: | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |  | ||||||
|  | __all__ = ( | ||||||
|  |     "AbstractInjector", | ||||||
|  |     "BodyGetter", | ||||||
|  |     "HeadersGetter", | ||||||
|  |     "MatchInfoGetter", | ||||||
|  |     "QueryGetter", | ||||||
|  |     "CONTEXT", | ||||||
|  |     "Group", | ||||||
|  | ) | ||||||
|   | |||||||
| @@ -15,7 +15,7 @@ async def pet_not_found_to_404(request, handler): | |||||||
|  |  | ||||||
|  |  | ||||||
| app = Application(middlewares=[pet_not_found_to_404]) | app = Application(middlewares=[pet_not_found_to_404]) | ||||||
| oas.setup(app) | oas.setup(app, version_spec="1.0.1", title_spec="My App") | ||||||
|  |  | ||||||
| app["model"] = Model() | app["model"] = Model() | ||||||
| app.router.add_view("/pets", PetCollectionView) | app.router.add_view("/pets", PetCollectionView) | ||||||
|   | |||||||
| @@ -1,10 +1,17 @@ | |||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
|  | from typing import List | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Friend(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     age: str | ||||||
|  |  | ||||||
|  |  | ||||||
| class Pet(BaseModel): | class Pet(BaseModel): | ||||||
|     id: int |     id: int | ||||||
|     name: str |     name: str | ||||||
|     age: int |     age: int | ||||||
|  |     friends: Friend | ||||||
|  |  | ||||||
|  |  | ||||||
| class Error(BaseModel): | class Error(BaseModel): | ||||||
|   | |||||||
							
								
								
									
										29
									
								
								demo/view.py
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								demo/view.py
									
									
									
									
									
								
							| @@ -10,25 +10,54 @@ from .model import Error, Pet | |||||||
|  |  | ||||||
| class PetCollectionView(PydanticView): | class PetCollectionView(PydanticView): | ||||||
|     async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: |     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() |         pets = self.request.app["model"].list_pets() | ||||||
|         return web.json_response( |         return web.json_response( | ||||||
|             [pet.dict() for pet in pets if age is None or age == pet.age] |             [pet.dict() for pet in pets if age is None or age == pet.age] | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|     async def post(self, pet: Pet) -> r201[Pet]: |     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) |         self.request.app["model"].add_pet(pet) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetItemView(PydanticView): | class PetItemView(PydanticView): | ||||||
|     async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: |     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) |         pet = self.request.app["model"].find_pet(id) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|     async def put(self, id: int, /, pet: Pet) -> r200[Pet]: |     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) |         self.request.app["model"].update_pet(id, pet) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|     async def delete(self, id: int, /) -> r204: |     async def delete(self, id: int, /) -> r204: | ||||||
|  |         """ | ||||||
|  |         Deletes a pet | ||||||
|  |         """ | ||||||
|         self.request.app["model"].remove_pet(id) |         self.request.app["model"].remove_pet(id) | ||||||
|         return web.Response(status=204) |         return web.Response(status=204) | ||||||
|   | |||||||
| @@ -1,7 +1,42 @@ | |||||||
| certifi==2020.11.8 | async-timeout==3.0.1 | ||||||
| chardet==3.0.4 | attrs==21.2.0 | ||||||
| codecov==2.1.10 | bleach==4.0.0 | ||||||
| coverage==5.3 | certifi==2021.5.30 | ||||||
| idna==2.10 | cffi==1.14.6 | ||||||
| requests==2.25.0 | chardet==4.0.0 | ||||||
| urllib3==1.26.2 | charset-normalizer==2.0.4 | ||||||
|  | codecov==2.1.11 | ||||||
|  | colorama==0.4.4 | ||||||
|  | coverage==5.5 | ||||||
|  | cryptography==3.4.7 | ||||||
|  | docutils==0.17.1 | ||||||
|  | idna==3.2 | ||||||
|  | importlib-metadata==4.6.3 | ||||||
|  | iniconfig==1.1.1 | ||||||
|  | jeepney==0.7.1 | ||||||
|  | keyring==23.0.1 | ||||||
|  | multidict==5.1.0 | ||||||
|  | packaging==21.0 | ||||||
|  | pkginfo==1.7.1 | ||||||
|  | pluggy==0.13.1 | ||||||
|  | py==1.10.0 | ||||||
|  | pycparser==2.20 | ||||||
|  | Pygments==2.9.0 | ||||||
|  | pyparsing==2.4.7 | ||||||
|  | pytest==6.1.2 | ||||||
|  | pytest-aiohttp==0.3.0 | ||||||
|  | pytest-cov==2.10.1 | ||||||
|  | readme-renderer==29.0 | ||||||
|  | requests==2.26.0 | ||||||
|  | requests-toolbelt==0.9.1 | ||||||
|  | rfc3986==1.5.0 | ||||||
|  | SecretStorage==3.3.1 | ||||||
|  | six==1.16.0 | ||||||
|  | toml==0.10.2 | ||||||
|  | tqdm==4.62.0 | ||||||
|  | twine==3.4.2 | ||||||
|  | typing-extensions==3.10.0.0 | ||||||
|  | urllib3==1.26.6 | ||||||
|  | webencodings==0.5.1 | ||||||
|  | yarl==1.6.3 | ||||||
|  | zipp==3.5.0 | ||||||
|   | |||||||
| @@ -1,13 +1,23 @@ | |||||||
| attrs==20.3.0 | async-timeout==3.0.1 | ||||||
| coverage==5.3 | attrs==21.2.0 | ||||||
|  | bleach==4.0.0 | ||||||
|  | chardet==4.0.0 | ||||||
|  | coverage==5.5 | ||||||
|  | docutils==0.17.1 | ||||||
|  | idna==3.2 | ||||||
| iniconfig==1.1.1 | iniconfig==1.1.1 | ||||||
| packaging==20.4 | multidict==5.1.0 | ||||||
|  | packaging==21.0 | ||||||
| pluggy==0.13.1 | pluggy==0.13.1 | ||||||
| py==1.9.0 | py==1.10.0 | ||||||
|  | Pygments==2.9.0 | ||||||
| pyparsing==2.4.7 | pyparsing==2.4.7 | ||||||
| pytest==6.1.2 | pytest==6.1.2 | ||||||
| pytest-aiohttp==0.3.0 | pytest-aiohttp==0.3.0 | ||||||
| pytest-cov==2.10.1 | pytest-cov==2.10.1 | ||||||
| six==1.15.0 | readme-renderer==29.0 | ||||||
|  | six==1.16.0 | ||||||
| toml==0.10.2 | toml==0.10.2 | ||||||
| typing-extensions==3.7.4.3 | typing-extensions==3.10.0.0 | ||||||
|  | webencodings==0.5.1 | ||||||
|  | yarl==1.6.3 | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								setup.cfg
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								setup.cfg
									
									
									
									
									
								
							| @@ -21,7 +21,7 @@ classifiers = | |||||||
|     Programming Language :: Python :: 3.8 |     Programming Language :: Python :: 3.8 | ||||||
|     Programming Language :: Python :: 3.9 |     Programming Language :: Python :: 3.9 | ||||||
|     Topic :: Software Development :: Libraries :: Application Frameworks |     Topic :: Software Development :: Libraries :: Application Frameworks | ||||||
|     Framework :: AsyncIO |     Framework :: aiohttp | ||||||
|     License :: OSI Approved :: MIT License |     License :: OSI Approved :: MIT License | ||||||
|  |  | ||||||
| [options] | [options] | ||||||
| @@ -31,17 +31,24 @@ packages = find: | |||||||
| python_requires = >=3.8 | python_requires = >=3.8 | ||||||
| install_requires = | install_requires = | ||||||
|     aiohttp |     aiohttp | ||||||
|     pydantic |     pydantic>=1.7 | ||||||
|     swagger-ui-bundle |     swagger-ui-bundle | ||||||
|  |  | ||||||
| [options.extras_require] | [options.extras_require] | ||||||
| test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 | test = | ||||||
| ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10 |     pytest==6.1.2 | ||||||
|  |     pytest-aiohttp==0.3.0 | ||||||
|  |     pytest-cov==2.10.1 | ||||||
|  |     readme-renderer==29.0 | ||||||
|  | ci = | ||||||
|  |     %(test)s | ||||||
|  |     codecov==2.1.11 | ||||||
|  |     twine==3.4.2 | ||||||
|  |  | ||||||
| [options.packages.find] | [options.packages.find] | ||||||
| exclude = | exclude = | ||||||
|     tests |     tests* | ||||||
|     demo |     demo* | ||||||
|  |  | ||||||
| [options.package_data] | [options.package_data] | ||||||
| aiohttp_pydantic.oas = index.j2 | aiohttp_pydantic.oas = index.j2 | ||||||
|   | |||||||
							
								
								
									
										174
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | """ | ||||||
|  | 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/") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @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}") | ||||||
							
								
								
									
										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} | ||||||
							
								
								
									
										57
									
								
								tests/test_hook_to_custom_response.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								tests/test_hook_to_custom_response.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | |||||||
|  | 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 | ||||||
|  |  | ||||||
|  | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModel(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     nb_page: Optional[int] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModels(BaseModel): | ||||||
|  |     __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() | ||||||
|  |         for error in errors: | ||||||
|  |             error["in"] = context | ||||||
|  |             error["custom"] = "custom" | ||||||
|  |         return json_response(data=errors, status=400) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | ||||||
|  |     aiohttp_client, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "body", | ||||||
|  |             "loc": ["nb_page"], | ||||||
|  |             "msg": "value is not a valid integer", | ||||||
|  |             "custom": "custom", | ||||||
|  |             "type": "type_error.integer", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
							
								
								
									
										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"} | ||||||
							
								
								
									
										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"}} | ||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|   | |||||||
| @@ -1,10 +1,15 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import argparse | import argparse | ||||||
| from textwrap import dedent | from textwrap import dedent | ||||||
|  | from io import StringIO | ||||||
|  | from pathlib import Path | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas import cmd | from aiohttp_pydantic.oas import cmd | ||||||
|  |  | ||||||
|  | PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json") | ||||||
|  |  | ||||||
|  |  | ||||||
| @pytest.fixture | @pytest.fixture | ||||||
| def cmd_line(): | def cmd_line(): | ||||||
| @@ -13,13 +18,18 @@ def cmd_line(): | |||||||
|     return parser |     return parser | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_show_oad_of_app(cmd_line, capfd): | def test_show_oas_of_app(cmd_line): | ||||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) |     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) | ||||||
|  |     args.output = StringIO() | ||||||
|     args.func(args) |     args.func(args) | ||||||
|     captured = capfd.readouterr() |  | ||||||
|     expected = dedent( |     expected = dedent( | ||||||
|         """ |         """ | ||||||
|         { |     { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/route-1/{a}": { |             "/route-1/{a}": { | ||||||
| @@ -30,6 +40,7 @@ def test_show_oad_of_app(cmd_line, capfd): | |||||||
|                             "name": "a", |                             "name": "a", | ||||||
|                             "required": true, |                             "required": true, | ||||||
|                             "schema": { |                             "schema": { | ||||||
|  |                                 "title": "a", | ||||||
|                                 "type": "integer" |                                 "type": "integer" | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
| @@ -44,6 +55,7 @@ def test_show_oad_of_app(cmd_line, capfd): | |||||||
|                             "name": "b", |                             "name": "b", | ||||||
|                             "required": true, |                             "required": true, | ||||||
|                             "schema": { |                             "schema": { | ||||||
|  |                                 "title": "b", | ||||||
|                                 "type": "integer" |                                 "type": "integer" | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
| @@ -55,16 +67,20 @@ def test_show_oad_of_app(cmd_line, capfd): | |||||||
|     """ |     """ | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     assert captured.out.strip() == expected.strip() |     assert args.output.getvalue().strip() == expected.strip() | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_show_oad_of_sub_app(cmd_line, capfd): | def test_show_oas_of_sub_app(cmd_line): | ||||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) |     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) | ||||||
|  |     args.output = StringIO() | ||||||
|     args.func(args) |     args.func(args) | ||||||
|     captured = capfd.readouterr() |  | ||||||
|     expected = dedent( |     expected = dedent( | ||||||
|         """ |         """ | ||||||
|         { |     { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/sub-app/route-2/{b}": { |             "/sub-app/route-2/{b}": { | ||||||
| @@ -75,6 +91,7 @@ def test_show_oad_of_sub_app(cmd_line, capfd): | |||||||
|                             "name": "b", |                             "name": "b", | ||||||
|                             "required": true, |                             "required": true, | ||||||
|                             "schema": { |                             "schema": { | ||||||
|  |                                 "title": "b", | ||||||
|                                 "type": "integer" |                                 "type": "integer" | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
| @@ -86,16 +103,26 @@ def test_show_oad_of_sub_app(cmd_line, capfd): | |||||||
|     """ |     """ | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     assert captured.out.strip() == expected.strip() |     assert args.output.getvalue().strip() == expected.strip() | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_show_oad_of_a_callable(cmd_line, capfd): | def test_show_oas_of_a_callable(cmd_line): | ||||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) |     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) |     args.func(args) | ||||||
|     captured = capfd.readouterr() |  | ||||||
|     expected = dedent( |     expected = dedent( | ||||||
|         """ |         """ | ||||||
|         { |         { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0" | ||||||
|  |         }, | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/route-3/{a}": { |             "/route-3/{a}": { | ||||||
| @@ -106,6 +133,7 @@ def test_show_oad_of_a_callable(cmd_line, capfd): | |||||||
|                             "name": "a", |                             "name": "a", | ||||||
|                             "required": true, |                             "required": true, | ||||||
|                             "schema": { |                             "schema": { | ||||||
|  |                                 "title": "a", | ||||||
|                                 "type": "integer" |                                 "type": "integer" | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
| @@ -117,4 +145,4 @@ def test_show_oad_of_a_callable(cmd_line, capfd): | |||||||
|     """ |     """ | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     assert captured.out.strip() == expected.strip() |     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:" | ||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
| @@ -5,10 +7,16 @@ from aiohttp_pydantic.oas.struct import OpenApiSpec3 | |||||||
|  |  | ||||||
| def test_info_title(): | def test_info_title(): | ||||||
|     oas = OpenApiSpec3() |     oas = OpenApiSpec3() | ||||||
|     assert oas.info.title is None |     assert oas.info.title == "Aiohttp pydantic application" | ||||||
|     oas.info.title = "Info Title" |     oas.info.title = "Info Title" | ||||||
|     assert oas.info.title == "Info Title" |     assert oas.info.title == "Info Title" | ||||||
|     assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"} |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "title": "Info Title", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_info_description(): | def test_info_description(): | ||||||
| @@ -16,15 +24,25 @@ def test_info_description(): | |||||||
|     assert oas.info.description is None |     assert oas.info.description is None | ||||||
|     oas.info.description = "info description" |     oas.info.description = "info description" | ||||||
|     assert oas.info.description == "info description" |     assert oas.info.description == "info description" | ||||||
|     assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"} |     assert oas.spec == { | ||||||
|  |         "info": { | ||||||
|  |             "description": "info description", | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |         }, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_info_version(): | def test_info_version(): | ||||||
|     oas = OpenApiSpec3() |     oas = OpenApiSpec3() | ||||||
|     assert oas.info.version is None |     assert oas.info.version == "1.0.0" | ||||||
|     oas.info.version = "3.14" |     oas.info.version = "3.14" | ||||||
|     assert oas.info.version == "3.14" |     assert oas.info.version == "3.14" | ||||||
|     assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"} |     assert oas.spec == { | ||||||
|  |         "info": {"version": "3.14", "title": "Aiohttp pydantic application"}, | ||||||
|  |         "openapi": "3.0.0", | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_info_terms_of_service(): | def test_info_terms_of_service(): | ||||||
| @@ -33,7 +51,11 @@ def test_info_terms_of_service(): | |||||||
|     oas.info.terms_of_service = "http://example.com/terms/" |     oas.info.terms_of_service = "http://example.com/terms/" | ||||||
|     assert oas.info.terms_of_service == "http://example.com/terms/" |     assert oas.info.terms_of_service == "http://example.com/terms/" | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "info": {"termsOfService": "http://example.com/terms/"}, |         "info": { | ||||||
|  |             "title": "Aiohttp pydantic application", | ||||||
|  |             "version": "1.0.0", | ||||||
|  |             "termsOfService": "http://example.com/terms/", | ||||||
|  |         }, | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|     } |     } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -6,6 +8,7 @@ def test_paths_description(): | |||||||
|     oas.paths["/users/{id}"].description = "This route ..." |     oas.paths["/users/{id}"].description = "This route ..." | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "paths": {"/users/{id}": {"description": "This route ..."}}, |         "paths": {"/users/{id}": {"description": "This route ..."}}, | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -13,7 +16,11 @@ def test_paths_description(): | |||||||
| def test_paths_get(): | def test_paths_get(): | ||||||
|     oas = OpenApiSpec3() |     oas = OpenApiSpec3() | ||||||
|     oas.paths["/users/{id}"].get |     oas.paths["/users/{id}"].get | ||||||
|     assert oas.spec == {"openapi": "3.0.0", "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(): | def test_paths_operation_description(): | ||||||
| @@ -22,6 +29,7 @@ def test_paths_operation_description(): | |||||||
|     operation.description = "Long descriptions ..." |     operation.description = "Long descriptions ..." | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, |         "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -32,6 +40,7 @@ def test_paths_operation_summary(): | |||||||
|     operation.summary = "Updates a pet in the store with form data" |     operation.summary = "Updates a pet in the store with form data" | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/users/{id}": { |             "/users/{id}": { | ||||||
|                 "get": {"summary": "Updates a pet in the store with form data"} |                 "get": {"summary": "Updates a pet in the store with form data"} | ||||||
| @@ -51,6 +60,7 @@ def test_paths_operation_parameters(): | |||||||
|  |  | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/users/{petId}": { |             "/users/{petId}": { | ||||||
|                 "get": { |                 "get": { | ||||||
| @@ -86,6 +96,7 @@ def test_paths_operation_requestBody(): | |||||||
|     request_body.required = True |     request_body.required = True | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "paths": { |         "paths": { | ||||||
|             "/users/{petId}": { |             "/users/{petId}": { | ||||||
|                 "get": { |                 "get": { | ||||||
| @@ -110,6 +121,18 @@ def test_paths_operation_requestBody(): | |||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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(): | def test_paths_operation_responses(): | ||||||
|     oas = OpenApiSpec3() |     oas = OpenApiSpec3() | ||||||
|     response = oas.paths["/users/{petId}"].get.responses[200] |     response = oas.paths["/users/{petId}"].get.responses[200] | ||||||
|   | |||||||
| @@ -1,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||||
| @@ -9,6 +11,7 @@ def test_sever_url(): | |||||||
|     oas.servers[1].url = "https://development.gigantic-server.com/v2" |     oas.servers[1].url = "https://development.gigantic-server.com/v2" | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "servers": [ |         "servers": [ | ||||||
|             {"url": "https://development.gigantic-server.com/v1"}, |             {"url": "https://development.gigantic-server.com/v1"}, | ||||||
|             {"url": "https://development.gigantic-server.com/v2"}, |             {"url": "https://development.gigantic-server.com/v2"}, | ||||||
| @@ -22,6 +25,7 @@ def test_sever_description(): | |||||||
|     oas.servers[0].description = "Development server" |     oas.servers[0].description = "Development server" | ||||||
|     assert oas.spec == { |     assert oas.spec == { | ||||||
|         "openapi": "3.0.0", |         "openapi": "3.0.0", | ||||||
|  |         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||||
|         "servers": [ |         "servers": [ | ||||||
|             { |             { | ||||||
|                 "url": "https://development.gigantic-server.com/v1", |                 "url": "https://development.gigantic-server.com/v1", | ||||||
|   | |||||||
| @@ -1,4 +1,7 @@ | |||||||
| from typing import List, Optional, Union | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from enum import Enum | ||||||
|  | from typing import List, Optional, Union, Literal | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| import pytest | import pytest | ||||||
| @@ -6,12 +9,26 @@ from aiohttp import web | |||||||
| from pydantic.main import BaseModel | from pydantic.main import BaseModel | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView, oas | from aiohttp_pydantic import PydanticView, oas | ||||||
|  | from aiohttp_pydantic.injectors import Group | ||||||
| from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||||
|  | from aiohttp_pydantic.oas.view import generate_oas | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Color(str, Enum): | ||||||
|  |     RED = "red" | ||||||
|  |     GREEN = "green" | ||||||
|  |     PINK = "pink" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Toy(BaseModel): | ||||||
|  |     name: str | ||||||
|  |     color: Color | ||||||
|  |  | ||||||
|  |  | ||||||
| class Pet(BaseModel): | class Pet(BaseModel): | ||||||
|     id: int |     id: int | ||||||
|     name: str |     name: str | ||||||
|  |     toys: List[Toy] | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetCollectionView(PydanticView): | class PetCollectionView(PydanticView): | ||||||
| @@ -20,6 +37,10 @@ class PetCollectionView(PydanticView): | |||||||
|     ) -> r200[List[Pet]]: |     ) -> r200[List[Pet]]: | ||||||
|         """ |         """ | ||||||
|         Get a list of pets |         Get a list of pets | ||||||
|  |  | ||||||
|  |         Tags: pet | ||||||
|  |         Status Codes: | ||||||
|  |           200: Successful operation | ||||||
|         """ |         """ | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
| @@ -29,28 +50,82 @@ class PetCollectionView(PydanticView): | |||||||
|  |  | ||||||
|  |  | ||||||
| class PetItemView(PydanticView): | class PetItemView(PydanticView): | ||||||
|     async def get(self, id: int, /) -> Union[r200[Pet], r404]: |     async def get( | ||||||
|  |         self, | ||||||
|  |         id: int, | ||||||
|  |         /, | ||||||
|  |         size: Union[int, Literal["x", "l", "s"]], | ||||||
|  |         day: Union[int, Literal["now"]] = "now", | ||||||
|  |     ) -> Union[r200[Pet], r404]: | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|     async def put(self, id: int, /, pet: Pet): |     async def put(self, id: int, /, pet: Pet): | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|     async def delete(self, id: int, /) -> r204: |     async def delete(self, id: int, /) -> r204: | ||||||
|  |         """ | ||||||
|  |         Status Code: | ||||||
|  |           204: Empty but OK | ||||||
|  |         """ | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ViewResponseReturnASimpleType(PydanticView): | ||||||
|  |     async def get(self) -> r200[int]: | ||||||
|  |         """ | ||||||
|  |         Status Codes: | ||||||
|  |           200: The new number | ||||||
|  |         """ | ||||||
|  |         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 | @pytest.fixture | ||||||
| async def generated_oas(aiohttp_client, loop) -> web.Application: | async def generated_oas(aiohttp_client, loop) -> web.Application: | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/pets", PetCollectionView) |     app.router.add_view("/pets", PetCollectionView) | ||||||
|     app.router.add_view("/pets/{id}", PetItemView) |     app.router.add_view("/pets/{id}", PetItemView) | ||||||
|  |     app.router.add_view("/simple-type", ViewResponseReturnASimpleType) | ||||||
|     oas.setup(app) |     oas.setup(app) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     return await ensure_content_durability(await aiohttp_client(app)) | ||||||
|     response = await client.get("/oas/spec") |  | ||||||
|     assert response.status == 200 |  | ||||||
|     assert response.content_type == "application/json" | async def test_generated_oas_should_have_components_schemas(generated_oas): | ||||||
|     return await response.json() |     assert generated_oas["components"]["schemas"] == { | ||||||
|  |         "Color": { | ||||||
|  |             "description": "An enumeration.", | ||||||
|  |             "enum": ["red", "green", "pink"], | ||||||
|  |             "title": "Color", | ||||||
|  |             "type": "string", | ||||||
|  |         }, | ||||||
|  |         "Toy": { | ||||||
|  |             "properties": { | ||||||
|  |                 "color": {"$ref": "#/components/schemas/Color"}, | ||||||
|  |                 "name": {"title": "Name", "type": "string"}, | ||||||
|  |             }, | ||||||
|  |             "required": ["name", "color"], | ||||||
|  |             "title": "Toy", | ||||||
|  |             "type": "object", | ||||||
|  |         }, | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_generated_oas_should_have_pets_paths(generated_oas): | async def test_generated_oas_should_have_pets_paths(generated_oas): | ||||||
| @@ -60,28 +135,30 @@ async def test_generated_oas_should_have_pets_paths(generated_oas): | |||||||
| async def test_pets_route_should_have_get_method(generated_oas): | async def test_pets_route_should_have_get_method(generated_oas): | ||||||
|     assert generated_oas["paths"]["/pets"]["get"] == { |     assert generated_oas["paths"]["/pets"]["get"] == { | ||||||
|         "description": "Get a list of pets", |         "description": "Get a list of pets", | ||||||
|  |         "tags": ["pet"], | ||||||
|         "parameters": [ |         "parameters": [ | ||||||
|             { |             { | ||||||
|                 "in": "query", |                 "in": "query", | ||||||
|                 "name": "format", |                 "name": "format", | ||||||
|                 "required": True, |                 "required": True, | ||||||
|                 "schema": {"type": "string"}, |                 "schema": {"title": "format", "type": "string"}, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 "in": "query", |                 "in": "query", | ||||||
|                 "name": "name", |                 "name": "name", | ||||||
|                 "required": False, |                 "required": False, | ||||||
|                 "schema": {"type": "string"}, |                 "schema": {"title": "name", "type": "string"}, | ||||||
|             }, |             }, | ||||||
|             { |             { | ||||||
|                 "in": "header", |                 "in": "header", | ||||||
|                 "name": "promo", |                 "name": "promo", | ||||||
|                 "required": False, |                 "required": False, | ||||||
|                 "schema": {"format": "uuid", "type": "string"}, |                 "schema": {"format": "uuid", "title": "promo", "type": "string"}, | ||||||
|             }, |             }, | ||||||
|         ], |         ], | ||||||
|         "responses": { |         "responses": { | ||||||
|             "200": { |             "200": { | ||||||
|  |                 "description": "Successful operation", | ||||||
|                 "content": { |                 "content": { | ||||||
|                     "application/json": { |                     "application/json": { | ||||||
|                         "schema": { |                         "schema": { | ||||||
| @@ -89,15 +166,20 @@ async def test_pets_route_should_have_get_method(generated_oas): | |||||||
|                                 "properties": { |                                 "properties": { | ||||||
|                                     "id": {"title": "Id", "type": "integer"}, |                                     "id": {"title": "Id", "type": "integer"}, | ||||||
|                                     "name": {"title": "Name", "type": "string"}, |                                     "name": {"title": "Name", "type": "string"}, | ||||||
|  |                                     "toys": { | ||||||
|  |                                         "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                         "title": "Toys", | ||||||
|  |                                         "type": "array", | ||||||
|  |                                     }, | ||||||
|                                 }, |                                 }, | ||||||
|                                 "required": ["id", "name"], |                                 "required": ["id", "name", "toys"], | ||||||
|                                 "title": "Pet", |                                 "title": "Pet", | ||||||
|                                 "type": "object", |                                 "type": "object", | ||||||
|                             }, |                             }, | ||||||
|                             "type": "array", |                             "type": "array", | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 }, | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
| @@ -110,32 +192,43 @@ async def test_pets_route_should_have_post_method(generated_oas): | |||||||
|             "content": { |             "content": { | ||||||
|                 "application/json": { |                 "application/json": { | ||||||
|                     "schema": { |                     "schema": { | ||||||
|                         "title": "Pet", |  | ||||||
|                         "type": "object", |  | ||||||
|                         "properties": { |                         "properties": { | ||||||
|                             "id": {"title": "Id", "type": "integer"}, |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|                             "name": {"title": "Name", "type": "string"}, |                             "name": {"title": "Name", "type": "string"}, | ||||||
|  |                             "toys": { | ||||||
|  |                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                 "title": "Toys", | ||||||
|  |                                 "type": "array", | ||||||
|  |                             }, | ||||||
|                         }, |                         }, | ||||||
|                         "required": ["id", "name"], |                         "required": ["id", "name", "toys"], | ||||||
|  |                         "title": "Pet", | ||||||
|  |                         "type": "object", | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|         "responses": { |         "responses": { | ||||||
|             "201": { |             "201": { | ||||||
|  |                 "description": "", | ||||||
|                 "content": { |                 "content": { | ||||||
|                     "application/json": { |                     "application/json": { | ||||||
|                         "schema": { |                         "schema": { | ||||||
|                             "title": "Pet", |  | ||||||
|                             "type": "object", |  | ||||||
|                             "properties": { |                             "properties": { | ||||||
|                                 "id": {"title": "Id", "type": "integer"}, |                                 "id": {"title": "Id", "type": "integer"}, | ||||||
|                                 "name": {"title": "Name", "type": "string"}, |                                 "name": {"title": "Name", "type": "string"}, | ||||||
|  |                                 "toys": { | ||||||
|  |                                     "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                     "title": "Toys", | ||||||
|  |                                     "type": "array", | ||||||
|  |                                 }, | ||||||
|                             }, |                             }, | ||||||
|                             "required": ["id", "name"], |                             "required": ["id", "name", "toys"], | ||||||
|  |                             "title": "Pet", | ||||||
|  |                             "type": "object", | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 }, | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
| @@ -147,15 +240,16 @@ async def test_generated_oas_should_have_pets_id_paths(generated_oas): | |||||||
|  |  | ||||||
| async def test_pets_id_route_should_have_delete_method(generated_oas): | async def test_pets_id_route_should_have_delete_method(generated_oas): | ||||||
|     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { |     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { | ||||||
|  |         "description": "", | ||||||
|         "parameters": [ |         "parameters": [ | ||||||
|             { |             { | ||||||
|                 "required": True, |  | ||||||
|                 "in": "path", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "schema": {"type": "integer"}, |                 "required": True, | ||||||
|  |                 "schema": {"title": "id", "type": "integer"}, | ||||||
|             } |             } | ||||||
|         ], |         ], | ||||||
|         "responses": {"204": {"content": {}}}, |         "responses": {"204": {"content": {}, "description": "Empty but OK"}}, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -166,26 +260,54 @@ async def test_pets_id_route_should_have_get_method(generated_oas): | |||||||
|                 "in": "path", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "required": True, |                 "required": True, | ||||||
|                 "schema": {"type": "integer"}, |                 "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"}, {"enum": ["now"], "type": "string"}], | ||||||
|  |                     "default": "now", | ||||||
|  |                     "title": "day", | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|         ], |         ], | ||||||
|         "responses": { |         "responses": { | ||||||
|             "200": { |             "200": { | ||||||
|  |                 "description": "", | ||||||
|                 "content": { |                 "content": { | ||||||
|                     "application/json": { |                     "application/json": { | ||||||
|                         "schema": { |                         "schema": { | ||||||
|                             "properties": { |                             "properties": { | ||||||
|                                 "id": {"title": "Id", "type": "integer"}, |                                 "id": {"title": "Id", "type": "integer"}, | ||||||
|                                 "name": {"title": "Name", "type": "string"}, |                                 "name": {"title": "Name", "type": "string"}, | ||||||
|  |                                 "toys": { | ||||||
|  |                                     "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                     "title": "Toys", | ||||||
|  |                                     "type": "array", | ||||||
|  |                                 }, | ||||||
|                             }, |                             }, | ||||||
|                             "required": ["id", "name"], |                             "required": ["id", "name", "toys"], | ||||||
|                             "title": "Pet", |                             "title": "Pet", | ||||||
|                             "type": "object", |                             "type": "object", | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 }, | ||||||
|             }, |             }, | ||||||
|             "404": {"content": {}}, |             "404": {"description": "", "content": {}}, | ||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -197,7 +319,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | |||||||
|                 "in": "path", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "required": True, |                 "required": True, | ||||||
|                 "schema": {"type": "integer"}, |                 "schema": {"title": "id", "type": "integer"}, | ||||||
|             } |             } | ||||||
|         ], |         ], | ||||||
|         "requestBody": { |         "requestBody": { | ||||||
| @@ -207,8 +329,13 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | |||||||
|                         "properties": { |                         "properties": { | ||||||
|                             "id": {"title": "Id", "type": "integer"}, |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|                             "name": {"title": "Name", "type": "string"}, |                             "name": {"title": "Name", "type": "string"}, | ||||||
|  |                             "toys": { | ||||||
|  |                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||||
|  |                                 "title": "Toys", | ||||||
|  |                                 "type": "array", | ||||||
|  |                             }, | ||||||
|                         }, |                         }, | ||||||
|                         "required": ["id", "name"], |                         "required": ["id", "name", "toys"], | ||||||
|                         "title": "Pet", |                         "title": "Pet", | ||||||
|                         "type": "object", |                         "type": "object", | ||||||
|                     } |                     } | ||||||
| @@ -216,3 +343,72 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | |||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from uuid import UUID | from uuid import UUID | ||||||
|  |  | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
| @@ -38,32 +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) == ( | ||||||
|  |         {"id": str}, | ||||||
|  |         {}, | ||||||
|  |         {"page": int}, | ||||||
|  |         {}, | ||||||
|  |         {}, | ||||||
|  |     ) | ||||||
|     assert _parse_func_signature(path_and_header) == ( |     assert _parse_func_signature(path_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {}, |         {}, | ||||||
|         {}, |         {}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(qs_and_header) == ( |     assert _parse_func_signature(qs_and_header) == ( | ||||||
|         {}, |         {}, | ||||||
|         {}, |         {}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(path_qs_and_header) == ( |     assert _parse_func_signature(path_qs_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {}, |         {}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(path_body_qs_and_header) == ( |     assert _parse_func_signature(path_body_qs_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {"user": User}, |         {"user": User}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|  |         {}, | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -1,4 +1,6 @@ | |||||||
| from typing import Optional | from __future__ import annotations | ||||||
|  |  | ||||||
|  | from typing import Iterator, List, Optional | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
| @@ -11,10 +13,20 @@ class ArticleModel(BaseModel): | |||||||
|     nb_page: Optional[int] |     nb_page: Optional[int] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ArticleModels(BaseModel): | ||||||
|  |     __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( | async def test_post_an_article_without_required_field_should_return_an_error_message( | ||||||
|     aiohttp_client, loop |     aiohttp_client, loop | ||||||
| @@ -27,7 +39,12 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes | |||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         {"loc": ["name"], "msg": "field required", "type": "value_error.missing"} |         { | ||||||
|  |             "in": "body", | ||||||
|  |             "loc": ["name"], | ||||||
|  |             "msg": "field required", | ||||||
|  |             "type": "value_error.missing", | ||||||
|  |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -43,6 +60,7 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess | |||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|  |             "in": "body", | ||||||
|             "loc": ["nb_page"], |             "loc": ["nb_page"], | ||||||
|             "msg": "value is not a valid integer", |             "msg": "value is not a valid integer", | ||||||
|             "type": "type_error.integer", |             "type": "type_error.integer", | ||||||
| @@ -50,6 +68,58 @@ async def test_post_an_article_with_wrong_type_field_should_return_an_error_mess | |||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_array_json_is_supported(aiohttp_client, loop): | ||||||
|  |     app = web.Application() | ||||||
|  |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|  |     client = await aiohttp_client(app) | ||||||
|  |     body = [{"name": "foo", "nb_page": 3}] * 2 | ||||||
|  |     resp = await client.put("/article", json=body) | ||||||
|  |     assert resp.status == 200 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |     assert await resp.json() == body | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_post_an_array_json_to_an_object_model_should_return_an_error( | ||||||
|  |     aiohttp_client, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "body", | ||||||
|  |             "loc": ["__root__"], | ||||||
|  |             "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, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "body", | ||||||
|  |             "loc": ["__root__"], | ||||||
|  |             "msg": "value is not a valid list", | ||||||
|  |             "type": "type_error.list", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): | async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, loop): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|   | |||||||
| @@ -1,9 +1,13 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| import json | import json | ||||||
| from datetime import datetime | from datetime import datetime | ||||||
|  | from enum import Enum | ||||||
|  |  | ||||||
| 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 JSONEncoder(json.JSONEncoder): | class JSONEncoder(json.JSONEncoder): | ||||||
| @@ -21,6 +25,41 @@ class ArticleView(PydanticView): | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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( | async def test_get_article_without_required_header_should_return_an_error_message( | ||||||
|     aiohttp_client, loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
| @@ -33,6 +72,7 @@ async def test_get_article_without_required_header_should_return_an_error_messag | |||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|  |             "in": "headers", | ||||||
|             "loc": ["signature_expired"], |             "loc": ["signature_expired"], | ||||||
|             "msg": "field required", |             "msg": "field required", | ||||||
|             "type": "value_error.missing", |             "type": "value_error.missing", | ||||||
| @@ -52,6 +92,7 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message | |||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|  |             "in": "headers", | ||||||
|             "loc": ["signature_expired"], |             "loc": ["signature_expired"], | ||||||
|             "msg": "invalid datetime format", |             "msg": "invalid datetime format", | ||||||
|             "type": "value_error.datetime", |             "type": "value_error.datetime", | ||||||
| @@ -87,3 +128,55 @@ async def test_get_article_with_valid_header_containing_hyphen_should_be_returne | |||||||
|     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, 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": {"enum_values": ["UMT", "MGRS"]}, | ||||||
|  |                 "in": "headers", | ||||||
|  |                 "loc": ["format"], | ||||||
|  |                 "msg": "value is not a valid enumeration member; permitted: 'UMT', 'MGRS'", | ||||||
|  |                 "type": "type_error.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, 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, 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,3 +1,5 @@ | |||||||
|  | from __future__ import annotations | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
| @@ -8,7 +10,7 @@ class ArticleView(PydanticView): | |||||||
|         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( | async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path( | ||||||
|     aiohttp_client, loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
| @@ -19,3 +21,23 @@ async def test_get_article_without_required_qs_should_return_an_error_message( | |||||||
|     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, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "path", | ||||||
|  |             "loc": ["date"], | ||||||
|  |             "msg": "value is not a valid integer", | ||||||
|  |             "type": "type_error.integer", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|   | |||||||
| @@ -1,13 +1,57 @@ | |||||||
| from typing import Optional | from __future__ import annotations | ||||||
|  |  | ||||||
|  | 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(self, with_comments: bool, age: Optional[int] = None): |     async def get( | ||||||
|         return web.json_response({"with_comments": with_comments, "age": age}) |         self, | ||||||
|  |         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, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | 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, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_without_required_qs_should_return_an_error_message( | async def test_get_article_without_required_qs_should_return_an_error_message( | ||||||
| @@ -22,6 +66,7 @@ async def test_get_article_without_required_qs_should_return_an_error_message( | |||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|  |             "in": "query string", | ||||||
|             "loc": ["with_comments"], |             "loc": ["with_comments"], | ||||||
|             "msg": "field required", |             "msg": "field required", | ||||||
|             "type": "value_error.missing", |             "type": "value_error.missing", | ||||||
| @@ -41,6 +86,7 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message( | |||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|  |             "in": "query string", | ||||||
|             "loc": ["with_comments"], |             "loc": ["with_comments"], | ||||||
|             "msg": "value could not be parsed to a boolean", |             "msg": "value could not be parsed to a boolean", | ||||||
|             "type": "type_error.bool", |             "type": "type_error.bool", | ||||||
| @@ -59,10 +105,15 @@ async def test_get_article_with_valid_qs_should_return_the_parsed_type( | |||||||
|     resp = await client.get("/article", params={"with_comments": "yes", "age": 3}) |     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, "age": 3} |     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_none( | async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( | ||||||
|     aiohttp_client, loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
| @@ -71,6 +122,136 @@ async def test_get_article_with_valid_qs_and_omitted_optional_should_return_none | |||||||
|     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"}) | ||||||
|  |     assert await resp.json() == { | ||||||
|  |         "with_comments": True, | ||||||
|  |         "age": None, | ||||||
|  |         "nb_items": 7, | ||||||
|  |         "tags": [], | ||||||
|  |     } | ||||||
|     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, "age": None} |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_multiple_value_for_qs_age_must_failed( | ||||||
|  |     aiohttp_client, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "query string", | ||||||
|  |             "loc": ["age"], | ||||||
|  |             "msg": "value is not a valid integer", | ||||||
|  |             "type": "type_error.integer", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_multiple_value_of_tags(aiohttp_client, 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, 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, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "query string", | ||||||
|  |             "loc": ["page_num"], | ||||||
|  |             "msg": "field required", | ||||||
|  |             "type": "value_error.missing", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def test_get_article_with_page(aiohttp_client, 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, 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_page_and_wrong_page_size(aiohttp_client, 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() == [ | ||||||
|  |         { | ||||||
|  |             "in": "query string", | ||||||
|  |             "loc": ["page_size"], | ||||||
|  |             "msg": "value is not a valid integer", | ||||||
|  |             "type": "type_error.integer", | ||||||
|  |         } | ||||||
|  |     ] | ||||||
|  |     assert resp.status == 400 | ||||||
|  |     assert resp.content_type == "application/json" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user