Compare commits
	
		
			49 Commits
		
	
	
		
			v1.5.1
			...
			1.12.1-dev
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 4aee715e48 | ||
|  | c649905e69 | ||
|  | 4015c60cfa | ||
|  | a1dcc544cf | ||
|  | f278629217 | ||
|  | 4e8fb95c52 | ||
|  | 27c0d76e16 | ||
|  | 69fb553635 | ||
|  | 3648dde1ea | ||
|  | f5f3a48ba4 | ||
|  | 799080bbd0 | ||
|  | 4a49d3b53d | ||
|  | 1b10ebbcfa | ||
|  | fa7e8d914b | ||
|  | cb996860a9 | ||
|  | dbf1eb6ac4 | ||
|  | adcf4ba902 | ||
|  | a624aba613 | ||
|  | c1a63e55b2 | ||
|  | 9a624437f4 | ||
|  | 43d2789636 | ||
|  | 258a5cddf6 | ||
|  | 7ab2d84263 | ||
|  | 81138cc1c6 | ||
|  | c9c8c6e205 | ||
|  | 17220f2840 | ||
|  | ff32f68e89 | ||
|  | 911bcbc2cd | ||
|  | 89a22f2fcd | ||
|  | 08ab4d2610 | ||
|  | c92437c624 | ||
|  | 5f86e1efda | ||
|  | 324c9b02f3 | ||
|  | beb638c0af | ||
|  | 7492af5acf | ||
|  | 145d2fc0f2 | ||
|  | 81d4e93a1d | ||
|  | c6b979dcaf | ||
|  | 4ff9739293 | ||
|  | 071395e8bd | ||
|  | cd8422bde3 | ||
|  | 070d7e7259 | ||
|  | 16ba8caa5b | ||
|  | 53357214a8 | ||
|  | cdcd526fb4 | ||
|  | 25fcac18ec | ||
|  | f2b16a46b5 | ||
|  | c4c18ee4a1 | ||
|  | 93ec0f6c80 | 
							
								
								
									
										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 | ||||
