Compare commits
	
		
			1 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2a064a75d9 | 
| @@ -1,73 +0,0 @@ | ||||
| /* | ||||
| 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
									
									
									
									
									
								
							
							
						
						
									
										95
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -1,95 +0,0 @@ | ||||
| --- | ||||
| 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 | ||||
|  | ||||
| ... | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1,11 +1,6 @@ | ||||
| .coverage | ||||
| .idea/ | ||||
| .pypirc | ||||
| .pytest_cache | ||||
| __pycache__ | ||||
| aiohttp_pydantic.egg-info/ | ||||
| build/ | ||||
| coverage.xml | ||||
| dist/ | ||||
| dist_venv/ | ||||
| venv/ | ||||
| @@ -1,12 +0,0 @@ | ||||
| stages: | ||||
|   - package | ||||
|  | ||||
| publish-pypi: | ||||
|   stage: package | ||||
|   image: python:3.11 | ||||
|   script: | ||||
|     - sed -i -e "s/1.12.1/${CI_COMMIT_TAG:1}/g" aiohttp_pydantic/__init__.py | ||||
|     - pip install -U setuptools wheel pip; pip install invoke | ||||
|     - invoke upload --pypi-user ${PYPI_REPO_USER} --pypi-password ${PYPI_REPO_PASSWORD} --pypi-url ${PYPI_REPO_URL} | ||||
|   only: | ||||
|     - tags | ||||
							
								
								
									
										19
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.travis.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| language: python | ||||
| python: | ||||
| - '3.8' | ||||
| script: | ||||
| - pytest tests/ | ||||
| install: | ||||
| - pip install -U setuptools wheel pip | ||||
| - pip install -r test_requirements.txt | ||||
| - pip install . | ||||
| 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' | ||||
							
								
								
									
										271
									
								
								README.rst
									
									
									
									
									
								
							
							
						
						
									
										271
									
								
								README.rst
									
									
									
									
									
								
							| @@ -1,29 +1,6 @@ | ||||
| Aiohttp pydantic - Aiohttp View to validate and parse request | ||||
| ============================================================= | ||||
|  | ||||
| .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg | ||||
|   :target: https://cloud.drone.io/Maillol/aiohttp-pydantic | ||||
|   :alt: Build status for master branch | ||||
|  | ||||
| .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||
|   :target: https://img.shields.io/pypi/v/aiohttp-pydantic | ||||
|   :alt: Latest PyPI package version | ||||
|  | ||||
| .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg | ||||
|   :target: https://codecov.io/gh/Maillol/aiohttp-pydantic | ||||
|   :alt: codecov.io status for master branch | ||||
|  | ||||
| Aiohttp pydantic is an `aiohttp view`_ to easily parse and validate request. | ||||
| You define using the function annotations what your methods for handling HTTP verbs expects and Aiohttp pydantic parses the HTTP request | ||||
| for you, validates the data, and injects that you want as parameters. | ||||
|  | ||||
|  | ||||
| Features: | ||||
|  | ||||
| - Query string, request body, URL path and HTTP headers validation. | ||||
| - Open API Specification generation. | ||||
|  | ||||
|  | ||||
| How to install | ||||
| -------------- | ||||
|  | ||||
| @@ -55,7 +32,7 @@ Example: | ||||
|             return web.json_response({'name': article.name, | ||||
|                                       'number_of_page': article.nb_page}) | ||||
|  | ||||
|         async def get(self, with_comments: bool=False): | ||||
|         async def get(self, with_comments: Optional[bool]): | ||||
|             return web.json_response({'with_comments': with_comments}) | ||||
|  | ||||
|  | ||||
| @@ -69,7 +46,6 @@ Example: | ||||
|     $ curl -X GET http://127.0.0.1:8080/article?with_comments=a | ||||
|     [ | ||||
|       { | ||||
|         "in": "query string", | ||||
|         "loc": [ | ||||
|           "with_comments" | ||||
|         ], | ||||
| @@ -84,7 +60,6 @@ Example: | ||||
|     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' | ||||
|     [ | ||||
|       { | ||||
|         "in": "body", | ||||
|         "loc": [ | ||||
|           "name" | ||||
|         ], | ||||
| @@ -102,7 +77,7 @@ API: | ||||
| Inject Path Parameters | ||||
| ~~~~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| To declare a path parameter, you must declare your argument as a `positional-only parameters`_: | ||||
| To declare a path parameters, you must declare your argument as a `positional-only parameters`_: | ||||
|  | ||||
|  | ||||
| Example: | ||||
| @@ -119,40 +94,22 @@ Example: | ||||
| Inject Query String Parameters | ||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| To declare a query parameter, you must declare your argument as a simple argument: | ||||
| To declare a query parameters, you must declare your argument as simple argument: | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class AccountView(PydanticView): | ||||
|         async def get(self, customer_id: Optional[str] = None): | ||||
|         async def get(self, customer_id: str): | ||||
|             ... | ||||
|  | ||||
|     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 parameter, you must declare your argument as a simple argument annotated with `pydantic Model`_. | ||||
| To declare a body parameters, you must declare your argument as a simple argument annotated with `pydantic Model`_. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
| @@ -171,7 +128,7 @@ To declare a body parameter, you must declare your argument as a simple argument | ||||
| Inject HTTP headers | ||||
| ~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| To declare a HTTP headers parameter, you must declare your argument as a `keyword-only argument`_. | ||||
| To declare a HTTP headers parameters, you must declare your argument as a `keyword-only argument`_. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
| @@ -188,8 +145,8 @@ To declare a HTTP headers parameter, you must declare your argument as a `keywor | ||||
| .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ | ||||
| .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | ||||
|  | ||||
| Add route to generate Open Api Specification (OAS) | ||||
| -------------------------------------------------- | ||||
| Add route to generate Open Api Specification | ||||
| -------------------------------------------- | ||||
|  | ||||
| aiohttp_pydantic provides a sub-application to serve a route to generate Open Api Specification | ||||
| reading annotation in your PydanticView. Use *aiohttp_pydantic.oas.setup()* to add the sub-application | ||||
| @@ -211,8 +168,8 @@ By default, the route to display the Open Api Specification is /oas but you can | ||||
|  | ||||
|     oas.setup(app, url_prefix='/spec-api') | ||||
|  | ||||
| If you want generate the Open Api Specification from specific aiohttp sub-applications. | ||||
| on the same route, you must use *apps_to_expose* parameter. | ||||
| If you want generate the Open Api Specification from several aiohttp sub-application. | ||||
| on the same route, you must use *apps_to_expose* parameters | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
| @@ -222,186 +179,11 @@ on the same route, you must use *apps_to_expose* parameter. | ||||
|  | ||||
|     app = web.Application() | ||||
|     sub_app_1 = web.Application() | ||||
|     sub_app_2 = web.Application() | ||||
|  | ||||
|     oas.setup(app, apps_to_expose=[sub_app_1, sub_app_2]) | ||||
|  | ||||
|  | ||||
| You can change the title or the version of the generated open api specification using | ||||
| *title_spec* and *version_spec* parameters: | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     oas.setup(app, title_spec="My application", version_spec="1.2.3") | ||||
|  | ||||
|  | ||||
| Add annotation to define response content | ||||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||||
|  | ||||
| The module aiohttp_pydantic.oas.typing provides class to annotate a | ||||
| response content. | ||||
|  | ||||
| For example *r200[List[Pet]]* means the server responses with | ||||
| the status code 200 and the response content is a List of Pet where Pet will be | ||||
| defined using a pydantic.BaseModel | ||||
|  | ||||
| The docstring of methods will be parsed to fill the descriptions in the | ||||
| Open Api Specification. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     from aiohttp_pydantic import PydanticView | ||||
|     from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||
|  | ||||
|  | ||||
|     class Pet(BaseModel): | ||||
|         id: int | ||||
|         name: str | ||||
|  | ||||
|  | ||||
|     class Error(BaseModel): | ||||
|         error: str | ||||
|  | ||||
|  | ||||
|     class PetCollectionView(PydanticView): | ||||
|         async def get(self) -> r200[List[Pet]]: | ||||
|             """ | ||||
|             Find all pets | ||||
|  | ||||
|             Tags: pet | ||||
|             """ | ||||
|             pets = self.request.app["model"].list_pets() | ||||
|             return web.json_response([pet.dict() for pet in pets]) | ||||
|  | ||||
|         async def post(self, pet: Pet) -> r201[Pet]: | ||||
|             """ | ||||
|             Add a new pet to the store | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 201: The pet is created | ||||
|             """ | ||||
|             self.request.app["model"].add_pet(pet) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
|  | ||||
|     class PetItemView(PydanticView): | ||||
|         async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: | ||||
|             """ | ||||
|             Find a pet by ID | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 200: Successful operation | ||||
|                 404: Pet not found | ||||
|             """ | ||||
|             pet = self.request.app["model"].find_pet(id) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
|         async def put(self, id: int, /, pet: Pet) -> r200[Pet]: | ||||
|             """ | ||||
|             Update an existing pet | ||||
|  | ||||
|             Tags: pet | ||||
|             Status Codes: | ||||
|                 200: successful operation | ||||
|             """ | ||||
|             self.request.app["model"].update_pet(id, pet) | ||||
|             return web.json_response(pet.dict()) | ||||
|  | ||||
|         async def delete(self, id: int, /) -> r204: | ||||
|             self.request.app["model"].remove_pet(id) | ||||
|             return web.Response(status=204) | ||||
|  | ||||
|  | ||||
| Group parameters | ||||
| ---------------- | ||||
|  | ||||
| If your method has lot of parameters you can group them together inside one or several Groups. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     from aiohttp_pydantic.injectors import Group | ||||
|  | ||||
|     class Pagination(Group): | ||||
|         page_num: int = 1 | ||||
|         page_size: int = 15 | ||||
|  | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page: Pagination): | ||||
|             articles = Article.get(page.page_num, page.page_size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| The parameters page_num and page_size are expected in the query string, and | ||||
| set inside a Pagination object passed as page parameter. | ||||
|  | ||||
| The code above is equivalent to: | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page_num: int = 1, page_size: int = 15): | ||||
|             articles = Article.get(page_num, page_size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| You can add methods or properties to your Group. | ||||
|  | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class Pagination(Group): | ||||
|         page_num: int = 1 | ||||
|         page_size: int = 15 | ||||
|  | ||||
|         @property | ||||
|         def num(self): | ||||
|             return self.page_num | ||||
|  | ||||
|         @property | ||||
|         def size(self): | ||||
|             return self.page_size | ||||
|  | ||||
|         def slice(self): | ||||
|             return slice(self.num, self.size) | ||||
|  | ||||
|  | ||||
|     class ArticleView(PydanticView): | ||||
|  | ||||
|         async def get(self, page: Pagination): | ||||
|             articles = Article.get(page.num, page.size) | ||||
|             ... | ||||
|  | ||||
|  | ||||
| Custom Validation error | ||||
| ----------------------- | ||||
|  | ||||
| You can redefine the on_validation_error hook in your PydanticView | ||||
|  | ||||
| .. code-block:: python3 | ||||
|  | ||||
|     class PetView(PydanticView): | ||||
|  | ||||
|         async def on_validation_error(self, | ||||
|                                       exception: ValidationError, | ||||
|                                       context: str): | ||||
|             errors = exception.errors() | ||||
|             for error in errors: | ||||
|                 error["in"] = context  # context is "body", "headers", "path" or "query string" | ||||
|                 error["custom"] = "your custom field ..." | ||||
|             return json_response(data=errors, status=400) | ||||
|  | ||||
|     oas.setup(app, apps_to_expose=[app, sub_app_1]) | ||||
|  | ||||
| Demo | ||||
| ---- | ||||
| ==== | ||||
|  | ||||
| Have a look at `demo`_ for a complete example | ||||
|  | ||||
| @@ -414,35 +196,6 @@ 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 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.12.1" | ||||
| __version__ = "1.1.0" | ||||
|  | ||||
| __all__ = ("PydanticView", "__version__") | ||||
| @@ -1,18 +1,11 @@ | ||||
| import abc | ||||
| import typing | ||||
| from inspect import signature, getmro | ||||
| from json.decoder import JSONDecodeError | ||||
| from types import SimpleNamespace | ||||
| from typing import Callable, Tuple, Literal, Type, get_type_hints | ||||
| from typing import Callable, Tuple | ||||
|  | ||||
| from aiohttp.web_exceptions import HTTPBadRequest | ||||
| from aiohttp.web_request import BaseRequest | ||||
| from multidict import MultiDict | ||||
| from pydantic import BaseModel | ||||
| from inspect import signature | ||||
|  | ||||
| from .utils import is_pydantic_base_model, robuste_issubclass | ||||
|  | ||||
| CONTEXT = Literal["body", "headers", "path", "query string"] | ||||
| import abc | ||||
|  | ||||
|  | ||||
| class AbstractInjector(metaclass=abc.ABCMeta): | ||||
| @@ -20,18 +13,8 @@ class AbstractInjector(metaclass=abc.ABCMeta): | ||||
|     An injector parse HTTP request and inject params to the view. | ||||
|     """ | ||||
|  | ||||
|     model: Type[BaseModel] | ||||
|  | ||||
|     @property | ||||
|     @abc.abstractmethod | ||||
|     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): | ||||
|     def __init__(self, args_spec: dict): | ||||
|         """ | ||||
|         args_spec - ordered mapping: arg_name -> type | ||||
|         """ | ||||
| @@ -48,12 +31,8 @@ class MatchInfoGetter(AbstractInjector): | ||||
|     Validates and injects the part of URL path inside the view positional args. | ||||
|     """ | ||||
|  | ||||
|     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 __init__(self, args_spec: dict): | ||||
|         self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|  | ||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||
|         args_view.extend(self.model(**request.match_info).dict().values()) | ||||
| @@ -64,33 +43,12 @@ class BodyGetter(AbstractInjector): | ||||
|     Validates and injects the content of request body inside the view kwargs. | ||||
|     """ | ||||
|  | ||||
|     context = "body" | ||||
|  | ||||
|     def __init__(self, args_spec: dict, default_values: dict): | ||||
|     def __init__(self, args_spec: dict): | ||||
|         self.arg_name, self.model = next(iter(args_spec.items())) | ||||
|         schema = self.model.model_json_schema() | ||||
|         if "type" not in schema: | ||||
|             schema["type"] = "object" | ||||
|         self._expect_object = schema["type"] == "object" | ||||
|  | ||||
|     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||
|         try: | ||||
|         body = await request.json() | ||||
|         except JSONDecodeError: | ||||
|             raise HTTPBadRequest( | ||||
|                 text='{"error": "Malformed JSON"}', content_type="application/json" | ||||
|             ) from None | ||||
|  | ||||
|         # Pydantic tries to cast certain structures, such as a list of 2-tuples, | ||||
|         # to a dict. Prevent this by requiring the body to be a dict for object models. | ||||
|         if self._expect_object and not isinstance(body, dict): | ||||
|             raise HTTPBadRequest( | ||||
|                 text='[{"loc_in": "body", "loc": ["root"], "msg": "value is not a ' | ||||
|                 'valid dict", "type": "type_error.dict"}]', | ||||
|                 content_type="application/json", | ||||
|             ) from None | ||||
|  | ||||
|         kwargs_view[self.arg_name] = self.model.parse_obj(body) | ||||
|         kwargs_view[self.arg_name] = self.model(**body) | ||||
|  | ||||
|  | ||||
| class QueryGetter(AbstractInjector): | ||||
| @@ -98,49 +56,11 @@ class QueryGetter(AbstractInjector): | ||||
|     Validates and injects the query string inside the view kwargs. | ||||
|     """ | ||||
|  | ||||
|     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 __init__(self, args_spec: dict): | ||||
|         self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|  | ||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: 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() | ||||
|         } | ||||
|         kwargs_view.update(self.model(**request.query).dict()) | ||||
|  | ||||
|  | ||||
| class HeadersGetter(AbstractInjector): | ||||
| @@ -148,162 +68,45 @@ class HeadersGetter(AbstractInjector): | ||||
|     Validates and injects the HTTP headers inside the view kwargs. | ||||
|     """ | ||||
|  | ||||
|     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 __init__(self, args_spec: dict): | ||||
|         self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec}) | ||||
|  | ||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||
|         header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} | ||||
|         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) | ||||
|         kwargs_view.update(self.model(**header).dict()) | ||||
|  | ||||
|  | ||||
| class Group(SimpleNamespace): | ||||
| def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]: | ||||
|     """ | ||||
|     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: | ||||
|     Analyse function signature and returns 4-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] = annotation | ||||
|  | ||||
|             path_args[param_name] = param_spec.annotation | ||||
|         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: | ||||
|             if is_pydantic_base_model(annotation): | ||||
|                 body_args[param_name] = annotation | ||||
|             if issubclass(param_spec.annotation, BaseModel): | ||||
|                 body_args[param_name] = param_spec.annotation | ||||
|             else: | ||||
|                 qs_args[param_name] = annotation | ||||
|                 qs_args[param_name] = param_spec.annotation | ||||
|         elif param_spec.kind is param_spec.KEYWORD_ONLY: | ||||
|             header_args[param_name] = annotation | ||||
|             header_args[param_name] = param_spec.annotation | ||||
|         else: | ||||
|             raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") | ||||
|  | ||||
|     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) | ||||
|     return path_args, body_args, qs_args, header_args | ||||
|   | ||||
| @@ -1,11 +1,10 @@ | ||||
| from typing import Iterable | ||||
| from importlib import resources | ||||
| from typing import Iterable, Optional | ||||
|  | ||||
| import jinja2 | ||||
| from aiohttp import web | ||||
| from swagger_ui_bundle import swagger_ui_path | ||||
|  | ||||
| from .view import get_oas, oas_ui | ||||
| from swagger_ui_bundle import swagger_ui_path | ||||
|  | ||||
|  | ||||
| def setup( | ||||
| @@ -13,19 +12,13 @@ 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, | ||||
|     custom_template: Optional[jinja2.Template] = None | ||||
| ): | ||||
|     if enable: | ||||
|         oas_app = web.Application() | ||||
|         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) | ||||
|         oas_app["index template"] = custom_template or jinja2.Template( | ||||
|         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,8 +0,0 @@ | ||||
| import argparse | ||||
|  | ||||
| from .cmd import setup | ||||
|  | ||||
| parser = argparse.ArgumentParser(description="Generate Open API Specification") | ||||
| setup(parser) | ||||
| args = parser.parse_args() | ||||
| args.func(args) | ||||
| @@ -1,133 +0,0 @@ | ||||
| import argparse | ||||
| import importlib | ||||
| import json | ||||
| from typing import Dict, Protocol, Optional, Callable | ||||
| import sys | ||||
| from .view import generate_oas | ||||
|  | ||||
|  | ||||
| class YamlModule(Protocol): | ||||
|     """ | ||||
|     Yaml Module type hint | ||||
|     """ | ||||
|  | ||||
|     def dump(self, data) -> str: | ||||
|         pass | ||||
|  | ||||
|  | ||||
| yaml: Optional[YamlModule] | ||||
|  | ||||
| try: | ||||
|     import yaml | ||||
| except ImportError: | ||||
|     yaml = None | ||||
|  | ||||
|  | ||||
| def application_type(value): | ||||
|     """ | ||||
|     Return aiohttp application defined in the value. | ||||
|     """ | ||||
|     try: | ||||
|         module_name, app_name = value.split(":") | ||||
|     except ValueError: | ||||
|         module_name, app_name = value, "app" | ||||
|  | ||||
|     module = importlib.import_module(module_name) | ||||
|     try: | ||||
|         if app_name.endswith("()"): | ||||
|             app_name = app_name.strip("()") | ||||
|             factory_app = getattr(module, app_name) | ||||
|             return factory_app() | ||||
|         return getattr(module, app_name) | ||||
|  | ||||
|     except AttributeError as error: | ||||
|         raise argparse.ArgumentTypeError(error) from error | ||||
|  | ||||
|  | ||||
| def base_oas_file_type(value) -> Dict: | ||||
|     """ | ||||
|     Load base oas file | ||||
|     """ | ||||
|     try: | ||||
|         with open(value) as oas_file: | ||||
|             data = oas_file.read() | ||||
|     except OSError as error: | ||||
|         raise argparse.ArgumentTypeError(error) from error | ||||
|  | ||||
|     return json.loads(data) | ||||
|  | ||||
|  | ||||
| def format_type(value) -> Callable: | ||||
|     """ | ||||
|     Date Dumper one of (json, yaml) | ||||
|     """ | ||||
|     dumpers = {"json": lambda data: json.dumps(data, sort_keys=True, indent=4)} | ||||
|     if yaml is not None: | ||||
|         dumpers["yaml"] = yaml.dump | ||||
|  | ||||
|     try: | ||||
|         return dumpers[value] | ||||
|     except KeyError: | ||||
|         raise argparse.ArgumentTypeError( | ||||
|             f"Wrong format value. (allowed values: {tuple(dumpers.keys())})" | ||||
|         ) from None | ||||
|  | ||||
|  | ||||
| def setup(parser: argparse.ArgumentParser): | ||||
|     parser.add_argument( | ||||
|         "apps", | ||||
|         metavar="APP", | ||||
|         type=application_type, | ||||
|         nargs="*", | ||||
|         help="The name of the module containing the asyncio.web.Application." | ||||
|         " By default the variable named 'app' is loaded but you can define" | ||||
|         " an other variable name ending the name of module with : characters" | ||||
|         " and the name of variable. Example: my_package.my_module:my_app" | ||||
|         " If your asyncio.web.Application is returned by a function, you can" | ||||
|         " use the syntax: my_package.my_module:my_app()", | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-b", | ||||
|         "--base-oas-file", | ||||
|         metavar="FILE", | ||||
|         dest="base", | ||||
|         type=base_oas_file_type, | ||||
|         help="A file that will be used as base to generate OAS", | ||||
|         default={}, | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "-o", | ||||
|         "--output", | ||||
|         metavar="FILE", | ||||
|         type=argparse.FileType("w"), | ||||
|         help="File to write the output", | ||||
|         default=sys.stdout, | ||||
|     ) | ||||
|  | ||||
|     if yaml: | ||||
|         help_output_format = ( | ||||
|             "The output format, can be 'json' or 'yaml' (default is json)" | ||||
|         ) | ||||
|     else: | ||||
|         help_output_format = "The output format, only 'json' is available install pyyaml to have yaml output format" | ||||
|  | ||||
|     parser.add_argument( | ||||
|         "-f", | ||||
|         "--format", | ||||
|         metavar="FORMAT", | ||||
|         dest="formatter", | ||||
|         type=format_type, | ||||
|         help=help_output_format, | ||||
|         default=format_type("json"), | ||||
|     ) | ||||
|  | ||||
|     parser.set_defaults(func=show_oas) | ||||
|  | ||||
|  | ||||
| def show_oas(args: argparse.Namespace): | ||||
|     """ | ||||
|     Display Open API Specification on the stdout. | ||||
|     """ | ||||
|     spec = args.base | ||||
|     spec.update(generate_oas(args.apps)) | ||||
|     print(args.formatter(spec), file=args.output) | ||||
| @@ -1,149 +0,0 @@ | ||||
| """ | ||||
| Utility to extract extra OAS description from docstring. | ||||
| """ | ||||
|  | ||||
| import re | ||||
| import textwrap | ||||
| from typing import Dict, List | ||||
|  | ||||
|  | ||||
| class LinesIterator: | ||||
|     def __init__(self, lines: str): | ||||
|         self._lines = lines.splitlines() | ||||
|         self._i = -1 | ||||
|  | ||||
|     def next_line(self) -> str: | ||||
|         if self._i == len(self._lines) - 1: | ||||
|             raise StopIteration from None | ||||
|         self._i += 1 | ||||
|         return self._lines[self._i] | ||||
|  | ||||
|     def rewind(self) -> str: | ||||
|         if self._i == -1: | ||||
|             raise StopIteration from None | ||||
|         self._i -= 1 | ||||
|         return self._lines[self._i] | ||||
|  | ||||
|     def __iter__(self): | ||||
|         return self | ||||
|  | ||||
|     def __next__(self): | ||||
|         return self.next_line() | ||||
|  | ||||
|  | ||||
| def _i_extract_block(lines: LinesIterator): | ||||
|     """ | ||||
|     Iter the line within an indented block and dedent them. | ||||
|     """ | ||||
|  | ||||
|     # Go to the first not empty or not white space line. | ||||
|     try: | ||||
|         line = next(lines) | ||||
|     except StopIteration: | ||||
|         return  # No block to extract. | ||||
|     while line.strip() == "": | ||||
|         try: | ||||
|             line = next(lines) | ||||
|         except StopIteration: | ||||
|             return | ||||
|  | ||||
|     indent = re.fullmatch("( *).*", line).groups()[0] | ||||
|     indentation = len(indent) | ||||
|     start_of_other_block = re.compile(f" {{0,{indentation}}}[^ ].*") | ||||
|     yield line[indentation:] | ||||
|  | ||||
|     # Yield lines until the indentation is the same or is greater than | ||||
|     # the first block line. | ||||
|     try: | ||||
|         line = next(lines) | ||||
|     except StopIteration: | ||||
|         return | ||||
|     while not start_of_other_block.fullmatch(line): | ||||
|         yield line[indentation:] | ||||
|         try: | ||||
|             line = next(lines) | ||||
|         except StopIteration: | ||||
|             return | ||||
|  | ||||
|     lines.rewind() | ||||
|  | ||||
|  | ||||
| def _dedent_under_first_line(text: str) -> str: | ||||
|     """ | ||||
|     Apply textwrap.dedent ignoring the first line. | ||||
|     """ | ||||
|     lines = text.splitlines() | ||||
|     other_lines = "\n".join(lines[1:]) | ||||
|     if other_lines: | ||||
|         return f"{lines[0]}\n{textwrap.dedent(other_lines)}" | ||||
|     return text | ||||
|  | ||||
|  | ||||
| def status_code(docstring: str) -> Dict[int, str]: | ||||
|     """ | ||||
|     Extract the "Status Code:" block of the docstring. | ||||
|     """ | ||||
|     iterator = LinesIterator(docstring) | ||||
|     for line in iterator: | ||||
|         if re.fullmatch("status\\s+codes?\\s*:", line, re.IGNORECASE): | ||||
|             iterator.rewind() | ||||
|             blocks = [] | ||||
|             lines = [] | ||||
|             i_block = _i_extract_block(iterator) | ||||
|             next(i_block) | ||||
|             for line_of_block in i_block: | ||||
|                 if re.search("^\\s*\\d{3}\\s*:", line_of_block): | ||||
|                     if lines: | ||||
|                         blocks.append("\n".join(lines)) | ||||
|                         lines = [] | ||||
|                 lines.append(line_of_block) | ||||
|             if lines: | ||||
|                 blocks.append("\n".join(lines)) | ||||
|  | ||||
|             return { | ||||
|                 int(status.strip()): _dedent_under_first_line(desc.strip()) | ||||
|                 for status, desc in (block.split(":", 1) for block in blocks) | ||||
|             } | ||||
|     return {} | ||||
|  | ||||
|  | ||||
| def tags(docstring: str) -> List[str]: | ||||
|     """ | ||||
|     Extract the "Tags:" block of the docstring. | ||||
|     """ | ||||
|     iterator = LinesIterator(docstring) | ||||
|     for line in iterator: | ||||
|         if re.fullmatch("tags\\s*:.*", line, re.IGNORECASE): | ||||
|             iterator.rewind() | ||||
|             lines = " ".join(_i_extract_block(iterator)) | ||||
|             return [" ".join(e.split()) for e in re.split("[,;]", lines.split(":")[1])] | ||||
|     return [] | ||||
|  | ||||
|  | ||||
| def operation_id(docstring: str) -> str | None: | ||||
|     """ | ||||
|     Extract the "OperationId:" block of the docstring. | ||||
|     """ | ||||
|     iterator = LinesIterator(docstring) | ||||
|     for line in iterator: | ||||
|         if re.fullmatch("operation_?id\\s*:.*", line, re.IGNORECASE): | ||||
|             iterator.rewind() | ||||
|             return line.split(":")[1].strip(' ') | ||||
|  | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def operation(docstring: str) -> str: | ||||
|     """ | ||||
|     Extract all docstring except the "Status Code:" block. | ||||
|     """ | ||||
|     lines = LinesIterator(docstring) | ||||
|     ret = [] | ||||
|     for line in lines: | ||||
|         if re.fullmatch("status\\s+codes?\\s*:|tags\\s*:.*|operation_?id\\s*:.*", line, re.IGNORECASE): | ||||
|             lines.rewind() | ||||
|             for _ in _i_extract_block(lines): | ||||
|                 pass | ||||
|         else: | ||||
|             ret.append(line) | ||||
|     return ("\n".join(ret)).strip() | ||||
| @@ -1,27 +1,45 @@ | ||||
| {# This updated file is part of swagger_ui_bundle (https://github.com/bartsanchez/swagger_ui_bundle) #} | ||||
| {# This updated file is part of swagger_ui_bundle (https://github.com/dtkav/swagger_ui_bundle) #} | ||||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <title>{{ title | default('Swagger UI') }}</title> | ||||
|     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" /> | ||||
|     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/index.css" /> | ||||
|     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/swagger-ui.css" > | ||||
|     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" /> | ||||
|     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" /> | ||||
|     <style> | ||||
|       html | ||||
|       { | ||||
|         box-sizing: border-box; | ||||
|         overflow: -moz-scrollbars-vertical; | ||||
|         overflow-y: scroll; | ||||
|       } | ||||
|  | ||||
|       *, | ||||
|       *:before, | ||||
|       *:after | ||||
|       { | ||||
|         box-sizing: inherit; | ||||
|       } | ||||
|  | ||||
|       body | ||||
|       { | ||||
|         margin:0; | ||||
|         background: #fafafa; | ||||
|       } | ||||
|     </style> | ||||
|   </head> | ||||
|  | ||||
|   <body> | ||||
|     <div id="swagger-ui"></div> | ||||
|     <script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js" charset="UTF-8"> </script> | ||||
|     <script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js" charset="UTF-8"> </script> | ||||
|  | ||||
|     <script src="{{ static_url | trim('/') }}/swagger-ui-bundle.js"> </script> | ||||
|     <script src="{{ static_url | trim('/') }}/swagger-ui-standalone-preset.js"> </script> | ||||
|     <script> | ||||
|     window.onload = function() { | ||||
|       // Begin Swagger UI call region | ||||
|       const ui = SwaggerUIBundle({ | ||||
|         url: "{{ openapi_spec_url }}", | ||||
|         {% if urls is defined %} | ||||
|         urls: {{ urls|tojson|safe }}, | ||||
|         {% endif %} | ||||
|         validatorUrl: {{ validatorUrl | default('null') }}, | ||||
|         {% if configUrl is defined %} | ||||
|         configUrl: "{{ configUrl }}", | ||||
| @@ -36,15 +54,16 @@ | ||||
|           SwaggerUIBundle.plugins.DownloadUrl | ||||
|         ], | ||||
|         layout: "StandaloneLayout" | ||||
|       }); | ||||
|       }) | ||||
|       {% if initOAuth is defined %} | ||||
|       ui.initOAuth( | ||||
|         {{ initOAuth|tojson|safe }} | ||||
|       ) | ||||
|       {% endif %} | ||||
|       // End Swagger UI call region | ||||
|       window.ui = ui; | ||||
|     }; | ||||
|  | ||||
|       window.ui = ui | ||||
|     } | ||||
|   </script> | ||||
|   </body> | ||||
| </html> | ||||
| @@ -1,17 +1,10 @@ | ||||
| """ | ||||
| Utility to write Open Api Specifications using the Python language. | ||||
| """ | ||||
|  | ||||
| from typing import Union, List | ||||
|  | ||||
|  | ||||
| class Info: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec.setdefault("info", {}) | ||||
|  | ||||
|     @property | ||||
|     def title(self): | ||||
|         return self._spec.get("title") | ||||
|         return self._spec["title"] | ||||
|  | ||||
|     @title.setter | ||||
|     def title(self, title): | ||||
| @@ -19,7 +12,7 @@ class Info: | ||||
|  | ||||
|     @property | ||||
|     def description(self): | ||||
|         return self._spec.get("description") | ||||
|         return self._spec["description"] | ||||
|  | ||||
|     @description.setter | ||||
|     def description(self, description): | ||||
| @@ -27,20 +20,12 @@ class Info: | ||||
|  | ||||
|     @property | ||||
|     def version(self): | ||||
|         return self._spec.get("version") | ||||
|         return self._spec["version"] | ||||
|  | ||||
|     @version.setter | ||||
|     def version(self, version): | ||||
|         self._spec["version"] = version | ||||
|  | ||||
|     @property | ||||
|     def terms_of_service(self): | ||||
|         return self._spec.get("termsOfService") | ||||
|  | ||||
|     @terms_of_service.setter | ||||
|     def terms_of_service(self, terms_of_service): | ||||
|         self._spec["termsOfService"] = terms_of_service | ||||
|  | ||||
|  | ||||
| class RequestBody: | ||||
|     def __init__(self, spec: dict): | ||||
| @@ -55,8 +40,8 @@ class RequestBody: | ||||
|         self._spec["description"] = description | ||||
|  | ||||
|     @property | ||||
|     def required(self) -> bool: | ||||
|         return self._spec.get("required", False) | ||||
|     def required(self): | ||||
|         return self._spec["required"] | ||||
|  | ||||
|     @required.setter | ||||
|     def required(self, required: bool): | ||||
| @@ -130,40 +115,6 @@ class Parameters: | ||||
|         return Parameter(spec) | ||||
|  | ||||
|  | ||||
| class Response: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec | ||||
|         self._spec.setdefault("description", "") | ||||
|  | ||||
|     @property | ||||
|     def description(self) -> str: | ||||
|         return self._spec["description"] | ||||
|  | ||||
|     @description.setter | ||||
|     def description(self, description: str): | ||||
|         self._spec["description"] = description | ||||
|  | ||||
|     @property | ||||
|     def content(self): | ||||
|         return self._spec["content"] | ||||
|  | ||||
|     @content.setter | ||||
|     def content(self, content: dict): | ||||
|         self._spec["content"] = content | ||||
|  | ||||
|  | ||||
| class Responses: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec.setdefault("responses", {}) | ||||
|  | ||||
|     def __getitem__(self, status_code: Union[int, str]) -> Response: | ||||
|         if not 100 <= int(status_code) < 600: | ||||
|             raise ValueError("status_code must be between 100 and 599") | ||||
|  | ||||
|         spec = self._spec.setdefault(str(status_code), {}) | ||||
|         return Response(spec) | ||||
|  | ||||
|  | ||||
| class OperationObject: | ||||
|     def __init__(self, spec: dict): | ||||
|         self._spec = spec | ||||
| @@ -192,32 +143,6 @@ class OperationObject: | ||||
|     def parameters(self) -> Parameters: | ||||
|         return Parameters(self._spec) | ||||
|  | ||||
|     @property | ||||
|     def responses(self) -> Responses: | ||||
|         return Responses(self._spec) | ||||
|  | ||||
|     @property | ||||
|     def tags(self) -> List[str]: | ||||
|         return self._spec.get("tags", [])[:] | ||||
|  | ||||
|     @tags.setter | ||||
|     def tags(self, tags: List[str]): | ||||
|         if tags: | ||||
|             self._spec["tags"] = tags[:] | ||||
|         else: | ||||
|             self._spec.pop("tags", None) | ||||
|  | ||||
|     @property | ||||
|     def operation_id(self) -> str | None: | ||||
|         return self._spec.get("operationId", None) | ||||
|  | ||||
|     @operation_id.setter | ||||
|     def operation_id(self, operation_id: str | None) -> None: | ||||
|         if operation_id: | ||||
|             self._spec["operationId"] = operation_id | ||||
|         else: | ||||
|             self._spec.pop("operationId", None) | ||||
|  | ||||
|  | ||||
| class PathItem: | ||||
|     def __init__(self, spec: dict): | ||||
| @@ -255,22 +180,6 @@ class PathItem: | ||||
|     def trace(self) -> OperationObject: | ||||
|         return OperationObject(self._spec.setdefault("trace", {})) | ||||
|  | ||||
|     @property | ||||
|     def description(self) -> str: | ||||
|         return self._spec["description"] | ||||
|  | ||||
|     @description.setter | ||||
|     def description(self, description: str): | ||||
|         self._spec["description"] = description | ||||
|  | ||||
|     @property | ||||
|     def summary(self) -> str: | ||||
|         return self._spec["summary"] | ||||
|  | ||||
|     @summary.setter | ||||
|     def summary(self, summary: str): | ||||
|         self._spec["summary"] = summary | ||||
|  | ||||
|  | ||||
| class Paths: | ||||
|     def __init__(self, spec: dict): | ||||
| @@ -295,7 +204,7 @@ class Server: | ||||
|  | ||||
|     @property | ||||
|     def description(self) -> str: | ||||
|         return self._spec["description"] | ||||
|         return self._spec["url"] | ||||
|  | ||||
|     @description.setter | ||||
|     def description(self, description: str): | ||||
| @@ -316,21 +225,9 @@ 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", | ||||
|             "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, | ||||
|         } | ||||
|         self._spec = {"openapi": "3.0.0"} | ||||
|  | ||||
|     @property | ||||
|     def info(self) -> Info: | ||||
| @@ -344,10 +241,6 @@ 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 | ||||
|   | ||||
| @@ -1,48 +0,0 @@ | ||||
| """ | ||||
| This module provides type to annotate the content of web.Response returned by | ||||
| the HTTP handlers. | ||||
|  | ||||
| The type are: r100, r101, ..., r599 | ||||
|  | ||||
| Example: | ||||
|  | ||||
|     class PetCollectionView(PydanticView): | ||||
|         async def get(self) -> Union[r200[List[Pet]], r404]: | ||||
|             ... | ||||
| """ | ||||
|  | ||||
| from functools import lru_cache | ||||
| from types import new_class | ||||
| from typing import Protocol, TypeVar | ||||
|  | ||||
| RespContents = TypeVar("RespContents", covariant=True) | ||||
|  | ||||
| _status_code = frozenset(f"r{code}" for code in range(100, 600)) | ||||
|  | ||||
|  | ||||
| @lru_cache(maxsize=len(_status_code)) | ||||
| def _make_status_code_type(status_code): | ||||
|     if status_code in _status_code: | ||||
|         return new_class(status_code, (Protocol[RespContents],)) | ||||
|     return None | ||||
|  | ||||
|  | ||||
| def is_status_code_type(obj) -> bool: | ||||
|     """ | ||||
|     Return True if obj is a status code type such as _200 or _404. | ||||
|     """ | ||||
|     name = getattr(obj, "__name__", None) | ||||
|     if name not in _status_code: | ||||
|         return False | ||||
|  | ||||
|     return obj is _make_status_code_type(name) | ||||
|  | ||||
|  | ||||
| def __getattr__(name): | ||||
|     if (status_code_type := _make_status_code_type(name)) is None: | ||||
|         raise AttributeError(f"module {__name__!r} has no attribute {name!r}") | ||||
|     return status_code_type | ||||
|  | ||||
|  | ||||
| __all__ = list(_status_code) | ||||
| __all__.append("is_status_code_type") | ||||
| @@ -1,186 +1,66 @@ | ||||
| import typing | ||||
| from inspect import getdoc | ||||
| from itertools import count | ||||
| from typing import List, Type, Optional, get_type_hints | ||||
|  | ||||
| from aiohttp.web import Response, json_response | ||||
| from aiohttp.web_app import Application | ||||
| from pydantic import BaseModel, RootModel | ||||
| from aiohttp.web import json_response, Response | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | ||||
| from . import docstring_parser | ||||
| from typing import Type | ||||
|  | ||||
| from ..injectors import _parse_func_signature | ||||
| from ..utils import is_pydantic_base_model | ||||
| from ..view import PydanticView, is_pydantic_view | ||||
| from .typing import is_status_code_type | ||||
|  | ||||
|  | ||||
| class _OASResponseBuilder: | ||||
|     """ | ||||
|     Parse the type annotated as returned by a function and | ||||
|     generate the OAS operation response. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): | ||||
|         self._oas_operation = oas_operation | ||||
|         self._oas = oas | ||||
|         self._status_code_descriptions = status_code_descriptions | ||||
|  | ||||
|     def _handle_pydantic_base_model(self, obj): | ||||
|         if is_pydantic_base_model(obj): | ||||
|             response_schema = obj.schema( | ||||
|                 ref_template="#/components/schemas/{model}" | ||||
|             ).copy() | ||||
|             if def_sub_schemas := response_schema.pop("$defs", None): | ||||
|                 self._oas.components.schemas.update(def_sub_schemas) | ||||
|             self._oas.components.schemas.update({response_schema['title']: response_schema}) | ||||
|             return {'$ref': f'#/components/schemas/{response_schema["title"]}'} | ||||
|         return {} | ||||
|  | ||||
|     def _handle_list(self, obj): | ||||
|         if typing.get_origin(obj) is list: | ||||
|             return { | ||||
|                 "type": "array", | ||||
|                 "items": self._handle_pydantic_base_model(typing.get_args(obj)[0]), | ||||
|             } | ||||
|         return self._handle_pydantic_base_model(obj) | ||||
|  | ||||
|     def _handle_status_code_type(self, obj): | ||||
|         if is_status_code_type(typing.get_origin(obj)): | ||||
|             status_code = typing.get_origin(obj).__name__[1:] | ||||
|             self._oas_operation.responses[status_code].content = { | ||||
|                 "application/json": { | ||||
|                     "schema": self._handle_list(typing.get_args(obj)[0]) | ||||
|                 } | ||||
|             } | ||||
|             desc = self._status_code_descriptions.get(int(status_code)) | ||||
|             if desc: | ||||
|                 self._oas_operation.responses[status_code].description = desc | ||||
|  | ||||
|         elif is_status_code_type(obj): | ||||
|             status_code = obj.__name__[1:] | ||||
|             self._oas_operation.responses[status_code].content = {} | ||||
|             desc = self._status_code_descriptions.get(int(status_code)) | ||||
|             if desc: | ||||
|                 self._oas_operation.responses[status_code].description = desc | ||||
|  | ||||
|     def _handle_union(self, obj): | ||||
|         if typing.get_origin(obj) is typing.Union: | ||||
|             for arg in typing.get_args(obj): | ||||
|                 self._handle_status_code_type(arg) | ||||
|         self._handle_status_code_type(obj) | ||||
|  | ||||
|     def build(self, obj): | ||||
|         self._handle_union(obj) | ||||
| JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"} | ||||
|  | ||||
|  | ||||
| def _add_http_method_to_oas( | ||||
|     oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView] | ||||
| ): | ||||
|     http_method = http_method.lower() | ||||
|     oas_operation: OperationObject = getattr(oas_path, http_method) | ||||
|     handler = getattr(view, http_method) | ||||
|     path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( | ||||
|         handler, unpack_group=True | ||||
|     ) | ||||
|     description = getdoc(handler) | ||||
|     if description: | ||||
|         oas_operation.description = docstring_parser.operation(description) | ||||
|         oas_operation.tags = docstring_parser.tags(description) | ||||
|         oas_operation.operation_id = docstring_parser.operation_id(description) | ||||
|         status_code_descriptions = docstring_parser.status_code(description) | ||||
|     else: | ||||
|         status_code_descriptions = {} | ||||
| def _add_http_method_to_oas(oas_path: PathItem, method: str, view: Type[PydanticView]): | ||||
|     method = method.lower() | ||||
|     mtd: OperationObject = getattr(oas_path, method) | ||||
|     handler = getattr(view, method) | ||||
|     path_args, body_args, qs_args, header_args = _parse_func_signature(handler) | ||||
|  | ||||
|     if body_args: | ||||
|         body_schema = ( | ||||
|             next(iter(body_args.values())) | ||||
|             .schema(ref_template="#/components/schemas/{model}") | ||||
|             .copy() | ||||
|         ) | ||||
|         if def_sub_schemas := body_schema.pop("$defs", None): | ||||
|             oas.components.schemas.update(def_sub_schemas) | ||||
|  | ||||
|         oas_operation.request_body.content = { | ||||
|             "application/json": {"schema": body_schema} | ||||
|         mtd.request_body.content = { | ||||
|             "application/json": {"schema": next(iter(body_args.values())).schema()} | ||||
|         } | ||||
|  | ||||
|     indexes = count() | ||||
|     for args_location, args in ( | ||||
|         ("path", path_args.items()), | ||||
|         ("query", qs_args.items()), | ||||
|         ("header", header_args.items()), | ||||
|     ): | ||||
|         for name, type_ in args: | ||||
|             i = next(indexes) | ||||
|             oas_operation.parameters[i].in_ = args_location | ||||
|             oas_operation.parameters[i].name = name | ||||
|     i = 0 | ||||
|     for i, (name, type_) in enumerate(path_args.items()): | ||||
|         mtd.parameters[i].required = True | ||||
|         mtd.parameters[i].in_ = "path" | ||||
|         mtd.parameters[i].name = name | ||||
|         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[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 | ||||
|     for i, (name, type_) in enumerate(qs_args.items(), i + 1): | ||||
|         mtd.parameters[i].required = False | ||||
|         mtd.parameters[i].in_ = "query" | ||||
|         mtd.parameters[i].name = name | ||||
|         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} | ||||
|  | ||||
|             oas_operation.parameters[i].schema = type(name, (RootModel,), attrs).schema( | ||||
|                 ref_template="#/components/schemas/{model}" | ||||
|             ) | ||||
|  | ||||
|             # move definitions | ||||
|             if def_sub_schemas := oas_operation.parameters[i].schema.pop("$defs", None): | ||||
|                 oas.components.schemas.update(def_sub_schemas) | ||||
|  | ||||
|     return_type = get_type_hints(handler).get("return") | ||||
|     if return_type is not None: | ||||
|         _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( | ||||
|             return_type | ||||
|         ) | ||||
|     for i, (name, type_) in enumerate(header_args.items(), i + 1): | ||||
|         mtd.parameters[i].required = False | ||||
|         mtd.parameters[i].in_ = "header" | ||||
|         mtd.parameters[i].name = name | ||||
|         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} | ||||
|  | ||||
|  | ||||
| def generate_oas( | ||||
|     apps: List[Application], | ||||
|     version_spec: Optional[str] = None, | ||||
|     title_spec: Optional[str] = None, | ||||
| ) -> dict: | ||||
| async def get_oas(request): | ||||
|     """ | ||||
|     Generate and return Open Api Specification from PydanticView in application. | ||||
|     Generate Open Api Specification from PydanticView in application. | ||||
|     """ | ||||
|     apps = request.app["apps to expose"] | ||||
|     oas = OpenApiSpec3() | ||||
|  | ||||
|     if version_spec is not None: | ||||
|         oas.info.version = version_spec | ||||
|  | ||||
|     if title_spec is not None: | ||||
|         oas.info.title = title_spec | ||||
|  | ||||
|     for app in apps: | ||||
|         for resources in app.router.resources(): | ||||
|             for resource_route in resources: | ||||
|                 if not is_pydantic_view(resource_route.handler): | ||||
|                     continue | ||||
|  | ||||
|                 if is_pydantic_view(resource_route.handler): | ||||
|                     view: Type[PydanticView] = resource_route.handler | ||||
|                     info = resource_route.get_info() | ||||
|                     path = oas.paths[info.get("path", info.get("formatter"))] | ||||
|                     if resource_route.method == "*": | ||||
|                         for method_name in view.allowed_methods: | ||||
|                         _add_http_method_to_oas(oas, path, method_name, view) | ||||
|                             _add_http_method_to_oas(path, method_name, view) | ||||
|                     else: | ||||
|                     _add_http_method_to_oas(oas, path, resource_route.method, view) | ||||
|                         _add_http_method_to_oas(path, resource_route.method, view) | ||||
|  | ||||
|     return oas.spec | ||||
|  | ||||
|  | ||||
| async def get_oas(request): | ||||
|     """ | ||||
|     View to generate the Open Api Specification from PydanticView in application. | ||||
|     """ | ||||
|     apps = request.app["apps to expose"] | ||||
|     version_spec = request.app["version_spec"] | ||||
|     title_spec = request.app["title_spec"] | ||||
|     return json_response(generate_oas(apps, version_spec, title_spec)) | ||||
|     return json_response(oas.spec) | ||||
|  | ||||
|  | ||||
| async def oas_ui(request): | ||||
| @@ -191,8 +71,6 @@ 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( | ||||
|   | ||||
| @@ -1,19 +0,0 @@ | ||||
| from pydantic import BaseModel | ||||
|  | ||||
|  | ||||
| def is_pydantic_base_model(obj): | ||||
|     """ | ||||
|     Return true is obj is a pydantic.BaseModel subclass. | ||||
|     """ | ||||
|     return robuste_issubclass(obj, BaseModel) | ||||
|  | ||||
|  | ||||
| def robuste_issubclass(cls1, cls2): | ||||
|     """ | ||||
|     function likes issubclass but returns False instead of raise type error | ||||
|     if first parameter is not a class. | ||||
|     """ | ||||
|     try: | ||||
|         return issubclass(cls1, cls2) | ||||
|     except TypeError: | ||||
|         return False | ||||
| @@ -1,116 +1,67 @@ | ||||
| from functools import update_wrapper | ||||
| from inspect import iscoroutinefunction | ||||
| from typing import Any, Callable, Generator, Iterable, Set, ClassVar | ||||
| import warnings | ||||
|  | ||||
| from aiohttp.abc import AbstractView | ||||
| from aiohttp.hdrs import METH_ALL | ||||
| from aiohttp.web import json_response | ||||
| from aiohttp.web_exceptions import HTTPMethodNotAllowed | ||||
| from aiohttp.web_response import StreamResponse | ||||
| from pydantic import ValidationError | ||||
| from typing import Generator, Any, Callable, Type, Iterable | ||||
| from aiohttp.web import json_response | ||||
| from functools import update_wrapper | ||||
|  | ||||
| from pydantic_core import ErrorDetails | ||||
|  | ||||
| from .injectors import ( | ||||
|     AbstractInjector, | ||||
|     BodyGetter, | ||||
|     HeadersGetter, | ||||
|     MatchInfoGetter, | ||||
|     HeadersGetter, | ||||
|     QueryGetter, | ||||
|     BodyGetter, | ||||
|     AbstractInjector, | ||||
|     _parse_func_signature, | ||||
|     CONTEXT, | ||||
|     Group, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class PydanticValidationError(ErrorDetails): | ||||
|     loc_in: CONTEXT | ||||
|  | ||||
|  | ||||
| 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: | ||||
|         if (method_name := self.request.method) not in self.allowed_methods: | ||||
|             self._raise_allowed_methods() | ||||
|         return await getattr(self, method_name.lower())() | ||||
|         method = getattr(self, self.request.method.lower(), None) | ||||
|         resp = await method() | ||||
|         return resp | ||||
|  | ||||
|     def __await__(self) -> Generator[Any, None, StreamResponse]: | ||||
|         return self._iter().__await__() | ||||
|  | ||||
|     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. | ||||
|         """ | ||||
|  | ||||
|     def __init_subclass__(cls, **kwargs): | ||||
|         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.lower() in vars(cls): | ||||
|             if meth_name not in cls.allowed_methods: | ||||
|                 setattr(cls, meth_name.lower(), cls.raise_not_allowed) | ||||
|             else: | ||||
|                 handler = getattr(cls, meth_name.lower()) | ||||
|                 decorated_handler = inject_params(handler, cls.parse_func_signature) | ||||
|                 setattr(cls, meth_name.lower(), decorated_handler) | ||||
|  | ||||
|     def _raise_allowed_methods(self) -> None: | ||||
|     async def raise_not_allowed(self): | ||||
|         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, defaults = _parse_func_signature( | ||||
|             func | ||||
|         ) | ||||
|         path_args, body_args, qs_args, header_args = _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, default_value(path_args))) | ||||
|             injectors.append(MatchInfoGetter(path_args)) | ||||
|         if body_args: | ||||
|             injectors.append(BodyGetter(body_args, default_value(body_args))) | ||||
|             injectors.append(BodyGetter(body_args)) | ||||
|         if qs_args: | ||||
|             injectors.append(QueryGetter(qs_args, default_value(qs_args))) | ||||
|             injectors.append(QueryGetter(qs_args)) | ||||
|         if header_args: | ||||
|             injectors.append(HeadersGetter(header_args, default_value(header_args))) | ||||
|             injectors.append(HeadersGetter(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() | ||||
|         own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] | ||||
|  | ||||
|         return json_response(data=own_errors, status=400) | ||||
|  | ||||
|  | ||||
| def inject_params( | ||||
|     handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] | ||||
| @@ -132,7 +83,7 @@ def inject_params( | ||||
|                 else: | ||||
|                     injector.inject(self.request, args, kwargs) | ||||
|             except ValidationError as error: | ||||
|                 return await self.on_validation_error(error, injector.context) | ||||
|                 return json_response(text=error.json(), status=400) | ||||
|  | ||||
|         return await handler(self, *args, **kwargs) | ||||
|  | ||||
| @@ -148,15 +99,3 @@ def is_pydantic_view(obj) -> bool: | ||||
|         return issubclass(obj, PydanticView) | ||||
|     except TypeError: | ||||
|         return False | ||||
|  | ||||
|  | ||||
| __all__ = ( | ||||
|     "PydanticValidationError", | ||||
|     "AbstractInjector", | ||||
|     "BodyGetter", | ||||
|     "HeadersGetter", | ||||
|     "MatchInfoGetter", | ||||
|     "QueryGetter", | ||||
|     "CONTEXT", | ||||
|     "Group", | ||||
| ) | ||||
|   | ||||
| @@ -1,5 +1,25 @@ | ||||
| from aiohttp import web | ||||
|  | ||||
| from .main import app | ||||
| from aiohttp_pydantic import oas | ||||
| from aiohttp.web import middleware | ||||
|  | ||||
| from .view import PetItemView, PetCollectionView | ||||
| from .model import Model | ||||
|  | ||||
|  | ||||
| @middleware | ||||
| async def pet_not_found_to_404(request, handler): | ||||
|     try: | ||||
|         return await handler(request) | ||||
|     except Model.NotFound as key: | ||||
|         return web.json_response({"error": f"Pet {key} does not exist"}, status=404) | ||||
|  | ||||
|  | ||||
| app = web.Application(middlewares=[pet_not_found_to_404]) | ||||
| oas.setup(app) | ||||
|  | ||||
| app["model"] = Model() | ||||
| app.router.add_view("/pets", PetCollectionView) | ||||
| app.router.add_view("/pets/{id}", PetItemView) | ||||
|  | ||||
| web.run_app(app) | ||||
|   | ||||
							
								
								
									
										22
									
								
								demo/main.py
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								demo/main.py
									
									
									
									
									
								
							| @@ -1,22 +0,0 @@ | ||||
| from aiohttp.web import Application, json_response, middleware | ||||
|  | ||||
| from aiohttp_pydantic import oas | ||||
|  | ||||
| from .model import Model | ||||
| from .view import PetCollectionView, PetItemView | ||||
|  | ||||
|  | ||||
| @middleware | ||||
| async def pet_not_found_to_404(request, handler): | ||||
|     try: | ||||
|         return await handler(request) | ||||
|     except Model.NotFound as key: | ||||
|         return json_response({"error": f"Pet {key} does not exist"}, status=404) | ||||
|  | ||||
|  | ||||
| app = Application(middlewares=[pet_not_found_to_404]) | ||||
| oas.setup(app, version_spec="1.0.1", title_spec="My App") | ||||
|  | ||||
| app["model"] = Model() | ||||
| app.router.add_view("/pets", PetCollectionView) | ||||
| app.router.add_view("/pets/{id}", PetItemView) | ||||
| @@ -1,21 +1,9 @@ | ||||
| from pydantic import BaseModel | ||||
| from typing import List | ||||
|  | ||||
|  | ||||
| class Friend(BaseModel): | ||||
|     name: str | ||||
|     age: str | ||||
|  | ||||
|  | ||||
| class Pet(BaseModel): | ||||
|     id: int | ||||
|     name: str | ||||
|     age: int | ||||
|     friends: Friend | ||||
|  | ||||
|  | ||||
| class Error(BaseModel): | ||||
|     error: str | ||||
|  | ||||
|  | ||||
| class Model: | ||||
|   | ||||
							
								
								
									
										53
									
								
								demo/view.py
									
									
									
									
									
								
							
							
						
						
									
										53
									
								
								demo/view.py
									
									
									
									
									
								
							| @@ -1,63 +1,28 @@ | ||||
| from typing import List, Optional, Union | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
| from aiohttp import web | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
| from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 | ||||
|  | ||||
| from .model import Error, Pet | ||||
| from .model import Pet | ||||
|  | ||||
|  | ||||
| class PetCollectionView(PydanticView): | ||||
|     async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: | ||||
|         """ | ||||
|         List all pets | ||||
|  | ||||
|         Status Codes: | ||||
|             200: Successful operation | ||||
|         """ | ||||
|     async def get(self): | ||||
|         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] | ||||
|         ) | ||||
|         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 | ||||
|  | ||||
|         Status Codes: | ||||
|             201: Successful operation | ||||
|         """ | ||||
|     async def post(self, pet: Pet): | ||||
|         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 | ||||
|         """ | ||||
|     async def get(self, id: int, /): | ||||
|         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 | ||||
|         """ | ||||
|     async def put(self, id: int, /, pet: Pet): | ||||
|         self.request.app["model"].update_pet(id, pet) | ||||
|         return web.json_response(pet.dict()) | ||||
|  | ||||
|     async def delete(self, id: int, /) -> r204: | ||||
|         """ | ||||
|         Deletes a pet | ||||
|         """ | ||||
|     async def delete(self, id: int, /): | ||||
|         self.request.app["model"].remove_pet(id) | ||||
|         return web.Response(status=204) | ||||
|         return web.json_response(id) | ||||
|   | ||||
| @@ -4,6 +4,3 @@ requires = [ | ||||
|   "wheel", | ||||
| ] | ||||
| build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [tool.pytest.ini_options] | ||||
| asyncio_mode = "auto" | ||||
| @@ -1,11 +0,0 @@ | ||||
| aiohttp==3.8.4 | ||||
| pydantic==2.0.2 | ||||
| jinja2==3.1.2 | ||||
| swagger-4-ui-bundle==0.0.4 | ||||
| pytest==7.4.0 | ||||
| pytest-aiohttp==1.0.4 | ||||
| pytest-asyncio==0.21.1 | ||||
| pytest-cov==4.1.0 | ||||
| readme-renderer==40.0 | ||||
| codecov==2.1.13 | ||||
| twine==4.0.2 | ||||
| @@ -1,9 +0,0 @@ | ||||
| aiohttp==3.8.5 | ||||
| pydantic==2.3.0 | ||||
| jinja2==3.1.2 | ||||
| swagger-4-ui-bundle==0.0.4 | ||||
| pytest==7.4.0 | ||||
| pytest-aiohttp==1.0.4 | ||||
| pytest-asyncio==0.21.1 | ||||
| pytest-cov==4.1.0 | ||||
| readme-renderer==41.0 | ||||
							
								
								
									
										27
									
								
								setup.cfg
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								setup.cfg
									
									
									
									
									
								
							| @@ -18,37 +18,30 @@ classifiers = | ||||
|     Programming Language :: Python | ||||
|     Programming Language :: Python :: 3 | ||||
|     Programming Language :: Python :: 3 :: Only | ||||
|     Programming Language :: Python :: 3.10 | ||||
|     Programming Language :: Python :: 3.11 | ||||
|     Programming Language :: Python :: 3.8 | ||||
|     Programming Language :: Python :: 3.9 | ||||
|     Topic :: Software Development :: Libraries :: Application Frameworks | ||||
|     Framework :: aiohttp | ||||
|     Framework :: AsyncIO | ||||
|     License :: OSI Approved :: MIT License | ||||
|  | ||||
| [options] | ||||
| zip_safe = False | ||||
| include_package_data = True | ||||
| packages = find: | ||||
| python_requires = >=3.10 | ||||
| python_requires = >=3.8 | ||||
| install_requires = | ||||
|     aiohttp | ||||
|     pydantic>=2.0.0 | ||||
|     swagger-4-ui-bundle | ||||
|     pydantic | ||||
|     swagger-ui-bundle | ||||
|  | ||||
| [options.extras_require] | ||||
| test = | ||||
|     pytest==7.4.0 | ||||
|     pytest-aiohttp==1.0.4 | ||||
|     pytest-cov==4.1.0 | ||||
|     readme-renderer==40.0 | ||||
| ci = | ||||
|     %(test)s | ||||
|     codecov==2.1.13 | ||||
|     twine==4.0.2 | ||||
| test = pytest; pytest-aiohttp | ||||
|  | ||||
|  | ||||
| [options.packages.find] | ||||
| exclude = | ||||
|     tests* | ||||
|     demo* | ||||
|     tests | ||||
|     demo | ||||
|  | ||||
| [options.package_data] | ||||
| aiohttp_pydantic.oas = index.j2 | ||||
|   | ||||
							
								
								
									
										175
									
								
								tasks.py
									
									
									
									
									
								
							
							
						
						
									
										175
									
								
								tasks.py
									
									
									
									
									
								
							| @@ -1,175 +0,0 @@ | ||||
| """ | ||||
| To use this module, install invoke and type invoke -l | ||||
| """ | ||||
|  | ||||
| from functools import partial | ||||
| import os | ||||
| from pathlib import Path | ||||
| from setuptools.config import read_configuration | ||||
|  | ||||
| from invoke import task, Exit, Task as Task_, call | ||||
|  | ||||
|  | ||||
| def activate_venv(c, venv: str): | ||||
|     """ | ||||
|     Activate a virtualenv | ||||
|     """ | ||||
|     virtual_env = Path().absolute() / venv | ||||
|     if original_path := os.environ.get("PATH"): | ||||
|         path = f'{virtual_env / "bin"}:{original_path}' | ||||
|     else: | ||||
|         path = str(virtual_env / "bin") | ||||
|     c.config.run.env["PATH"] = path | ||||
|     c.config.run.env["VIRTUAL_ENV"] = str(virtual_env) | ||||
|     os.environ.pop("PYTHONHOME", "") | ||||
|  | ||||
|  | ||||
| def title(text, underline_char="#"): | ||||
|     """ | ||||
|     Display text as a title. | ||||
|     """ | ||||
|     template = f"{{:{underline_char}^80}}" | ||||
|     text = template.format(f" {text.strip()} ") | ||||
|     print(f"\033[1m{text}\033[0m") | ||||
|  | ||||
|  | ||||
| class Task(Task_): | ||||
|     """ | ||||
|     This task add 'skip_if_recent' feature. | ||||
|  | ||||
|     >>> @task(skip_if_recent=['./target', './dependency']) | ||||
|     >>> def my_tash(c): | ||||
|     >>>    ... | ||||
|  | ||||
|     target is file created by the task | ||||
|     dependency is file used by the task | ||||
|  | ||||
|     The task is ran only if the dependency is more recent than the target file. | ||||
|     The target or the dependency can be a tuple of files. | ||||
|     """ | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         self.skip_if_recent = kwargs.pop("skip_if_recent", None) | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     def __call__(self, *args, **kwargs): | ||||
|         title(self.__doc__ or self.name) | ||||
|  | ||||
|         if self.skip_if_recent: | ||||
|             targets, dependencies = self.skip_if_recent | ||||
|             if isinstance(targets, str): | ||||
|                 targets = (targets,) | ||||
|             if isinstance(dependencies, str): | ||||
|                 dependencies = (dependencies,) | ||||
|  | ||||
|             target_mtime = min( | ||||
|                 ((Path(file).exists() and Path(file).lstat().st_mtime) or 0) | ||||
|                 for file in targets | ||||
|             ) | ||||
|             dependency_mtime = max(Path(file).lstat().st_mtime for file in dependencies) | ||||
|  | ||||
|             if dependency_mtime < target_mtime: | ||||
|                 print(f"{self.name}, nothing to do") | ||||
|                 return None | ||||
|  | ||||
|         return super().__call__(*args, **kwargs) | ||||
|  | ||||
|  | ||||
| task = partial(task, klass=Task) | ||||
|  | ||||
|  | ||||
| @task() | ||||
| def venv(c): | ||||
|     """ | ||||
|     Create a virtual environment for dev | ||||
|     """ | ||||
|     c.run("python -m venv --clear venv") | ||||
|     c.run("venv/bin/pip install -U setuptools wheel pip") | ||||
|     c.run("venv/bin/pip install -e .") | ||||
|     c.run("venv/bin/pip install -r requirements/test.txt") | ||||
|  | ||||
|  | ||||
| @task() | ||||
| def check_readme(c): | ||||
|     """ | ||||
|     Check the README.rst render | ||||
|     """ | ||||
|     c.run("python -m readme_renderer -o /dev/null README.rst") | ||||
|  | ||||
|  | ||||
| @task() | ||||
| def test(c, isolate=False): | ||||
|     """ | ||||
|     Launch tests | ||||
|     """ | ||||
|     #opt = "I" if isolate else "" | ||||
|     #c.run(f"python -{opt}m pytest --cov-report=xml --cov=aiohttp_pydantic tests/") | ||||
|     pass | ||||
|  | ||||
|  | ||||
| @task() | ||||
| def tag_eq_version(c): | ||||
|     """ | ||||
|     Ensure that the last git tag matches the package version | ||||
|     """ | ||||
|     git_tag = c.run("git describe --tags HEAD", hide=True).stdout.strip() | ||||
|     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||
|     if git_tag != f"v{package_version}": | ||||
|         raise Exit( | ||||
|             f"ERROR: The git tag {git_tag!r} does not matches" | ||||
|             f" the package version {package_version!r}" | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @task() | ||||
| def prepare_ci_env(c): | ||||
|     """ | ||||
|     Prepare CI environment | ||||
|     """ | ||||
|     title("Creating virtual env", "=") | ||||
|     c.run("python -m venv --clear dist_venv") | ||||
|     activate_venv(c, "dist_venv") | ||||
|  | ||||
|     c.run("dist_venv/bin/python -m pip install -U setuptools wheel pip") | ||||
|  | ||||
|     title("Building wheel", "=") | ||||
|     c.run("dist_venv/bin/python setup.py build bdist_wheel") | ||||
|  | ||||
|     title("Installing wheel", "=") | ||||
|     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||
|     print([x for x in Path("dist").glob('*')]) | ||||
|     dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) | ||||
|     c.run(f"dist_venv/bin/python -m pip install {dist}") | ||||
|  | ||||
|     # We verify that aiohttp-pydantic module is importable before installing CI tools. | ||||
|     package_names = read_configuration("./setup.cfg")["options"]["packages"] | ||||
|     for package_name in package_names: | ||||
|         c.run(f"dist_venv/bin/python -I -c 'import {package_name}'") | ||||
|  | ||||
|     title("Installing CI tools", "=") | ||||
|     c.run("dist_venv/bin/python -m pip install -r requirements/ci.txt") | ||||
|  | ||||
|  | ||||
| @task(prepare_ci_env, check_readme, call(test, isolate=True), klass=Task_) | ||||
| def prepare_upload(c): | ||||
|     """ | ||||
|     Launch all tests and verifications | ||||
|     """ | ||||
|  | ||||
|  | ||||
| @task(tag_eq_version, prepare_upload) | ||||
| def upload(c, pypi_user=None, pypi_password=None, pypi_url=None): | ||||
|     """ | ||||
|     Upload on pypi | ||||
|     """ | ||||
|     package_version = read_configuration("./setup.cfg")["metadata"]["version"] | ||||
|     dist = next(Path("dist").glob(f"aiohttp_pydantic-{package_version}-*.whl")) | ||||
|     if pypi_user is not None and pypi_password is not None: | ||||
|         c.run( | ||||
|             f"dist_venv/bin/twine upload --non-interactive" | ||||
|             f" -u {pypi_user} -p {pypi_password} {dist}" | ||||
|             f" --repository-url {pypi_url}", | ||||
|             hide=True, | ||||
|         ) | ||||
|     else: | ||||
|         c.run(f"dist_venv/bin/twine upload --repository-url {pypi_url} --repository aiohttp-pydantic {dist}") | ||||
							
								
								
									
										3
									
								
								test_requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								test_requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| pytest==6.1.1 | ||||
| pytest-aiohttp==0.3.0 | ||||
| typing_extensions>=3.6.5 | ||||
| @@ -1,76 +0,0 @@ | ||||
| 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} | ||||
| @@ -1,59 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Iterator, List, Optional | ||||
|  | ||||
| from aiohttp import web | ||||
| from aiohttp.web_response import json_response | ||||
| from pydantic import BaseModel, RootModel | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
| from aiohttp_pydantic.view import PydanticValidationError | ||||
|  | ||||
|  | ||||
| class ArticleModel(BaseModel): | ||||
|     name: str | ||||
|     nb_page: Optional[int] | ||||
|  | ||||
|  | ||||
| class ArticleModels(RootModel): | ||||
|     root: List[ArticleModel] | ||||
|  | ||||
|     def __iter__(self) -> Iterator[ArticleModel]: | ||||
|         return iter(self.root) | ||||
|  | ||||
|  | ||||
| class ArticleView(PydanticView): | ||||
|     async def post(self, article: ArticleModel): | ||||
|         return web.json_response(article.dict()) | ||||
|  | ||||
|     async def put(self, articles: ArticleModels): | ||||
|         return web.json_response([article.dict() for article in articles]) | ||||
|  | ||||
|     async def on_validation_error(self, exception, context): | ||||
|         errors = exception.errors() | ||||
|         own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] | ||||
|         return json_response(data=own_errors, status=400) | ||||
|  | ||||
|  | ||||
| async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | ||||
|         aiohttp_client, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.post("/article", json={"name": "foo", "nb_page": "foo"}) | ||||
|  | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'loc_in': 'body', | ||||
|             'input': 'foo', | ||||
|             'loc': ['nb_page'], | ||||
|             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||
|             'type': 'int_parsing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/int_parsing' | ||||
|         } | ||||
|     ] | ||||
| @@ -1,73 +0,0 @@ | ||||
| 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 +0,0 @@ | ||||
| {"info": {"title": "MyApp",  "version": "1.0.0"}} | ||||
| @@ -1,29 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from aiohttp import web | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
|  | ||||
|  | ||||
| class View1(PydanticView): | ||||
|     async def get(self, a: int, /): | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| class View2(PydanticView): | ||||
|     async def post(self, b: int, /): | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| sub_app = web.Application() | ||||
| sub_app.router.add_view("/route-2/{b}", View2) | ||||
|  | ||||
| app = web.Application() | ||||
| app.router.add_view("/route-1/{a}", View1) | ||||
| app.add_subapp("/sub-app", sub_app) | ||||
|  | ||||
|  | ||||
| def make_app(): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/route-3/{a}", View1) | ||||
|     return app | ||||
| @@ -1,148 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import argparse | ||||
| from textwrap import dedent | ||||
| from io import StringIO | ||||
| from pathlib import Path | ||||
| import pytest | ||||
|  | ||||
| from aiohttp_pydantic.oas import cmd | ||||
|  | ||||
| PATH_TO_BASE_JSON_FILE = str(Path(__file__).parent / "oas_base.json") | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| def cmd_line(): | ||||
|     parser = argparse.ArgumentParser() | ||||
|     cmd.setup(parser) | ||||
|     return parser | ||||
|  | ||||
|  | ||||
| def test_show_oas_of_app(cmd_line): | ||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample"]) | ||||
|     args.output = StringIO() | ||||
|     args.func(args) | ||||
|  | ||||
|     expected = dedent( | ||||
|         """ | ||||
|     { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/route-1/{a}": { | ||||
|                 "get": { | ||||
|                     "parameters": [ | ||||
|                         { | ||||
|                             "in": "path", | ||||
|                             "name": "a", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "a", | ||||
|                                 "type": "integer" | ||||
|                             } | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             }, | ||||
|             "/sub-app/route-2/{b}": { | ||||
|                 "post": { | ||||
|                     "parameters": [ | ||||
|                         { | ||||
|                             "in": "path", | ||||
|                             "name": "b", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "b", | ||||
|                                 "type": "integer" | ||||
|                             } | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     """ | ||||
|     ) | ||||
|  | ||||
|     assert args.output.getvalue().strip() == expected.strip() | ||||
|  | ||||
|  | ||||
| def test_show_oas_of_sub_app(cmd_line): | ||||
|     args = cmd_line.parse_args(["tests.test_oas.test_cmd.sample:sub_app"]) | ||||
|     args.output = StringIO() | ||||
|     args.func(args) | ||||
|     expected = dedent( | ||||
|         """ | ||||
|     { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/sub-app/route-2/{b}": { | ||||
|                 "post": { | ||||
|                     "parameters": [ | ||||
|                         { | ||||
|                             "in": "path", | ||||
|                             "name": "b", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "b", | ||||
|                                 "type": "integer" | ||||
|                             } | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     """ | ||||
|     ) | ||||
|  | ||||
|     assert args.output.getvalue().strip() == expected.strip() | ||||
|  | ||||
|  | ||||
| def test_show_oas_of_a_callable(cmd_line): | ||||
|     args = cmd_line.parse_args( | ||||
|         [ | ||||
|             "tests.test_oas.test_cmd.sample:make_app()", | ||||
|             "--base-oas-file", | ||||
|             PATH_TO_BASE_JSON_FILE, | ||||
|         ] | ||||
|     ) | ||||
|     args.output = StringIO() | ||||
|     args.func(args) | ||||
|     expected = dedent( | ||||
|         """ | ||||
|         { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0" | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|         "paths": { | ||||
|             "/route-3/{a}": { | ||||
|                 "get": { | ||||
|                     "parameters": [ | ||||
|                         { | ||||
|                             "in": "path", | ||||
|                             "name": "a", | ||||
|                             "required": true, | ||||
|                             "schema": { | ||||
|                                 "title": "a", | ||||
|                                 "type": "integer" | ||||
|                             } | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     """ | ||||
|     ) | ||||
|  | ||||
|     assert args.output.getvalue().strip() == expected.strip() | ||||
| @@ -1,157 +0,0 @@ | ||||
| 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,76 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
|  | ||||
|  | ||||
| def test_info_title(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.title == "Aiohttp pydantic application" | ||||
|     oas.info.title = "Info Title" | ||||
|     assert oas.info.title == "Info Title" | ||||
|     assert oas.spec == { | ||||
|         "info": { | ||||
|             "title": "Info Title", | ||||
|             "version": "1.0.0", | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_info_description(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.description is None | ||||
|     oas.info.description = "info description" | ||||
|     assert oas.info.description == "info description" | ||||
|     assert oas.spec == { | ||||
|         "info": { | ||||
|             "description": "info description", | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0", | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_info_version(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.version == "1.0.0" | ||||
|     oas.info.version = "3.14" | ||||
|     assert oas.info.version == "3.14" | ||||
|     assert oas.spec == { | ||||
|         "info": {"version": "3.14", "title": "Aiohttp pydantic application"}, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_info_terms_of_service(): | ||||
|     oas = OpenApiSpec3() | ||||
|     assert oas.info.terms_of_service is None | ||||
|     oas.info.terms_of_service = "http://example.com/terms/" | ||||
|     assert oas.info.terms_of_service == "http://example.com/terms/" | ||||
|     assert oas.spec == { | ||||
|         "info": { | ||||
|             "title": "Aiohttp pydantic application", | ||||
|             "version": "1.0.0", | ||||
|             "termsOfService": "http://example.com/terms/", | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
|  | ||||
|  | ||||
| @pytest.mark.skip("Not yet implemented") | ||||
| def test_info_license(): | ||||
|     oas = OpenApiSpec3() | ||||
|     oas.info.license.name = "Apache 2.0" | ||||
|     oas.info.license.url = "https://www.apache.org/licenses/LICENSE-2.0.html" | ||||
|     assert oas.spec == { | ||||
|         "info": { | ||||
|             "license": { | ||||
|                 "name": "Apache 2.0", | ||||
|                 "url": "https://www.apache.org/licenses/LICENSE-2.0.html", | ||||
|             } | ||||
|         }, | ||||
|         "openapi": "3.0.0", | ||||
|     } | ||||
| @@ -1,147 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
|  | ||||
|  | ||||
| def test_paths_description(): | ||||
|     oas = OpenApiSpec3() | ||||
|     oas.paths["/users/{id}"].description = "This route ..." | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": {"/users/{id}": {"description": "This route ..."}}, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_get(): | ||||
|     oas = OpenApiSpec3() | ||||
|     oas.paths["/users/{id}"].get | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": {"/users/{id}": {"get": {}}}, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_operation_description(): | ||||
|     oas = OpenApiSpec3() | ||||
|     operation = oas.paths["/users/{id}"].get | ||||
|     operation.description = "Long descriptions ..." | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}}, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_operation_summary(): | ||||
|     oas = OpenApiSpec3() | ||||
|     operation = oas.paths["/users/{id}"].get | ||||
|     operation.summary = "Updates a pet in the store with form data" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{id}": { | ||||
|                 "get": {"summary": "Updates a pet in the store with form data"} | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_operation_parameters(): | ||||
|     oas = OpenApiSpec3() | ||||
|     operation = oas.paths["/users/{petId}"].get | ||||
|     parameter = operation.parameters[0] | ||||
|     parameter.name = "petId" | ||||
|     parameter.description = "ID of pet that needs to be updated" | ||||
|     parameter.in_ = "path" | ||||
|     parameter.required = True | ||||
|  | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{petId}": { | ||||
|                 "get": { | ||||
|                     "parameters": [ | ||||
|                         { | ||||
|                             "description": "ID of pet that needs to be updated", | ||||
|                             "in": "path", | ||||
|                             "name": "petId", | ||||
|                             "required": True, | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_operation_requestBody(): | ||||
|     oas = OpenApiSpec3() | ||||
|     request_body = oas.paths["/users/{petId}"].get.request_body | ||||
|     request_body.description = "user to add to the system" | ||||
|     request_body.content = { | ||||
|         "application/json": { | ||||
|             "schema": {"$ref": "#/components/schemas/User"}, | ||||
|             "examples": { | ||||
|                 "user": { | ||||
|                     "summary": "User Example", | ||||
|                     "externalValue": "http://foo.bar/examples/user-example.json", | ||||
|                 } | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|     request_body.required = True | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "paths": { | ||||
|             "/users/{petId}": { | ||||
|                 "get": { | ||||
|                     "requestBody": { | ||||
|                         "content": { | ||||
|                             "application/json": { | ||||
|                                 "examples": { | ||||
|                                     "user": { | ||||
|                                         "externalValue": "http://foo.bar/examples/user-example.json", | ||||
|                                         "summary": "User Example", | ||||
|                                     } | ||||
|                                 }, | ||||
|                                 "schema": {"$ref": "#/components/schemas/User"}, | ||||
|                             } | ||||
|                         }, | ||||
|                         "description": "user to add to the system", | ||||
|                         "required": True, | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_paths_operation_tags(): | ||||
|     oas = OpenApiSpec3() | ||||
|     operation = oas.paths["/users/{petId}"].get | ||||
|     assert operation.tags == [] | ||||
|     operation.tags = ["pets"] | ||||
|  | ||||
|     assert oas.spec["paths"]["/users/{petId}"] == {"get": {"tags": ["pets"]}} | ||||
|  | ||||
|     operation.tags = [] | ||||
|     assert oas.spec["paths"]["/users/{petId}"] == {"get": {}} | ||||
|  | ||||
|  | ||||
| def test_paths_operation_responses(): | ||||
|     oas = OpenApiSpec3() | ||||
|     response = oas.paths["/users/{petId}"].get.responses[200] | ||||
|     response.description = "A complex object array response" | ||||
|     response.content = { | ||||
|         "application/json": { | ||||
|             "schema": { | ||||
|                 "type": "array", | ||||
|                 "items": {"$ref": "#/components/schemas/VeryComplexType"}, | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| @@ -1,40 +0,0 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import pytest | ||||
|  | ||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3 | ||||
|  | ||||
|  | ||||
| def test_sever_url(): | ||||
|     oas = OpenApiSpec3() | ||||
|     oas.servers[0].url = "https://development.gigantic-server.com/v1" | ||||
|     oas.servers[1].url = "https://development.gigantic-server.com/v2" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "servers": [ | ||||
|             {"url": "https://development.gigantic-server.com/v1"}, | ||||
|             {"url": "https://development.gigantic-server.com/v2"}, | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_sever_description(): | ||||
|     oas = OpenApiSpec3() | ||||
|     oas.servers[0].url = "https://development.gigantic-server.com/v1" | ||||
|     oas.servers[0].description = "Development server" | ||||
|     assert oas.spec == { | ||||
|         "openapi": "3.0.0", | ||||
|         "info": {"title": "Aiohttp pydantic application", "version": "1.0.0"}, | ||||
|         "servers": [ | ||||
|             { | ||||
|                 "url": "https://development.gigantic-server.com/v1", | ||||
|                 "description": "Development server", | ||||
|             } | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| @pytest.mark.skip("Not yet implemented") | ||||
| def test_sever_variables(): | ||||
|     oas = OpenApiSpec3() | ||||
| @@ -1,209 +1,46 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
| from typing import List, Optional, Union, Literal, Annotated | ||||
| from uuid import UUID | ||||
| from pydantic.main import BaseModel | ||||
| from aiohttp_pydantic import PydanticView, oas | ||||
| from aiohttp import web | ||||
|  | ||||
| import pytest | ||||
| from aiohttp import web | ||||
| from pydantic import Field, RootModel | ||||
| from pydantic.main import BaseModel | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView, oas | ||||
| from aiohttp_pydantic.injectors import Group | ||||
| from aiohttp_pydantic.oas.typing import r200, r201, r204, r404, r400 | ||||
| from aiohttp_pydantic.oas.view import generate_oas | ||||
|  | ||||
|  | ||||
| class Color(str, Enum): | ||||
|     """ | ||||
|     Pet color | ||||
|     """ | ||||
|     RED = "red" | ||||
|     GREEN = "green" | ||||
|     PINK = "pink" | ||||
|  | ||||
|  | ||||
| class Lang(str, Enum): | ||||
|     EN = 'en' | ||||
|     FR = 'fr' | ||||
|  | ||||
|  | ||||
| class Toy(BaseModel): | ||||
|     name: str | ||||
|     color: Color | ||||
|  | ||||
|  | ||||
| class Pet(BaseModel): | ||||
|     id: int | ||||
|     name: Optional[str] = Field(None) | ||||
|     toys: List[Toy] | ||||
|  | ||||
|  | ||||
| class Error(BaseModel): | ||||
|     code: int | ||||
|     text: str | ||||
|  | ||||
|  | ||||
| class Cat(BaseModel): | ||||
|     pet_type: Literal['cat'] | ||||
|     meows: int | ||||
|  | ||||
|  | ||||
| class Dog(BaseModel): | ||||
|     pet_type: Literal['dog'] | ||||
|     barks: float | ||||
|  | ||||
|  | ||||
| class Animal(RootModel): | ||||
|     root: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] | ||||
|     name: str | ||||
|  | ||||
|  | ||||
| class PetCollectionView(PydanticView): | ||||
|     async def get( | ||||
|             self, format: str, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None | ||||
|     ) -> r200[List[Pet]]: | ||||
|         """ | ||||
|         Get a list of pets | ||||
|  | ||||
|         Tags: pet | ||||
|         Status Codes: | ||||
|           200: Successful operation | ||||
|         OperationId: createPet | ||||
|         """ | ||||
|     async def get(self): | ||||
|         return web.json_response() | ||||
|  | ||||
|     async def post(self, pet: Pet) -> r201[Pet]: | ||||
|         """Create a Pet""" | ||||
|     async def post(self, pet: Pet): | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| class PetItemView(PydanticView): | ||||
|     async def get( | ||||
|             self, | ||||
|             id: int, | ||||
|             /, | ||||
|             size: Union[int, Literal["x", "l", "s"]], | ||||
|             day: Union[int, Literal["now"]] = "now", | ||||
|     ) -> Union[r200[Pet], r404[Error], r400[Error]]: | ||||
|     async def get(self, id: int, /): | ||||
|         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 | ||||
|         """ | ||||
|     async def delete(self, id: int, /): | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| class ViewResponseReturnASimpleType(PydanticView): | ||||
|     async def get(self) -> r200[int]: | ||||
|         """ | ||||
|         Status Codes: | ||||
|           200: The new number | ||||
|         """ | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| class DiscriminatedView(PydanticView): | ||||
|     async def post(self, /, request: Animal) -> r200[int]: | ||||
|         return web.json_response() | ||||
|  | ||||
|  | ||||
| async def ensure_content_durability(client): | ||||
|     """ | ||||
|     Reload the page 2 times to ensure that content is always the same | ||||
|     note: pydantic can return a cached dict, if a view updates the dict the | ||||
|     output will be incoherent | ||||
|     """ | ||||
|     response_1 = await client.get("/oas/spec") | ||||
|     assert response_1.status == 200 | ||||
|     assert response_1.content_type == "application/json" | ||||
|     content_1 = await response_1.json() | ||||
|  | ||||
|     response_2 = await client.get("/oas/spec") | ||||
|     content_2 = await response_2.json() | ||||
|     assert content_1 == content_2 | ||||
|  | ||||
|     return content_2 | ||||
|  | ||||
|  | ||||
| @pytest.fixture | ||||
| async def generated_oas(aiohttp_client, event_loop) -> web.Application: | ||||
| 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) | ||||
|     app.router.add_view("/animals", DiscriminatedView) | ||||
|     oas.setup(app) | ||||
|  | ||||
|     return await ensure_content_durability(await aiohttp_client(app)) | ||||
|  | ||||
|  | ||||
| async def test_generated_oas_should_have_components_schemas(generated_oas): | ||||
|     assert generated_oas["components"]["schemas"] == { | ||||
|         'Cat': {'properties': {'meows': {'title': 'Meows', 'type': 'integer'}, | ||||
|                                'pet_type': {'const': 'cat', 'title': 'Pet Type'}}, | ||||
|                 'required': ['pet_type', 'meows'], | ||||
|                 'title': 'Cat', | ||||
|                 'type': 'object'}, | ||||
|         "Color": { | ||||
|             "description": "Pet color", | ||||
|             "enum": ["red", "green", "pink"], | ||||
|             "title": "Color", | ||||
|             "type": "string", | ||||
|         }, | ||||
|         'Dog': {'properties': {'barks': {'title': 'Barks', 'type': 'number'}, | ||||
|                                'pet_type': {'const': 'dog', 'title': 'Pet Type'}}, | ||||
|                 'required': ['pet_type', 'barks'], | ||||
|                 'title': 'Dog', | ||||
|                 'type': 'object'}, | ||||
|         'Error': { | ||||
|             'properties': { | ||||
|                 'code': {'title': 'Code', 'type': 'integer'}, | ||||
|                 'text': {'title': 'Text', 'type': 'string'}}, | ||||
|             'required': ['code', 'text'], | ||||
|             'title': 'Error', | ||||
|             'type': 'object' | ||||
|         }, | ||||
|         'Lang': { | ||||
|             'enum': ['en', 'fr'], | ||||
|             'title': 'Lang', | ||||
|             'type': 'string' | ||||
|         }, | ||||
|         "Toy": { | ||||
|             "properties": { | ||||
|                 "color": {"$ref": "#/components/schemas/Color"}, | ||||
|                 "name": {"title": "Name", "type": "string"}, | ||||
|             }, | ||||
|             "required": ["name", "color"], | ||||
|             "title": "Toy", | ||||
|             "type": "object", | ||||
|         }, | ||||
|         'Pet': { | ||||
|             'properties': { | ||||
|                 'id': {'title': 'Id', 'type': 'integer'}, | ||||
|                 'name': { | ||||
|                     'anyOf': [ | ||||
|                         {'type': 'string'}, | ||||
|                         {'type': 'null'} | ||||
|                     ], | ||||
|                     'default': None, | ||||
|                     'title': 'Name'}, | ||||
|                 'toys': { | ||||
|                     'items': {'$ref': '#/components/schemas/Toy'}, | ||||
|                     'title': 'Toys', | ||||
|                     'type': 'array' | ||||
|                 } | ||||
|             }, | ||||
|             'required': ['id', 'toys'], | ||||
|             'title': 'Pet', | ||||
|             'type': 'object' | ||||
|         } | ||||
|     } | ||||
|     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() | ||||
|  | ||||
|  | ||||
| async def test_generated_oas_should_have_pets_paths(generated_oas): | ||||
| @@ -211,107 +48,26 @@ 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", | ||||
|         "operationId": "createPet", | ||||
|         "tags": ["pet"], | ||||
|         "parameters": [ | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "format", | ||||
|                 "required": True, | ||||
|                 "schema": {"title": "format", "type": "string"}, | ||||
|             }, | ||||
|             { | ||||
|                 'in': 'query', | ||||
|                 'name': 'lang', | ||||
|                 'required': False, | ||||
|                 'schema': { | ||||
|                     'allOf': [{'$ref': '#/components/schemas/Lang'}], | ||||
|                     'default': 'en', | ||||
|                     'title': 'lang' | ||||
|                 } | ||||
|             }, | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "name", | ||||
|                 "required": False, | ||||
|                 "schema": { | ||||
|                     'anyOf': [{'type': 'string'}, {'type': 'null'}], | ||||
|                     'default': None, | ||||
|                     'title': 'name' | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 "in": "header", | ||||
|                 "name": "promo", | ||||
|                 "required": False, | ||||
|                 "schema": { | ||||
|                     'anyOf': [ | ||||
|                         {'format': 'uuid', 'type': 'string'}, | ||||
|                         {'type': 'null'} | ||||
|                     ], | ||||
|                     'default': None, | ||||
|                     'title': 'promo' | ||||
|                 }, | ||||
|             }, | ||||
|         ], | ||||
|         "responses": { | ||||
|             "200": { | ||||
|                 "description": "Successful operation", | ||||
|                 "content": { | ||||
|                     "application/json": { | ||||
|                         "schema": { | ||||
|                             "items": {'$ref': '#/components/schemas/Pet'}, | ||||
|                             "type": "array", | ||||
|                         } | ||||
|                     } | ||||
|                 }, | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|     assert generated_oas["paths"]["/pets"]["get"] == {} | ||||
|  | ||||
|  | ||||
| async def test_pets_route_should_have_post_method(generated_oas): | ||||
|     assert generated_oas["paths"]["/pets"]["post"] == { | ||||
|         "description": "Create a Pet", | ||||
|         "requestBody": { | ||||
|             "content": { | ||||
|                 "application/json": { | ||||
|                     "schema": { | ||||
|                         "properties": { | ||||
|                             "id": {"title": "Id", "type": "integer"}, | ||||
|                             "name": { | ||||
|                                 'anyOf': [ | ||||
|                                     {'type': 'string'}, | ||||
|                                     {'type': 'null'} | ||||
|                                 ], | ||||
|                                 'default': None, | ||||
|                                 'title': 'Name' | ||||
|                             "name": {"title": "Name", "type": "string"}, | ||||
|                         }, | ||||
|                             "toys": { | ||||
|                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||
|                                 "title": "Toys", | ||||
|                                 "type": "array", | ||||
|                             }, | ||||
|                         }, | ||||
|                         "required": ["id", "toys"], | ||||
|                         "required": ["id", "name"], | ||||
|                         "title": "Pet", | ||||
|                         "type": "object", | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         "responses": { | ||||
|             "201": { | ||||
|                 "description": "", | ||||
|                 "content": { | ||||
|                     "application/json": { | ||||
|                         "schema": {'$ref': '#/components/schemas/Pet'} | ||||
|         } | ||||
|                 }, | ||||
|             } | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -321,16 +77,14 @@ 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": [ | ||||
|             { | ||||
|                 "in": "path", | ||||
|                 "name": "id", | ||||
|                 "required": True, | ||||
|                 "schema": {"title": "id", "type": "integer"}, | ||||
|                 "schema": {"type": "integer"}, | ||||
|             } | ||||
|         ], | ||||
|         "responses": {"204": {"content": {}, "description": "Empty but OK"}}, | ||||
|         ] | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -341,39 +95,9 @@ async def test_pets_id_route_should_have_get_method(generated_oas): | ||||
|                 "in": "path", | ||||
|                 "name": "id", | ||||
|                 "required": True, | ||||
|                 "schema": {"title": "id", "type": "integer"}, | ||||
|             }, | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "size", | ||||
|                 "required": True, | ||||
|                 "schema": { | ||||
|                     "anyOf": [ | ||||
|                         {"type": "integer"}, | ||||
|                         {"enum": ["x", "l", "s"], "type": "string"}, | ||||
|                     ], | ||||
|                     "title": "size", | ||||
|                 }, | ||||
|             }, | ||||
|             { | ||||
|                 "in": "query", | ||||
|                 "name": "day", | ||||
|                 "required": False, | ||||
|                 "schema": { | ||||
|                     'anyOf': [{'type': 'integer'}, {'const': 'now'}], | ||||
|                     'default': 'now', | ||||
|                     'title': 'day' | ||||
|                 }, | ||||
|             }, | ||||
|         ], | ||||
|         'responses': { | ||||
|             '200': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Pet'}}}, | ||||
|                     'description': ''}, | ||||
|             '400': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, | ||||
|                     'description': ''}, | ||||
|             '404': {'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Error'}}}, | ||||
|                     'description': ''} | ||||
|                 "schema": {"type": "integer"}, | ||||
|             } | ||||
|         ] | ||||
|     } | ||||
|  | ||||
|  | ||||
| @@ -384,7 +108,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | ||||
|                 "in": "path", | ||||
|                 "name": "id", | ||||
|                 "required": True, | ||||
|                 "schema": {"title": "id", "type": "integer"}, | ||||
|                 "schema": {"type": "integer"}, | ||||
|             } | ||||
|         ], | ||||
|         "requestBody": { | ||||
| @@ -393,20 +117,9 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | ||||
|                     "schema": { | ||||
|                         "properties": { | ||||
|                             "id": {"title": "Id", "type": "integer"}, | ||||
|                             "name": { | ||||
|                                 'anyOf': [ | ||||
|                                     {'type': 'string'}, | ||||
|                                     {'type': 'null'} | ||||
|                                 ], | ||||
|                                 'default': None, | ||||
|                                 'title': 'Name'}, | ||||
|                             "toys": { | ||||
|                                 "items": {"$ref": "#/components/schemas/Toy"}, | ||||
|                                 "title": "Toys", | ||||
|                                 "type": "array", | ||||
|                             "name": {"title": "Name", "type": "string"}, | ||||
|                         }, | ||||
|                         }, | ||||
|                         "required": ["id", "toys"], | ||||
|                         "required": ["id", "name"], | ||||
|                         "title": "Pet", | ||||
|                         "type": "object", | ||||
|                     } | ||||
| @@ -414,72 +127,3 @@ 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,10 +1,6 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from uuid import UUID | ||||
|  | ||||
| from pydantic import BaseModel | ||||
|  | ||||
| from aiohttp_pydantic.injectors import _parse_func_signature | ||||
| from pydantic import BaseModel | ||||
| from uuid import UUID | ||||
|  | ||||
|  | ||||
| class User(BaseModel): | ||||
| @@ -40,42 +36,32 @@ 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,35 +1,21 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import Iterator, List, Optional | ||||
|  | ||||
| from pydantic import BaseModel | ||||
| from typing import Optional | ||||
| from aiohttp import web | ||||
| from pydantic import BaseModel, RootModel | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
|  | ||||
|  | ||||
| class ArticleModel(BaseModel): | ||||
|     name: str | ||||
|     nb_page: Optional[int] = None | ||||
|  | ||||
|  | ||||
| class ArticleModels(RootModel): | ||||
|     root: List[ArticleModel] | ||||
|  | ||||
|     def __iter__(self) -> Iterator[ArticleModel]: | ||||
|         return iter(self.root) | ||||
|     nb_page: Optional[int] | ||||
|  | ||||
|  | ||||
| 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, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -38,21 +24,13 @@ async def test_post_an_article_without_required_field_should_return_an_error_mes | ||||
|     resp = await client.post("/article", json={}) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': {}, | ||||
|             'loc': ['name'], | ||||
|             'loc_in': 'body', | ||||
|             'msg': 'Field required', | ||||
|             'type': 'missing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing' | ||||
|         } | ||||
|         {"loc": ["name"], "msg": "field required", "type": "value_error.missing"} | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -63,72 +41,14 @@ 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() == [ | ||||
|         { | ||||
|             'input': 'foo', | ||||
|             'loc': ['nb_page'], | ||||
|             'loc_in': 'body', | ||||
|             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||
|             'type': 'int_parsing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/int_parsing' | ||||
|             "loc": ["nb_page"], | ||||
|             "msg": "value is not a valid integer", | ||||
|             "type": "type_error.integer", | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_post_an_array_json_is_supported(aiohttp_client, event_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, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.post("/article", json=[{"name": "foo", "nb_page": 3}]) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'loc': ['root'], | ||||
|             'loc_in': 'body', | ||||
|             'msg': 'value is not a valid dict', | ||||
|             'type': 'type_error.dict' | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_post_an_object_json_to_a_list_model_should_return_an_error( | ||||
|         aiohttp_client, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.put("/article", json={"name": "foo", "nb_page": 3}) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': {'name': 'foo', 'nb_page': 3}, | ||||
|             'loc': [], | ||||
|             'loc_in': 'body', | ||||
|             'msg': 'Input should be a valid list', | ||||
|             'type': 'list_type', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/list_type' | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_post_a_valid_article_should_return_the_parsed_type(aiohttp_client, event_loop): | ||||
| 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,13 +1,7 @@ | ||||
| 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 | ||||
| from datetime import datetime | ||||
| import json | ||||
|  | ||||
|  | ||||
| class JSONEncoder(json.JSONEncoder): | ||||
| @@ -25,43 +19,8 @@ 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, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -70,24 +29,17 @@ async def test_get_article_without_required_header_should_return_an_error_messag | ||||
|     resp = await client.get("/article", headers={}) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     result = await resp.json() | ||||
|     assert len(result) == 1 | ||||
|     result[0].pop('input') | ||||
|  | ||||
|     assert result == [ | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'type': 'missing', | ||||
|             'loc': ['signature_expired'], | ||||
|             'msg': 'Field required', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing', | ||||
|             'loc_in': 'headers' | ||||
|             "loc": ["signature_expired"], | ||||
|             "msg": "field required", | ||||
|             "type": "value_error.missing", | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_wrong_header_type_should_return_an_error_message( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -96,22 +48,17 @@ async def test_get_article_with_wrong_header_type_should_return_an_error_message | ||||
|     resp = await client.get("/article", headers={"signature_expired": "foo"}) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'type': 'datetime_parsing', | ||||
|             'loc': ['signature_expired'], | ||||
|             'msg': 'Input should be a valid datetime, input is too short', | ||||
|             'input': 'foo', | ||||
|             'ctx': {'error': 'input is too short'}, | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/datetime_parsing', | ||||
|             'loc_in': 'headers' | ||||
|             "loc": ["signature_expired"], | ||||
|             "msg": "invalid datetime format", | ||||
|             "type": "value_error.datetime", | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_valid_header_should_return_the_parsed_type( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -126,7 +73,7 @@ async def test_get_article_with_valid_header_should_return_the_parsed_type( | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_valid_header_containing_hyphen_should_be_returned( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -138,57 +85,3 @@ 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, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/coord", ViewWithEnumType) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.get("/coord", headers={"format": "WGS84"}) | ||||
|  | ||||
|     assert ( | ||||
|             await resp.json() | ||||
|             == [ | ||||
|                 { | ||||
|                     'ctx': {'expected': "'UMT' or 'MGRS'"}, | ||||
|                     'input': 'WGS84', | ||||
|                     'loc': ['format'], | ||||
|                     'loc_in': 'headers', | ||||
|                     'msg': "Input should be 'UMT' or 'MGRS'", | ||||
|                     'type': 'enum' | ||||
|                 } | ||||
|             ] | ||||
|             != {"signature": "2020-10-04T18:01:00"} | ||||
|     ) | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/coord", ViewWithEnumType) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.get("/coord", headers={"format": "UMT"}) | ||||
|     assert await resp.json() == {"format": "UMT"} | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_with_signature_group(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithSignatureGroup) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.get( | ||||
|         "/article", | ||||
|         headers={ | ||||
|             "signature_expired": "2020-10-04T18:01:00", | ||||
|             "signature.scope": "write", | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|     assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"} | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|   | ||||
| @@ -1,7 +1,4 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from aiohttp import web | ||||
|  | ||||
| from aiohttp_pydantic import PydanticView | ||||
|  | ||||
|  | ||||
| @@ -10,8 +7,8 @@ class ArticleView(PydanticView): | ||||
|         return web.json_response({"path": [author_id, tag, date]}) | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path( | ||||
|     aiohttp_client, event_loop | ||||
| async def test_get_article_without_required_qs_should_return_an_error_message( | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) | ||||
| @@ -21,26 +18,3 @@ async def test_get_article_with_correct_path_parameters_should_return_parameters | ||||
|     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, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|     resp = await client.get("/article/1234/tag/music/before/now") | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': 'now', | ||||
|             'loc': ['date'], | ||||
|             'loc_in': 'path', | ||||
|             'msg': 'Input should be a valid integer, unable to parse string as an integer', | ||||
|             'type': 'int_parsing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/int_parsing' | ||||
|         } | ||||
|     ] | ||||
|   | ||||
| @@ -1,76 +1,14 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| from enum import Enum | ||||
| 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, | ||||
|             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, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class Lang(str, Enum): | ||||
|     EN = 'en' | ||||
|     FR = 'fr' | ||||
|  | ||||
|  | ||||
| class ArticleViewWithEnumInQuery(PydanticView): | ||||
|     async def get(self, lang: Lang): | ||||
|         return web.json_response( | ||||
|             { | ||||
|                 "lang": lang | ||||
|             } | ||||
|         ) | ||||
|     async def get(self, with_comments: bool): | ||||
|         return web.json_response({"with_comments": with_comments}) | ||||
|  | ||||
|  | ||||
| async def test_get_article_without_required_qs_should_return_an_error_message( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -79,21 +17,17 @@ async def test_get_article_without_required_qs_should_return_an_error_message( | ||||
|     resp = await client.get("/article") | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': {}, | ||||
|             'loc': ['with_comments'], | ||||
|             'loc_in': 'query string', | ||||
|             'msg': 'Field required', | ||||
|             'type': 'missing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing' | ||||
|             "loc": ["with_comments"], | ||||
|             "msg": "field required", | ||||
|             "type": "value_error.missing", | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_wrong_qs_type_should_return_an_error_message( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
| @@ -104,195 +38,21 @@ async def test_get_article_with_wrong_qs_type_should_return_an_error_message( | ||||
|     assert resp.content_type == "application/json" | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': 'foo', | ||||
|             'loc': ['with_comments'], | ||||
|             'loc_in': 'query string', | ||||
|             'msg': 'Input should be a valid boolean, unable to interpret input', | ||||
|             'type': 'bool_parsing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/bool_parsing' | ||||
|             "loc": ["with_comments"], | ||||
|             "msg": "value could not be parsed to a boolean", | ||||
|             "type": "type_error.bool", | ||||
|         } | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_valid_qs_should_return_the_parsed_type( | ||||
|         aiohttp_client, event_loop | ||||
|     aiohttp_client, loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     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, | ||||
|         "nb_items": 7, | ||||
|         "tags": [], | ||||
|     } | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_valid_qs_and_omitted_optional_should_return_default_value( | ||||
|         aiohttp_client, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get("/article", params={"with_comments": "yes"}) | ||||
|     assert await resp.json() == { | ||||
|         "with_comments": True, | ||||
|         "age": None, | ||||
|         "nb_items": 7, | ||||
|         "tags": [], | ||||
|     } | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_multiple_value_for_qs_age_must_failed( | ||||
|         aiohttp_client, event_loop | ||||
| ): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get("/article", params={"age": ["2", "3"], "with_comments": 1}) | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': ['2', '3'], | ||||
|             'loc': ['age'], | ||||
|             'loc_in': 'query string', | ||||
|             'msg': 'Input should be a valid integer', | ||||
|             'type': 'int_type', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/int_type' | ||||
|         } | ||||
|     ] | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_multiple_value_of_tags(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get( | ||||
|         "/article", params={"age": 2, "with_comments": 1, "tags": ["aa", "bb"]} | ||||
|     ) | ||||
|     assert await resp.json() == { | ||||
|         "age": 2, | ||||
|         "nb_items": 7, | ||||
|         "tags": ["aa", "bb"], | ||||
|         "with_comments": True, | ||||
|     } | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_one_value_of_tags_must_be_a_list(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleView) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get( | ||||
|         "/article", params={"age": 2, "with_comments": 1, "tags": ["aa"]} | ||||
|     ) | ||||
|     assert await resp.json() == { | ||||
|         "age": 2, | ||||
|         "nb_items": 7, | ||||
|         "tags": ["aa"], | ||||
|         "with_comments": True, | ||||
|     } | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_without_required_field_page(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get("/article", params={"with_comments": 1}) | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': {'with_comments': '1'}, | ||||
|             'loc': ['page_num'], | ||||
|             'loc_in': 'query string', | ||||
|             'msg': 'Field required', | ||||
|             'type': 'missing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing' | ||||
|         } | ||||
|     ] | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_page(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get("/article", params={"with_comments": 1, "page_num": 2}) | ||||
|     assert await resp.json() == {"page_num": 2, "page_size": 20, "with_comments": True} | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_page_and_page_size(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get( | ||||
|         "/article", params={"with_comments": 1, "page_num": 1, "page_size": 10} | ||||
|     ) | ||||
|     assert await resp.json() == {"page_num": 1, "page_size": 10, "with_comments": True} | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_enum_in_query(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithEnumInQuery) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get( | ||||
|         "/article", params={"lang": Lang.EN.value} | ||||
|     ) | ||||
|     assert await resp.json() == {'lang': Lang.EN} | ||||
|     assert resp.status == 200 | ||||
|     assert resp.content_type == "application/json" | ||||
|  | ||||
|  | ||||
| async def test_get_article_with_page_and_wrong_page_size(aiohttp_client, event_loop): | ||||
|     app = web.Application() | ||||
|     app.router.add_view("/article", ArticleViewWithPaginationGroup) | ||||
|  | ||||
|     client = await aiohttp_client(app) | ||||
|  | ||||
|     resp = await client.get( | ||||
|         "/article", params={"with_comments": 1, "page_num": 1, "page_size": "large"} | ||||
|     ) | ||||
|     assert await resp.json() == [ | ||||
|         { | ||||
|             'input': 'large', | ||||
|             'loc': ['page_size'], | ||||
|             'loc_in': 'query string', | ||||
|             'msg': 'Input should be a valid integer, unable to parse string as an ' | ||||
|                    'integer', | ||||
|             'type': 'int_parsing', | ||||
|             'url': 'https://errors.pydantic.dev/2.3/v/int_parsing' | ||||
|         } | ||||
|     ] | ||||
|     assert resp.status == 400 | ||||
|     assert resp.content_type == "application/json" | ||||
|     assert await resp.json() == {"with_comments": True} | ||||
|   | ||||
		Reference in New Issue
	
	Block a user