| .idea/ | ||||
| .pypirc | ||||
| .pytest_cache | ||||
| __pycache__ | ||||
| aiohttp_pydantic.egg-info/ | ||||
| build/ | ||||
| coverage.xml | ||||
| 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' | ||||
							
								
								
									
										189
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										189
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,14 +1,15 @@ | ||||
| Aiohttp pydantic - Aiohttp View to validate and parse request | ||||
| ============================================================= | ||||
|  | ||||
| .. image:: https://travis-ci.org/Maillol/aiohttp-pydantic.svg?branch=main | ||||
|   :target: https://travis-ci.org/Maillol/aiohttp-pydantic | ||||
| .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg | ||||
|   :target: https://cloud.drone.io/Maillol/aiohttp-pydantic | ||||
|   :alt: Build status for master branch | ||||
|  | ||||
| .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||
|   :target: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||
|   :alt: Latest PyPI package version | ||||
|  | ||||
| .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/add_code_coverage/graph/badge.svg | ||||
| .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg | ||||
|   :target: https://codecov.io/gh/Maillol/aiohttp-pydantic | ||||
|   :alt: codecov.io status for master branch | ||||
|  | ||||
| @@ -54,7 +55,7 @@ Example: | ||||
|             return web.json_response({'name': article.name, | ||||
|                                       '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}) | ||||
|  | ||||
|  | ||||
| @@ -68,6 +69,7 @@ Example: | ||||
|     $ curl -X GET http://127.0.0.1:8080/article?with_comments=a | ||||
|     [ | ||||
|       { | ||||
|         "in": "query string", | ||||
|         "loc": [ | ||||
|           "with_comments" | ||||
|         ], | ||||
| @@ -82,6 +84,7 @@ Example: | ||||
|     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' | ||||
|     [ | ||||
|       { | ||||
|         "in": "body", | ||||
|         "loc": [ | ||||
|           "name" | ||||
|         ], | ||||
| @@ -99,7 +102,7 @@ API: | ||||
| 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: | ||||
| @@ -116,22 +119,40 @@ Example: | ||||
| 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 | ||||
|  | ||||
|     class AccountView(PydanticView): | ||||
|         async def get(self, customer_id: str): | ||||
|         async def get(self, customer_id: Optional[str] = None): | ||||
|             ... | ||||
|  | ||||
|     app = web.Application() | ||||
|     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 | ||||
| ~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| 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 | ||||
| @@ -150,7 +171,7 @@ To declare a body parameters, you must declare your argument as a simple argumen | ||||
| 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 | ||||
| @@ -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]) | ||||
|  | ||||
|  | ||||
| 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 | ||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| @@ -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 | ||||
| defined using a pydantic.BaseModel | ||||
|  | ||||
| The docstring of methods will be parsed to fill the descriptions in the | ||||
| Open Api Specification. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
| @@ -233,20 +267,47 @@ defined using a pydantic.BaseModel | ||||
|  | ||||
|     class PetCollectionView(PydanticView): | ||||
|         async def get(self) -> r200[List[Pet]]: | ||||
|             """ | ||||
|             Find all pets | ||||
|  | ||||
|             Tags: pet | ||||
|             """ | ||||
|             pets = self.request.app["model"].list_pets() | ||||
|             return web.json_response([pet.dict() for pet in pets]) | ||||
|  | ||||
|         async def post(self, pet: Pet) -> r201[Pet]: | ||||
|             """ | ||||
|             Add a new pet to the store | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 201: The pet is created | ||||
|             """ | ||||
|             self.request.app["model"].add_pet(pet) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
|  | ||||
|     class PetItemView(PydanticView): | ||||
|         async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: | ||||
|             """ | ||||
|             Find a pet by ID | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 200: Successful operation | ||||
|                 404: Pet not found | ||||
|             """ | ||||
|             pet = self.request.app["model"].find_pet(id) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
|         async def put(self, id: int, /, pet: Pet) -> r200[Pet]: | ||||
|             """ | ||||
|             Update an existing pet | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 200: successful operation | ||||
|             """ | ||||
|             self.request.app["model"].update_pet(id, pet) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
| @@ -254,6 +315,91 @@ defined using a pydantic.BaseModel | ||||
|             self.request.app["model"].remove_pet(id) | ||||
|             return web.Response(status=204) | ||||
|  | ||||
|  | ||||
| Group parameters | ||||
| ---------------- | ||||
|  | ||||
| If your method has lot of parameters you can group them together inside one or several Groups. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     from aiohttp_pydantic.injectors import Group | ||||
|  | ||||
|     class Pagination(Group): | ||||
|         page_num: int = 1 | ||||
|         page_size: int = 15 | ||||
|  | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page: Pagination): | ||||
|             articles = Article.get(page.page_num, page.page_size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| The parameters page_num and page_size are expected in the query string, and | ||||
| set inside a Pagination object passed as page parameter. | ||||
|  | ||||
| The code above is equivalent to: | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page_num: int = 1, page_size: int = 15): | ||||
|             articles = Article.get(page_num, page_size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| You can add methods or properties to your Group. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class Pagination(Group): | ||||
|         page_num: int = 1 | ||||
|         page_size: int = 15 | ||||
|  | ||||
|         @property | ||||
|         def num(self): | ||||
|             return self.page_num | ||||
|  | ||||
|         @property | ||||
|         def size(self): | ||||
|             return self.page_size | ||||
|  | ||||
|         def slice(self): | ||||
|             return slice(self.num, self.size) | ||||
|  | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page: Pagination): | ||||
|             articles = Article.get(page.num, page.size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| Custom Validation error | ||||
| ----------------------- | ||||
|  | ||||
| You can redefine the on_validation_error hook in your PydanticView | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class PetView(PydanticView): | ||||
|  | ||||
|         async def on_validation_error(self, | ||||
|                                       exception: ValidationError, | ||||
|                                       context: str): | ||||
|             errors = exception.errors() | ||||
|             for error in errors: | ||||
|                 error["in"] = context  # context is "body", "headers", "path" or "query string" | ||||
|                 error["custom"] = "your custom field ..." | ||||
|             return json_response(data=errors, status=400) | ||||
|  | ||||
|  | ||||
| Demo | ||||
| ---- | ||||
|  | ||||
| @@ -268,12 +414,35 @@ Have a look at `demo`_ for a complete example | ||||
|  | ||||
| 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 | ||||
|  | ||||
|     python -m aiohttp_pydantic.oas demo.main | ||||
|  | ||||
| .. code-block:: bash | ||||
|  | ||||
|     $ python3 -m aiohttp_pydantic.oas  --help | ||||
|     usage: __main__.py [-h] [-b FILE] [-o FILE] [-f FORMAT] [APP [APP ...]] | ||||
|  | ||||
|     Generate Open API Specification | ||||
|  | ||||
|     positional arguments: | ||||
|       APP                   The name of the module containing the asyncio.web.Application. By default the variable named | ||||
|                             'app' is loaded but you can define an other variable name ending the name of module with : | ||||
|                             characters and the name of variable. Example: my_package.my_module:my_app If your | ||||
|                             asyncio.web.Application is returned by a function, you can use the syntax: | ||||
|                             my_package.my_module:my_app() | ||||
|  | ||||
|     optional arguments: | ||||
|       -h, --help            show this help message and exit | ||||
|       -b FILE, --base-oas-file FILE | ||||
|                             A file that will be used as base to generate OAS | ||||
|       -o FILE, --output FILE | ||||
|                             File to write the output | ||||
|       -f FORMAT, --format FORMAT | ||||
|                             The output format, can be 'json' or 'yaml' (default is json) | ||||
|  | ||||
|  | ||||
| .. _demo: https://github.com/Maillol/aiohttp-pydantic/tree/main/demo | ||||
| .. _aiohttp view: https://docs.aiohttp.org/en/stable/web_quickstart.html#class-based-views | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| from .view import PydanticView | ||||
|  | ||||
| __version__ = "1.5.1" | ||||
| __version__ = "1.12.1-dev4" | ||||
|  | ||||
| __all__ = ("PydanticView", "__version__") | ||||
|   | ||||
| @@ -1,13 +1,18 @@ | ||||
| import abc | ||||
| from inspect import signature | ||||
| import typing | ||||
| from inspect import signature, getmro | ||||
| 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_request import BaseRequest | ||||
| from multidict import MultiDict | ||||
| 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): | ||||
| @@ -15,8 +20,18 @@ class AbstractInjector(metaclass=abc.ABCMeta): | ||||
|     An injector parse HTTP request and inject params to the view. | ||||
|     """ | ||||
|  | ||||
|     model: Type[BaseModel] | ||||
|  | ||||
|     @property | ||||
|     @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 | ||||
|         """ | ||||
| @@ -33,8 +48,12 @@ class MatchInfoGetter(AbstractInjector): | ||||
|     Validates and injects the part of URL path inside the view positional args. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, args_spec: dict): | ||||
|         self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|     context = "path" | ||||
|  | ||||
|     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): | ||||
|         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. | ||||
|     """ | ||||
|  | ||||
|     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._expect_object = self.model.schema()["type"] == "object" | ||||
|  | ||||
|     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||
|         try: | ||||
| @@ -54,9 +76,18 @@ class BodyGetter(AbstractInjector): | ||||
|         except JSONDecodeError: | ||||
|             raise HTTPBadRequest( | ||||
|                 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): | ||||
| @@ -64,11 +95,49 @@ class QueryGetter(AbstractInjector): | ||||
|     Validates and injects the query string inside the view kwargs. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, args_spec: dict): | ||||
|         self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|     context = "query string" | ||||
|  | ||||
|     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): | ||||
|         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): | ||||
| @@ -76,45 +145,162 @@ class HeadersGetter(AbstractInjector): | ||||
|     Validates and injects the HTTP headers inside the view kwargs. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, args_spec: dict): | ||||
|         self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|     context = "headers" | ||||
|  | ||||
|     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): | ||||
|         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 | ||||
|         1 - argument will be set from the request body. | ||||
|         2 - argument will be set from the query string. | ||||
|         3 - argument will be set from the HTTP headers. | ||||
|         4 - Default value for each parameters | ||||
|     """ | ||||
|  | ||||
|     path_args = {} | ||||
|     body_args = {} | ||||
|     qs_args = {} | ||||
|     header_args = {} | ||||
|     defaults = {} | ||||
|  | ||||
|     annotations = get_type_hints(func) | ||||
|     for param_name, param_spec in signature(func).parameters.items(): | ||||
|  | ||||
|         if param_name == "self": | ||||
|             continue | ||||
|  | ||||
|         if param_spec.annotation == param_spec.empty: | ||||
|             raise RuntimeError(f"The parameter {param_name} must have an annotation") | ||||
|  | ||||
|         annotation = annotations[param_name] | ||||
|         if param_spec.default is not param_spec.empty: | ||||
|             defaults[param_name] = param_spec.default | ||||
|  | ||||
|         if param_spec.kind is param_spec.POSITIONAL_ONLY: | ||||
|             path_args[param_name] = param_spec.annotation | ||||
|             path_args[param_name] = annotation | ||||
|  | ||||
|         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: | ||||
|             if is_pydantic_base_model(param_spec.annotation): | ||||
|                 body_args[param_name] = param_spec.annotation | ||||
|             if is_pydantic_base_model(annotation): | ||||
|                 body_args[param_name] = annotation | ||||
|             else: | ||||
|                 qs_args[param_name] = param_spec.annotation | ||||
|                 qs_args[param_name] = annotation | ||||
|         elif param_spec.kind is param_spec.KEYWORD_ONLY: | ||||
|             header_args[param_name] = param_spec.annotation | ||||
|             header_args[param_name] = annotation | ||||
|         else: | ||||
|             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 typing import Iterable | ||||
| from typing import Iterable, Optional | ||||
|  | ||||
| import jinja2 | ||||
| from aiohttp import web | ||||
| @@ -13,13 +13,21 @@ def setup( | ||||
|     apps_to_expose: Iterable[web.Application] = (), | ||||
|     url_prefix: str = "/oas", | ||||
|     enable: bool = True, | ||||
|     version_spec: Optional[str] = None, | ||||
|     title_spec: Optional[str] = None, | ||||
|     raise_validation_errors: bool = False, | ||||
| ): | ||||
|     if enable: | ||||
|         oas_app = web.Application() | ||||
|         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( | ||||
|             resources.read_text("aiohttp_pydantic.oas", "index.j2") | ||||
|         ) | ||||
|         oas_app["version_spec"] = version_spec | ||||
|         oas_app["title_spec"] = title_spec | ||||
|  | ||||
|         oas_app.router.add_get("/spec", get_oas, name="spec") | ||||
|         oas_app.router.add_static("/static", swagger_ui_path, name="static") | ||||
|         oas_app.router.add_get("", oas_ui, name="index") | ||||
|   | ||||
| @@ -1,10 +1,28 @@ | ||||
| import argparse | ||||
| import importlib | ||||
| import json | ||||
|  | ||||
| from typing import Dict, Protocol, Optional, Callable | ||||
| import sys | ||||
| from .view import generate_oas | ||||
|  | ||||
|  | ||||
| class YamlModule(Protocol): | ||||
|     """ | ||||
|     Yaml Module type hint | ||||
|     """ | ||||
|  | ||||
|     def dump(self, data) -> str: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| yaml: Optional[YamlModule] | ||||
|  | ||||
| try: | ||||
|     import yaml | ||||
| except ImportError: | ||||
|     yaml = None | ||||
|  | ||||
|  | ||||
| def application_type(value): | ||||
|     """ | ||||
|     Return aiohttp application defined in the value. | ||||
| @@ -26,6 +44,35 @@ def application_type(value): | ||||
|         raise argparse.ArgumentTypeError(error) from error | ||||
|  | ||||
|  | ||||
| def base_oas_file_type(value) -> Dict: | ||||
|     """ | ||||
|     Load base oas file | ||||
|     """ | ||||
|     try: | ||||
|         with open(value) as oas_file: | ||||
|             data = oas_file.read() | ||||
|     except OSError as error: | ||||
|         raise argparse.ArgumentTypeError(error) from error | ||||
|  | ||||
|     return json.loads(data) | ||||
|  | ||||
|  | ||||
| def format_type(value) -> Callable: | ||||
|     """ | ||||
|     Date Dumper one of (json, yaml) | ||||
|     """ | ||||
|     dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)} | ||||
|     if yaml is not None: | ||||
|         dumpers["yaml"] = yaml.dump | ||||
|  | ||||
|     try: | ||||
|         return dumpers[value] | ||||
|     except KeyError: | ||||
|         raise argparse.ArgumentTypeError( | ||||
|             f"Wrong format value. (allowed values: {tuple(dumpers.keys())})" | ||||
|         ) from None | ||||
|  | ||||
|  | ||||
| def setup(parser: argparse.ArgumentParser): | ||||
|     parser.add_argument( | ||||
|         "apps", | ||||
| @@ -35,11 +82,52 @@ def setup(parser: argparse.ArgumentParser): | ||||
|         help="The name of the module containing the asyncio.web.Application." | ||||
|         " By default the variable named 'app' is loaded but you can define" | ||||
|         " an other variable name ending the name of module with : characters" | ||||
|         " and the name of variable. Example: my_package.my_module:my_app", | ||||
|         " and the name of variable. Example: my_package.my_module:my_app" | ||||
|         " If your asyncio.web.Application is returned by a function, you can" | ||||
|         " use the syntax: my_package.my_module:my_app()", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-b", | ||||
|         "--base-oas-file", | ||||
|         metavar="FILE", | ||||
|         dest="base", | ||||
|         type=base_oas_file_type, | ||||
|         help="A file that will be used as base to generate OAS", | ||||
|         default={}, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-o", | ||||
|         "--output", | ||||
|         metavar="FILE", | ||||
|         type=argparse.FileType("w"), | ||||
|         help="File to write the output", | ||||
|         default=sys.stdout, | ||||
|     ) | ||||
|  | ||||
|     if yaml: | ||||
|         help_output_format = ( | ||||
|             "The output format, can be 'json' or 'yaml' (default is json)" | ||||
|         ) | ||||
|     else: | ||||
|         help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format" | ||||
|  | ||||
|     parser.add_argument( | ||||
|         "-f", | ||||
|         "--format", | ||||
|         metavar="FORMAT", | ||||
|         dest="formatter", | ||||
|         type=format_type, | ||||
|         help=help_output_format, | ||||
|         default=format_type("json"), | ||||
|     ) | ||||
|  | ||||
|     parser.set_defaults(func=show_oas) | ||||
|  | ||||
|  | ||||
| def show_oas(args: argparse.Namespace): | ||||
|     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. | ||||
| """ | ||||
|  | ||||
| from typing import Union | ||||
| from typing import Union, List | ||||
|  | ||||
|  | ||||
| class Info: | ||||
| @@ -133,6 +133,7 @@ class Parameters: | ||||
| class Response: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec | ||||
|         self._spec.setdefault("description", "") | ||||
|  | ||||
|     @property | ||||
|     def description(self) -> str: | ||||
| @@ -156,7 +157,7 @@ class Responses: | ||||
|         self._spec = spec.setdefault("responses", {}) | ||||
|  | ||||
|     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") | ||||
|  | ||||
|         spec = self._spec.setdefault(str(status_code), {}) | ||||
| @@ -195,6 +196,17 @@ class OperationObject: | ||||
|     def responses(self) -> Responses: | ||||
|         return Responses(self._spec) | ||||
|  | ||||
|     @property | ||||
|     def tags(self) -> List[str]: | ||||
|         return self._spec.get("tags", [])[:] | ||||
|  | ||||
|     @tags.setter | ||||
|     def tags(self, tags: List[str]): | ||||
|         if tags: | ||||
|             self._spec["tags"] = tags[:] | ||||
|         else: | ||||
|             self._spec.pop("tags", None) | ||||
|  | ||||
|  | ||||
| class PathItem: | ||||
|     def __init__(self, spec: dict): | ||||
| @@ -293,9 +305,21 @@ class Servers: | ||||
|         return Server(spec) | ||||
|  | ||||
|  | ||||
| class Components: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec.setdefault("components", {}) | ||||
|  | ||||
|     @property | ||||
|     def schemas(self) -> dict: | ||||
|         return self._spec.setdefault("schemas", {}) | ||||
|  | ||||
|  | ||||
| class OpenApiSpec3: | ||||
|     def __init__(self): | ||||
|         self._spec = {"openapi": "3.0.0"} | ||||
|         self._spec = { | ||||
|             "openapi": "3.0.0", | ||||
|             "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, | ||||
|         } | ||||
|  | ||||
|     @property | ||||
|     def info(self) -> Info: | ||||
| @@ -309,6 +333,10 @@ class OpenApiSpec3: | ||||
|     def paths(self) -> Paths: | ||||
|         return Paths(self._spec) | ||||
|  | ||||
|     @property | ||||
|     def components(self) -> Components: | ||||
|         return Components(self._spec) | ||||
|  | ||||
|     @property | ||||
|     def spec(self): | ||||
|         return self._spec | ||||
|   | ||||
| @@ -13,7 +13,7 @@ Example: | ||||
|  | ||||
| from functools import lru_cache | ||||
| from types import new_class | ||||
| from typing import Protocol, TypeVar, Optional, Type | ||||
| from typing import Protocol, TypeVar | ||||
|  | ||||
| RespContents = TypeVar("RespContents", covariant=True) | ||||
|  | ||||
|   | ||||
| @@ -1,45 +1,20 @@ | ||||
| import typing | ||||
| from datetime import date, datetime | ||||
| from inspect import getdoc | ||||
| from itertools import count | ||||
| from typing import List, Type | ||||
| from uuid import UUID | ||||
| from typing import List, Type, Optional, get_type_hints | ||||
|  | ||||
| from aiohttp.web import Response, json_response | ||||
| from aiohttp.web_app import Application | ||||
| from pydantic import BaseModel | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | ||||
| from . import docstring_parser | ||||
|  | ||||
| from ..injectors import _parse_func_signature | ||||
| from ..utils import is_pydantic_base_model | ||||
| from ..view import PydanticView, is_pydantic_view | ||||
| from .typing import is_status_code_type | ||||
|  | ||||
| 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: | ||||
|     """ | ||||
| @@ -47,13 +22,19 @@ class _OASResponseBuilder: | ||||
|     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 = oas | ||||
|         self._status_code_descriptions = status_code_descriptions | ||||
|  | ||||
|     @staticmethod | ||||
|     def _handle_pydantic_base_model(obj): | ||||
|     def _handle_pydantic_base_model(self, 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 {} | ||||
|  | ||||
|     def _handle_list(self, obj): | ||||
| @@ -72,10 +53,16 @@ class _OASResponseBuilder: | ||||
|                     "schema": self._handle_list(typing.get_args(obj)[0]) | ||||
|                 } | ||||
|             } | ||||
|             desc = self._status_code_descriptions.get(int(status_code)) | ||||
|             if desc: | ||||
|                 self._oas_operation.responses[status_code].description = desc | ||||
|  | ||||
|         elif is_status_code_type(obj): | ||||
|             status_code = obj.__name__[1:] | ||||
|             self._oas_operation.responses[status_code].content = {} | ||||
|             desc = self._status_code_descriptions.get(int(status_code)) | ||||
|             if desc: | ||||
|                 self._oas_operation.responses[status_code].description = desc | ||||
|  | ||||
|     def _handle_union(self, obj): | ||||
|         if typing.get_origin(obj) is typing.Union: | ||||
| @@ -88,19 +75,33 @@ class _OASResponseBuilder: | ||||
|  | ||||
|  | ||||
| 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() | ||||
|     oas_operation: OperationObject = getattr(oas_path, 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) | ||||
|     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: | ||||
|         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 = { | ||||
|             "application/json": {"schema": next(iter(body_args.values())).schema()} | ||||
|             "application/json": {"schema": body_schema} | ||||
|         } | ||||
|  | ||||
|     indexes = count() | ||||
| @@ -113,36 +114,55 @@ def _add_http_method_to_oas( | ||||
|             i = next(indexes) | ||||
|             oas_operation.parameters[i].in_ = args_location | ||||
|             oas_operation.parameters[i].name = name | ||||
|             optional_type = _handle_optional(type_) | ||||
|             if optional_type is None: | ||||
|                 oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_] | ||||
|                 oas_operation.parameters[i].required = True | ||||
|             else: | ||||
|                 oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type] | ||||
|  | ||||
|             attrs = {"__annotations__": {"__root__": type_}} | ||||
|             if name in defaults: | ||||
|                 attrs["__root__"] = defaults[name] | ||||
|                 oas_operation.parameters[i].required = False | ||||
|             else: | ||||
|                 oas_operation.parameters[i].required = True | ||||
|  | ||||
|     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: | ||||
|         _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. | ||||
|     """ | ||||
|     oas = OpenApiSpec3() | ||||
|  | ||||
|     if version_spec is not None: | ||||
|         oas.info.version = version_spec | ||||
|  | ||||
|     if title_spec is not None: | ||||
|         oas.info.title = title_spec | ||||
|  | ||||
|     for app in apps: | ||||
|         for resources in app.router.resources(): | ||||
|             for resource_route in resources: | ||||
|                 if is_pydantic_view(resource_route.handler): | ||||
|                 if not is_pydantic_view(resource_route.handler): | ||||
|                     continue | ||||
|  | ||||
|                 view: Type[PydanticView] = resource_route.handler | ||||
|                 info = resource_route.get_info() | ||||
|                 path = oas.paths[info.get("path", info.get("formatter"))] | ||||
|                 if resource_route.method == "*": | ||||
|                     for method_name in view.allowed_methods: | ||||
|                             _add_http_method_to_oas(path, method_name, view) | ||||
|                         _add_http_method_to_oas(oas, path, method_name, view) | ||||
|                 else: | ||||
|                         _add_http_method_to_oas(path, resource_route.method, view) | ||||
|                     _add_http_method_to_oas(oas, path, resource_route.method, view) | ||||
|  | ||||
|     return oas.spec | ||||
|  | ||||
| @@ -152,7 +172,9 @@ async def get_oas(request): | ||||
|     View to generate the Open Api Specification from PydanticView in application. | ||||
|     """ | ||||
|     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): | ||||
| @@ -163,6 +185,8 @@ async def oas_ui(request): | ||||
|  | ||||
|     static_url = request.app.router["static"].url_for(filename="") | ||||
|     spec_url = request.app.router["spec"].url_for() | ||||
|     if request.scheme != request.headers.get('x-forwarded-proto', request.scheme): | ||||
|         request = request.clone(scheme=request.headers['x-forwarded-proto']) | ||||
|     host = request.url.origin() | ||||
|  | ||||
|     return Response( | ||||
|   | ||||
| @@ -5,7 +5,15 @@ def is_pydantic_base_model(obj): | ||||
|     """ | ||||
|     Return true is obj is a pydantic.BaseModel subclass. | ||||
|     """ | ||||
|     return robuste_issubclass(obj, BaseModel) | ||||
|  | ||||
|  | ||||
| def robuste_issubclass(cls1, cls2): | ||||
|     """ | ||||
|     function likes issubclass but returns False instead of raise type error | ||||
|     if first parameter is not a class. | ||||
|     """ | ||||
|     try: | ||||
|         return issubclass(obj, BaseModel) | ||||
|         return issubclass(cls1, cls2) | ||||
|     except TypeError: | ||||
|         return False | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| from functools import update_wrapper | ||||
| 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.hdrs import METH_ALL | ||||
| @@ -16,6 +17,8 @@ from .injectors import ( | ||||
|     MatchInfoGetter, | ||||
|     QueryGetter, | ||||
|     _parse_func_signature, | ||||
|     CONTEXT, | ||||
|     Group, | ||||
| ) | ||||
|  | ||||
|  | ||||
| @@ -24,44 +27,85 @@ class PydanticView(AbstractView): | ||||
|     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: | ||||
|         method = getattr(self, self.request.method.lower(), None) | ||||
|         resp = await method() | ||||
|         return resp | ||||
|         if (method_name := self.request.method) not in self.allowed_methods: | ||||
|             self._raise_allowed_methods() | ||||
|         return await getattr(self, method_name.lower())() | ||||
|  | ||||
|     def __await__(self) -> Generator[Any, None, StreamResponse]: | ||||
|         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 = { | ||||
|             meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) | ||||
|         } | ||||
|  | ||||
|         for meth_name in METH_ALL: | ||||
|             if meth_name not in cls.allowed_methods: | ||||
|                 setattr(cls, meth_name.lower(), cls.raise_not_allowed) | ||||
|             else: | ||||
|             if meth_name.lower() in vars(cls): | ||||
|                 handler = getattr(cls, meth_name.lower()) | ||||
|                 decorated_handler = inject_params(handler, cls.parse_func_signature) | ||||
|                 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) | ||||
|  | ||||
|     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 | ||||
|     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 = [] | ||||
|  | ||||
|         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: | ||||
|             injectors.append(MatchInfoGetter(path_args)) | ||||
|             injectors.append(MatchInfoGetter(path_args, default_value(path_args))) | ||||
|         if body_args: | ||||
|             injectors.append(BodyGetter(body_args)) | ||||
|             injectors.append(BodyGetter(body_args, default_value(body_args))) | ||||
|         if qs_args: | ||||
|             injectors.append(QueryGetter(qs_args)) | ||||
|             injectors.append(QueryGetter(qs_args, default_value(qs_args))) | ||||
|         if header_args: | ||||
|             injectors.append(HeadersGetter(header_args)) | ||||
|             injectors.append(HeadersGetter(header_args, default_value(header_args))) | ||||
|         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( | ||||
|     handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] | ||||
| @@ -83,7 +127,10 @@ def inject_params( | ||||
|                 else: | ||||
|                     injector.inject(self.request, args, kwargs) | ||||
|             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) | ||||
|  | ||||
| @@ -99,3 +146,14 @@ def is_pydantic_view(obj) -> bool: | ||||
|         return issubclass(obj, PydanticView) | ||||
|     except TypeError: | ||||
|         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]) | ||||
| oas.setup(app) | ||||
| oas.setup(app, version_spec="1.0.1", title_spec="My App") | ||||
|  | ||||
| app["model"] = Model() | ||||
| app.router.add_view("/pets", PetCollectionView) | ||||
|   | ||||
| @@ -1,10 +1,17 @@ | ||||
| from pydantic import BaseModel | ||||
| from typing import List | ||||
|  | ||||
|  | ||||
| class Friend(BaseModel): | ||||
|     name: str | ||||
|     age: str | ||||
|  | ||||
|  | ||||
| class Pet(BaseModel): | ||||
|     id: int | ||||
|     name: str | ||||
|     age: int | ||||
|     friends: Friend | ||||
|  | ||||
|  | ||||
| class Error(BaseModel): | ||||
|   | ||||
							
								
								
									
										29
									
								
								demo/view.py
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								demo/view.py
									
									
									
									
									
								
							| @@ -10,25 +10,54 @@ from .model import Error, Pet | ||||
|  | ||||
| class PetCollectionView(PydanticView): | ||||
|     async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: | ||||
|         """ | ||||
|         List all pets | ||||
|  | ||||
|         Status Codes: | ||||
|             200: Successful operation | ||||
|         """ | ||||
|         pets = self.request.app["model"].list_pets() | ||||
|         return web.json_response( | ||||
|             [pet.dict() for pet in pets if age is None or age == pet.age] | ||||
|         ) | ||||
|  | ||||
|     async def post(self, pet: Pet) -> r201[Pet]: | ||||
|         """ | ||||
|         Add a new pet to the store | ||||
|  | ||||
|         Status Codes: | ||||
|             201: Successful operation | ||||
|         """ | ||||
|         self.request.app["model"].add_pet(pet) | ||||
|         return web.json_response(pet.dict()) | ||||
|  | ||||
|  | ||||
| class PetItemView(PydanticView): | ||||
|     async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: | ||||
|         """ | ||||
|         Find a pet by ID | ||||
|  | ||||
|         Status Codes: | ||||
|             200: Successful operation | ||||
|             404: Pet not found | ||||
|         """ | ||||
|         pet = self.request.app["model"].find_pet(id) | ||||
|         return web.json_response(pet.dict()) | ||||
|  | ||||
|     async def put(self, id: int, /, pet: Pet) -> r200[Pet]: | ||||
|         """ | ||||
|         Update an existing object | ||||
|  | ||||
|         Status Codes: | ||||
|             200: Successful operation | ||||
|             404: Pet not found | ||||
|         """ | ||||
|         self.request.app["model"].update_pet(id, pet) | ||||
|         return web.json_response(pet.dict()) | ||||
|  | ||||
|     async def delete(self, id: int, /) -> r204: | ||||
|         """ | ||||
|         Deletes a pet | ||||
|         """ | ||||
|         self.request.app["model"].remove_pet(id) | ||||
|         return web.Response(status=204) | ||||
|   | ||||
| @@ -1,7 +1,42 @@ | ||||
| certifi==2020.11.8 | ||||
| chardet==3.0.4 | ||||
| codecov==2.1.10 | ||||
| coverage==5.3 | ||||
| idna==2.10 | ||||
| requests==2.25.0 | ||||
| urllib3==1.26.2 | ||||
| async-timeout==3.0.1 | ||||
| attrs==21.2.0 | ||||
| bleach==4.0.0 | ||||
| certifi==2021.5.30 | ||||
| cffi==1.14.6 | ||||
| chardet==4.0.0 | ||||
| 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 | ||||
| coverage==5.3 | ||||
| async-timeout==3.0.1 | ||||
| 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 | ||||
| packaging==20.4 | ||||
| multidict==5.1.0 | ||||
| packaging==21.0 | ||||
| pluggy==0.13.1 | ||||
| py==1.9.0 | ||||
| py==1.10.0 | ||||
| Pygments==2.9.0 | ||||
| pyparsing==2.4.7 | ||||
| pytest==6.1.2 | ||||
| pytest-aiohttp==0.3.0 | ||||
| pytest-cov==2.10.1 | ||||
| six==1.15.0 | ||||
| readme-renderer==29.0 | ||||
| six==1.16.0 | ||||
| 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.9 | ||||
|     Topic :: Software Development :: Libraries :: Application Frameworks | ||||
|     Framework :: AsyncIO | ||||
|     Framework :: aiohttp | ||||
|     License :: OSI Approved :: MIT License | ||||
|  | ||||
| [options] | ||||
| @@ -31,17 +31,24 @@ packages = find: | ||||
| python_requires = >=3.8 | ||||
| install_requires = | ||||
|     aiohttp | ||||
|     pydantic | ||||
|     pydantic>=1.7 | ||||
|     swagger-ui-bundle | ||||
|  | ||||
| [options.extras_require] | ||||
| test = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1 | ||||
| ci = pytest==6.1.2; pytest-aiohttp==0.3.0; pytest-cov==2.10.1; codecov==2.1.10 | ||||
| test = | ||||
|     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] | ||||
| exclude = | ||||
|     tests | ||||
|     demo | ||||
|     tests* | ||||
|     demo* | ||||
|  | ||||
| [options.package_data] | ||||
| aiohttp_pydantic.oas = index.j2 | ||||
|   | ||||
							
								
								
									
										173
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								tasks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,173 @@ | ||||
| """ | ||||
| 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"] | ||||
|     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_pydantic import PydanticView | ||||
|   | ||||
| @@ -1,10 +1,15 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import argparse | ||||
| from textwrap import dedent | ||||
|  | ||||
| from io import StringIO | ||||
| from pathlib import Path | ||||
| import pytest | ||||
|  | ||||
| from aiohttp_pydantic.oas import cmd | ||||
|  | ||||
| PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def cmd_line(): | ||||
| @@ -13,13 +18,18 @@ def cmd_line(): | ||||
|     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.output = StringIO() | ||||
|     args.func(args) | ||||
|     captured = capfd.readouterr() | ||||
|  | ||||
|     expected = dedent( | ||||
|         """ | ||||
|     { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/route-1/{a}": { | ||||
| @@ -30,6 +40,7 @@ def test_show_oad_of_app(cmd_line, capfd): | ||||
|                             "name": "a", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "a", | ||||
|                                 "type": "integer" | ||||
|                             } | ||||
|                         } | ||||
| @@ -44,6 +55,7 @@ def test_show_oad_of_app(cmd_line, capfd): | ||||
|                             "name": "b", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "b", | ||||
|                                 "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.output = StringIO() | ||||
|     args.func(args) | ||||
|     captured = capfd.readouterr() | ||||
|     expected = dedent( | ||||
|         """ | ||||
|     { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/sub-app/route-2/{b}": { | ||||
| @@ -75,6 +91,7 @@ def test_show_oad_of_sub_app(cmd_line, capfd): | ||||
|                             "name": "b", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "b", | ||||
|                                 "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): | ||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:make_app()"]) | ||||
| def test_show_oas_of_a_callable(cmd_line): | ||||
|     args = cmd_line.parse_args( | ||||
|         [ | ||||
|             "tests.test_oas.test_cmd.sample:make_app()", | ||||
|             "--base-oas-file", | ||||
|             PATH_TO_BASE_JSON_FILE, | ||||
|         ] | ||||
|     ) | ||||
|     args.output = StringIO() | ||||
|     args.func(args) | ||||
|     captured = capfd.readouterr() | ||||
|     expected = dedent( | ||||
|         """ | ||||
|         { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/route-3/{a}": { | ||||
| @@ -106,6 +133,7 @@ def test_show_oad_of_a_callable(cmd_line, capfd): | ||||
|                             "name": "a", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "a", | ||||
|                                 "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 | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
| @@ -5,10 +7,16 @@ from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
|  | ||||
| def test_info_title(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.title is None | ||||
|     assert oas.info.title == "Aiohttp pydantic application" | ||||
|     oas.info.title = "Info Title" | ||||
|     assert oas.info.title == "Info Title" | ||||
|     assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"} | ||||
|     assert oas.spec == { | ||||
|         "info": { | ||||
|             "title": "Info Title", | ||||
|             "version": "1.0.0", | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_info_description(): | ||||
| @@ -16,15 +24,25 @@ def test_info_description(): | ||||
|     assert oas.info.description is None | ||||
|     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(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.version is None | ||||
|     assert oas.info.version == "1.0.0" | ||||
|     oas.info.version = "3.14" | ||||
|     assert oas.info.version == "3.14" | ||||
|     assert oas.spec == {"info": {"version": "3.14"}, "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(): | ||||
| @@ -33,7 +51,11 @@ def test_info_terms_of_service(): | ||||
|     oas.info.terms_of_service = "http://example.com/terms/" | ||||
|     assert oas.info.terms_of_service == "http://example.com/terms/" | ||||
|     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", | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
|  | ||||
|  | ||||
| @@ -6,6 +8,7 @@ def test_paths_description(): | ||||
|     oas.paths["/users/{id}"].description = "This route ..." | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": {"/users/{id}": {"description": "This route ..."}}, | ||||
|     } | ||||
|  | ||||
| @@ -13,7 +16,11 @@ def test_paths_description(): | ||||
| def test_paths_get(): | ||||
|     oas = OpenApiSpec3() | ||||
|     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(): | ||||
| @@ -22,6 +29,7 @@ def test_paths_operation_description(): | ||||
|     operation.description = "Long descriptions ..." | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, | ||||
|     } | ||||
|  | ||||
| @@ -32,6 +40,7 @@ def test_paths_operation_summary(): | ||||
|     operation.summary = "Updates a pet in the store with form data" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{id}": { | ||||
|                 "get": {"summary": "Updates a pet in the store with form data"} | ||||
| @@ -51,6 +60,7 @@ def test_paths_operation_parameters(): | ||||
|  | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{petId}": { | ||||
|                 "get": { | ||||
| @@ -86,6 +96,7 @@ def test_paths_operation_requestBody(): | ||||
|     request_body.required = True | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{petId}": { | ||||
|                 "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(): | ||||
|     oas = OpenApiSpec3() | ||||
|     response = oas.paths["/users/{petId}"].get.responses[200] | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| 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" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "servers": [ | ||||
|             {"url": "https://development.gigantic-server.com/v1"}, | ||||
|             {"url": "https://development.gigantic-server.com/v2"}, | ||||
| @@ -22,6 +25,7 @@ def test_sever_description(): | ||||
|     oas.servers[0].description = "Development server" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "servers": [ | ||||
|             { | ||||
|                 "url": "https://development.gigantic-server.com/v1", | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
| import pytest | ||||
| @@ -6,12 +9,26 @@ from aiohttp import web | ||||
| from pydantic.main import BaseModel | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView, oas | ||||
| from aiohttp_pydantic.injectors import Group | ||||
| from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||
| 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): | ||||
|     id: int | ||||
|     name: str | ||||
|     toys: List[Toy] | ||||
|  | ||||
|  | ||||
| class PetCollectionView(PydanticView): | ||||
| @@ -20,6 +37,10 @@ class PetCollectionView(PydanticView): | ||||
|     ) -> r200[List[Pet]]: | ||||
|         """ | ||||
|         Get a list of pets | ||||
|  | ||||
|         Tags: pet | ||||
|         Status Codes: | ||||
|           200: Successful operation | ||||
|         """ | ||||
|         return web.json_response() | ||||
|  | ||||
| @@ -29,28 +50,82 @@ class PetCollectionView(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() | ||||
|  | ||||
|     async def put(self, id: int, /, pet: Pet): | ||||
|         return web.json_response() | ||||
|  | ||||
|     async def delete(self, id: int, /) -> r204: | ||||
|         """ | ||||
|         Status Code: | ||||
|           204: Empty but OK | ||||
|         """ | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| class ViewResponseReturnASimpleType(PydanticView): | ||||
|     async def get(self) -> r200[int]: | ||||
|         """ | ||||
|         Status Codes: | ||||
|           200: The new number | ||||
|         """ | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| async def ensure_content_durability(client): | ||||
|     """ | ||||
|     Reload the page 2 times to ensure that content is always the same | ||||
|     note: pydantic can return a cached dict, if a view updates the dict the | ||||
|     output will be incoherent | ||||
|     """ | ||||
|     response_1 = await client.get("/oas/spec") | ||||
|     assert response_1.status == 200 | ||||
|     assert response_1.content_type == "application/json" | ||||
|     content_1 = await response_1.json() | ||||
|  | ||||
|     response_2 = await client.get("/oas/spec") | ||||
|     content_2 = await response_2.json() | ||||
|     assert content_1 == content_2 | ||||
|  | ||||
|     return content_2 | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| async def generated_oas(aiohttp_client, loop) -> web.Application: | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/pets", PetCollectionView) | ||||
|     app.router.add_view("/pets/{id}", PetItemView) | ||||
|     app.router.add_view("/simple-type", ViewResponseReturnASimpleType) | ||||
|     oas.setup(app) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     response = await client.get("/oas/spec") | ||||
|     assert response.status == 200 | ||||
|     assert response.content_type == "application/json" | ||||
|     return await response.json() | ||||
|     return await ensure_content_durability(await aiohttp_client(app)) | ||||
|  | ||||
|  | ||||
| async def test_generated_oas_should_have_components_schemas(generated_oas): | ||||
|     assert generated_oas["components"]["schemas"] == { | ||||
|         "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): | ||||
| @@ -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): | ||||
|     assert generated_oas["paths"]["/pets"]["get"] == { | ||||
|         "description": "Get a list of pets", | ||||
|         "tags": ["pet"], | ||||
|         "parameters": [ | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "format", | ||||
|                 "required": True, | ||||
|                 "schema": {"type": "string"}, | ||||
|                 "schema": {"title": "format", "type": "string"}, | ||||
|             }, | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "name", | ||||
|                 "required": False, | ||||
|                 "schema": {"type": "string"}, | ||||
|                 "schema": {"title": "name", "type": "string"}, | ||||
|             }, | ||||
|             { | ||||
|                 "in": "header", | ||||
|                 "name": "promo", | ||||
|                 "required": False, | ||||
|                 "schema": {"format": "uuid", "type": "string"}, | ||||
|                 "schema": {"format": "uuid", "title": "promo", "type": "string"}, | ||||
|             }, | ||||
|         ], | ||||
|         "responses": { | ||||
|             "200": { | ||||
|                 "description": "Successful operation", | ||||
|                 "content": { | ||||
|                     "application/json": { | ||||
|                         "schema": { | ||||
| @@ -89,15 +166,20 @@ async def test_pets_route_should_have_get_method(generated_oas): | ||||
|                                 "properties": { | ||||
|                                     "id": {"title": "Id", "type": "integer"}, | ||||
|                                     "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", | ||||
|                             }, | ||||
|                             "type": "array", | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 }, | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
| @@ -110,32 +192,43 @@ async def test_pets_route_should_have_post_method(generated_oas): | ||||
|             "content": { | ||||
|                 "application/json": { | ||||
|                     "schema": { | ||||
|                         "title": "Pet", | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "id": {"title": "Id", "type": "integer"}, | ||||
|                             "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": { | ||||
|             "201": { | ||||
|                 "description": "", | ||||
|                 "content": { | ||||
|                     "application/json": { | ||||
|                         "schema": { | ||||
|                             "title": "Pet", | ||||
|                             "type": "object", | ||||
|                             "properties": { | ||||
|                                 "id": {"title": "Id", "type": "integer"}, | ||||
|                                 "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): | ||||
|     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { | ||||
|         "description": "", | ||||
|         "parameters": [ | ||||
|             { | ||||
|                 "required": True, | ||||
|                 "in": "path", | ||||
|                 "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", | ||||
|                 "name": "id", | ||||
|                 "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": { | ||||
|             "200": { | ||||
|                 "description": "", | ||||
|                 "content": { | ||||
|                     "application/json": { | ||||
|                         "schema": { | ||||
|                             "properties": { | ||||
|                                 "id": {"title": "Id", "type": "integer"}, | ||||
|                                 "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", | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|                 }, | ||||
|             "404": {"content": {}}, | ||||
|             }, | ||||
|             "404": {"description": "", "content": {}}, | ||||
|         }, | ||||
|     } | ||||
|  | ||||
| @@ -197,7 +319,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | ||||
|                 "in": "path", | ||||
|                 "name": "id", | ||||
|                 "required": True, | ||||
|                 "schema": {"type": "integer"}, | ||||
|                 "schema": {"title": "id", "type": "integer"}, | ||||
|             } | ||||
|         ], | ||||
|         "requestBody": { | ||||
| @@ -207,8 +329,13 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | ||||
|                         "properties": { | ||||
|                             "id": {"title": "Id", "type": "integer"}, | ||||
|                             "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", | ||||
|                     } | ||||
| @@ -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 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): | ||||
|         pass | ||||
|  | ||||
|     assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}) | ||||
|     assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}) | ||||
|     assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}) | ||||
|     assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}) | ||||
|     assert _parse_func_signature(path_and_qs) == ({"id": str}, {}, {"page": int}, {}) | ||||
|     assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {}) | ||||
|     assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {}) | ||||
|     assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {}) | ||||
|     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_header) == ( | ||||
|         {"id": str}, | ||||
|         {}, | ||||
|         {}, | ||||
|         {"auth": UUID}, | ||||
|         {}, | ||||
|     ) | ||||
|     assert _parse_func_signature(qs_and_header) == ( | ||||
|         {}, | ||||
|         {}, | ||||
|         {"page": int}, | ||||
|         {"auth": UUID}, | ||||
|         {}, | ||||
|     ) | ||||
|     assert _parse_func_signature(path_qs_and_header) == ( | ||||
|         {"id": str}, | ||||
|         {}, | ||||
|         {"page": int}, | ||||
|         {"auth": UUID}, | ||||
|         {}, | ||||
|     ) | ||||
|     assert _parse_func_signature(path_body_qs_and_header) == ( | ||||
|         {"id": str}, | ||||
|         {"user": User}, | ||||
|         {"page": int}, | ||||
|         {"auth": UUID}, | ||||
|         {}, | ||||
|     ) | ||||
|   | ||||
| @@ -1,4 +1,6 @@ | ||||
| from typing import Optional | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Iterator, List, Optional | ||||
|  | ||||
| from aiohttp import web | ||||
| from pydantic import BaseModel | ||||
| @@ -11,10 +13,20 @@ class ArticleModel(BaseModel): | ||||
|     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 test_post_an_article_without_required_field_should_return_an_error_message( | ||||
|     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.content_type == "application/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 await resp.json() == [ | ||||
|         { | ||||
|             "in": "body", | ||||
|             "loc": ["nb_page"], | ||||
|             "msg": "value is not a valid 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): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|   | ||||
| @@ -1,9 +1,13 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import json | ||||
| from datetime import datetime | ||||
| from enum import Enum | ||||
|  | ||||
| from aiohttp import web | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
| from aiohttp_pydantic.injectors import Group | ||||
|  | ||||
|  | ||||
| class JSONEncoder(json.JSONEncoder): | ||||
| @@ -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( | ||||
|     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 await resp.json() == [ | ||||
|         { | ||||
|             "in": "headers", | ||||
|             "loc": ["signature_expired"], | ||||
|             "msg": "field required", | ||||
|             "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 await resp.json() == [ | ||||
|         { | ||||
|             "in": "headers", | ||||
|             "loc": ["signature_expired"], | ||||
|             "msg": "invalid datetime format", | ||||
|             "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.content_type == "application/json" | ||||
|     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_pydantic import PydanticView | ||||
| @@ -8,7 +10,7 @@ class ArticleView(PydanticView): | ||||
|         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 | ||||
| ): | ||||
|     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.content_type == "application/json" | ||||
|     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_pydantic import PydanticView | ||||
| from aiohttp_pydantic.injectors import Group | ||||
|  | ||||
|  | ||||
| class ArticleView(PydanticView): | ||||
|     async def get(self, with_comments: bool, age: Optional[int] = None): | ||||
|         return web.json_response({"with_comments": with_comments, "age": age}) | ||||
|     async def get( | ||||
|         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( | ||||
| @@ -22,6 +66,7 @@ async def test_get_article_without_required_qs_should_return_an_error_message( | ||||
|     assert resp.content_type == "application/json" | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             "in": "query string", | ||||
|             "loc": ["with_comments"], | ||||
|             "msg": "field required", | ||||
|             "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 await resp.json() == [ | ||||
|         { | ||||
|             "in": "query string", | ||||
|             "loc": ["with_comments"], | ||||
|             "msg": "value could not be parsed to a boolean", | ||||
|             "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}) | ||||
|     assert resp.status == 200 | ||||
|     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 | ||||
| ): | ||||
|     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) | ||||
|  | ||||
|     resp = await client.get("/article", params={"with_comments": "yes"}) | ||||
|     assert await resp.json() == { | ||||
|         "with_comments": True, | ||||
|         "age": None, | ||||
|         "nb_items": 7, | ||||
|         "tags": [], | ||||
|     } | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|     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