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/ | .idea/ | ||||||
| .pypirc |  | ||||||
| .pytest_cache | .pytest_cache | ||||||
| __pycache__ | __pycache__ | ||||||
| aiohttp_pydantic.egg-info/ | aiohttp_pydantic.egg-info/ | ||||||
| build/ | build/ | ||||||
| coverage.xml |  | ||||||
| dist/ | dist/ | ||||||
| dist_venv/ |  | ||||||
| venv/ |  | ||||||
| @@ -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 | Aiohttp pydantic - Aiohttp View to validate and parse request | ||||||
| ============================================================= | ============================================================= | ||||||
|  |  | ||||||
| .. image:: https://cloud.drone.io/api/badges/Maillol/aiohttp-pydantic/status.svg |  | ||||||
|   :target: https://cloud.drone.io/Maillol/aiohttp-pydantic |  | ||||||
|   :alt: Build status for master branch |  | ||||||
|  |  | ||||||
| .. image:: https://img.shields.io/pypi/v/aiohttp-pydantic |  | ||||||
|   :target: https://img.shields.io/pypi/v/aiohttp-pydantic |  | ||||||
|   :alt: Latest PyPI package version |  | ||||||
|  |  | ||||||
| .. image:: https://codecov.io/gh/Maillol/aiohttp-pydantic/branch/main/graph/badge.svg |  | ||||||
|   :target: https://codecov.io/gh/Maillol/aiohttp-pydantic |  | ||||||
|   :alt: codecov.io status for master branch |  | ||||||
|  |  | ||||||
| Aiohttp pydantic is an `aiohttp view`_ to easily parse and validate request. |  | ||||||
| You define using the function annotations what your methods for handling HTTP verbs expects and Aiohttp pydantic parses the HTTP request |  | ||||||
| for you, validates the data, and injects that you want as parameters. |  | ||||||
|  |  | ||||||
|  |  | ||||||
| Features: |  | ||||||
|  |  | ||||||
| - Query string, request body, URL path and HTTP headers validation. |  | ||||||
| - Open API Specification generation. |  | ||||||
|  |  | ||||||
|  |  | ||||||
| How to install | How to install | ||||||
| -------------- | -------------- | ||||||
|  |  | ||||||
| @@ -55,7 +32,7 @@ Example: | |||||||
|             return web.json_response({'name': article.name, |             return web.json_response({'name': article.name, | ||||||
|                                       'number_of_page': article.nb_page}) |                                       'number_of_page': article.nb_page}) | ||||||
|  |  | ||||||
|         async def get(self, with_comments: bool=False): |         async def get(self, with_comments: Optional[bool]): | ||||||
|             return web.json_response({'with_comments': with_comments}) |             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 |     $ curl -X GET http://127.0.0.1:8080/article?with_comments=a | ||||||
|     [ |     [ | ||||||
|       { |       { | ||||||
|         "in": "query string", |  | ||||||
|         "loc": [ |         "loc": [ | ||||||
|           "with_comments" |           "with_comments" | ||||||
|         ], |         ], | ||||||
| @@ -84,7 +60,6 @@ Example: | |||||||
|     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' |     $ curl -H "Content-Type: application/json" -X post http://127.0.0.1:8080/article --data '{}' | ||||||
|     [ |     [ | ||||||
|       { |       { | ||||||
|         "in": "body", |  | ||||||
|         "loc": [ |         "loc": [ | ||||||
|           "name" |           "name" | ||||||
|         ], |         ], | ||||||
| @@ -102,7 +77,7 @@ API: | |||||||
| Inject Path Parameters | 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: | Example: | ||||||
| @@ -119,40 +94,22 @@ Example: | |||||||
| Inject Query String Parameters | 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 | .. code-block:: python3 | ||||||
|  |  | ||||||
|     class AccountView(PydanticView): |     class AccountView(PydanticView): | ||||||
|         async def get(self, customer_id: Optional[str] = None): |         async def get(self, customer_id: str): | ||||||
|             ... |             ... | ||||||
|  |  | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_get('/customers', AccountView) |     app.router.add_get('/customers', AccountView) | ||||||
|  |  | ||||||
|  |  | ||||||
| A query string parameter is generally optional and we do not want to force the user to set it in the URL. |  | ||||||
| It's recommended to define a default value. It's possible to get a multiple value for the same parameter using |  | ||||||
| the List type |  | ||||||
|  |  | ||||||
| .. code-block:: python3 |  | ||||||
|  |  | ||||||
|     from typing import List |  | ||||||
|     from pydantic import Field |  | ||||||
|  |  | ||||||
|     class AccountView(PydanticView): |  | ||||||
|         async def get(self, tags: List[str] = Field(default_factory=list)): |  | ||||||
|             ... |  | ||||||
|  |  | ||||||
|     app = web.Application() |  | ||||||
|     app.router.add_get('/customers', AccountView) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| Inject Request Body | Inject Request Body | ||||||
| ~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~ | ||||||
|  |  | ||||||
| To declare a body 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 | .. code-block:: python3 | ||||||
| @@ -171,7 +128,7 @@ To declare a body parameter, you must declare your argument as a simple argument | |||||||
| Inject HTTP headers | 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 | .. 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/ | .. _pydantic Model: https://pydantic-docs.helpmanual.io/usage/models/ | ||||||
| .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | .. _keyword-only argument: https://www.python.org/dev/peps/pep-3102/ | ||||||
|  |  | ||||||
| Add route to generate Open Api Specification (OAS) | Add route to generate Open Api Specification | ||||||
| -------------------------------------------------- | -------------------------------------------- | ||||||
|  |  | ||||||
| aiohttp_pydantic provides a sub-application to serve a 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 | 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') |     oas.setup(app, url_prefix='/spec-api') | ||||||
|  |  | ||||||
| If you want generate the Open Api Specification from specific aiohttp sub-applications. | If you want generate the Open Api Specification from several aiohttp sub-application. | ||||||
| on the same route, you must use *apps_to_expose* parameter. | on the same route, you must use *apps_to_expose* parameters | ||||||
|  |  | ||||||
|  |  | ||||||
| .. code-block:: python3 | .. code-block:: python3 | ||||||
| @@ -222,186 +179,11 @@ on the same route, you must use *apps_to_expose* parameter. | |||||||
|  |  | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     sub_app_1 = 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 | Demo | ||||||
| ---- | ==== | ||||||
|  |  | ||||||
| Have a look at `demo`_ for a complete example | 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 | 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 | .. _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 | from .view import PydanticView | ||||||
|  |  | ||||||
| __version__ = "1.12.1" | __version__ = "1.1.0" | ||||||
|  |  | ||||||
| __all__ = ("PydanticView", "__version__") | __all__ = ("PydanticView", "__version__") | ||||||
| @@ -1,18 +1,11 @@ | |||||||
| import abc | from typing import Callable, Tuple | ||||||
| import typing |  | ||||||
| from inspect import signature, getmro |  | ||||||
| from json.decoder import JSONDecodeError |  | ||||||
| from types import SimpleNamespace |  | ||||||
| from typing import Callable, Tuple, Literal, Type, get_type_hints |  | ||||||
|  |  | ||||||
| from aiohttp.web_exceptions import HTTPBadRequest |  | ||||||
| from aiohttp.web_request import BaseRequest | from aiohttp.web_request import BaseRequest | ||||||
| from multidict import MultiDict |  | ||||||
| from pydantic import BaseModel | 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): | 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. |     An injector parse HTTP request and inject params to the view. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     model: Type[BaseModel] |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     @abc.abstractmethod |     @abc.abstractmethod | ||||||
|     def context(self) -> CONTEXT: |     def __init__(self, args_spec: dict): | ||||||
|         """ |  | ||||||
|         The name of part of parsed request |  | ||||||
|         i.e "HTTP header", "URL path", ... |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|     @abc.abstractmethod |  | ||||||
|     def __init__(self, args_spec: dict, default_values: dict): |  | ||||||
|         """ |         """ | ||||||
|         args_spec - ordered mapping: arg_name -> type |         args_spec - ordered mapping: arg_name -> type | ||||||
|         """ |         """ | ||||||
| @@ -48,12 +31,8 @@ class MatchInfoGetter(AbstractInjector): | |||||||
|     Validates and injects the part of URL path inside the view positional args. |     Validates and injects the part of URL path inside the view positional args. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     context = "path" |     def __init__(self, args_spec: dict): | ||||||
|  |         self.model = type("PathModel", (BaseModel,), {"__annotations__": args_spec}) | ||||||
|     def __init__(self, args_spec: dict, default_values: dict): |  | ||||||
|         attrs = {"__annotations__": args_spec} |  | ||||||
|         attrs.update(default_values) |  | ||||||
|         self.model = type("PathModel", (BaseModel,), attrs) |  | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         args_view.extend(self.model(**request.match_info).dict().values()) |         args_view.extend(self.model(**request.match_info).dict().values()) | ||||||
| @@ -64,33 +43,12 @@ class BodyGetter(AbstractInjector): | |||||||
|     Validates and injects the content of request body inside the view kwargs. |     Validates and injects the content of request body inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     context = "body" |     def __init__(self, args_spec: dict): | ||||||
|  |  | ||||||
|     def __init__(self, args_spec: dict, default_values: dict): |  | ||||||
|         self.arg_name, self.model = next(iter(args_spec.items())) |         self.arg_name, self.model = next(iter(args_spec.items())) | ||||||
|         schema = self.model.model_json_schema() |  | ||||||
|         if "type" not in schema: |  | ||||||
|             schema["type"] = "object" |  | ||||||
|         self._expect_object = schema["type"] == "object" |  | ||||||
|  |  | ||||||
|     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     async def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         try: |  | ||||||
|         body = await request.json() |         body = await request.json() | ||||||
|         except JSONDecodeError: |         kwargs_view[self.arg_name] = self.model(**body) | ||||||
|             raise HTTPBadRequest( |  | ||||||
|                 text='{"error": "Malformed JSON"}', content_type="application/json" |  | ||||||
|             ) from None |  | ||||||
|  |  | ||||||
|         # Pydantic tries to cast certain structures, such as a list of 2-tuples, |  | ||||||
|         # to a dict. Prevent this by requiring the body to be a dict for object models. |  | ||||||
|         if self._expect_object and not isinstance(body, dict): |  | ||||||
|             raise HTTPBadRequest( |  | ||||||
|                 text='[{"loc_in": "body", "loc": ["root"], "msg": "value is not a ' |  | ||||||
|                 'valid dict", "type": "type_error.dict"}]', |  | ||||||
|                 content_type="application/json", |  | ||||||
|             ) from None |  | ||||||
|  |  | ||||||
|         kwargs_view[self.arg_name] = self.model.parse_obj(body) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class QueryGetter(AbstractInjector): | class QueryGetter(AbstractInjector): | ||||||
| @@ -98,49 +56,11 @@ class QueryGetter(AbstractInjector): | |||||||
|     Validates and injects the query string inside the view kwargs. |     Validates and injects the query string inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     context = "query string" |     def __init__(self, args_spec: dict): | ||||||
|  |         self.model = type("QueryModel", (BaseModel,), {"__annotations__": args_spec}) | ||||||
|     def __init__(self, args_spec: dict, default_values: dict): |  | ||||||
|         args_spec = args_spec.copy() |  | ||||||
|  |  | ||||||
|         self._groups = {} |  | ||||||
|         for group_name, group in args_spec.items(): |  | ||||||
|             if robuste_issubclass(group, Group): |  | ||||||
|                 self._groups[group_name] = (group, _get_group_signature(group)[0]) |  | ||||||
|  |  | ||||||
|         _unpack_group_in_signature(args_spec, default_values) |  | ||||||
|         attrs = {"__annotations__": args_spec} |  | ||||||
|         attrs.update(default_values) |  | ||||||
|  |  | ||||||
|         self.model = type("QueryModel", (BaseModel,), attrs) |  | ||||||
|         self.args_spec = args_spec |  | ||||||
|         self._is_multiple = frozenset( |  | ||||||
|             name for name, spec in args_spec.items() if typing.get_origin(spec) is list |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         data = self._query_to_dict(request.query) |         kwargs_view.update(self.model(**request.query).dict()) | ||||||
|         cleaned = self.model(**data).dict() |  | ||||||
|         for group_name, (group_cls, group_attrs) in self._groups.items(): |  | ||||||
|             group = group_cls() |  | ||||||
|             for attr_name in group_attrs: |  | ||||||
|                 setattr(group, attr_name, cleaned.pop(attr_name)) |  | ||||||
|             cleaned[group_name] = group |  | ||||||
|         kwargs_view.update(**cleaned) |  | ||||||
|  |  | ||||||
|     def _query_to_dict(self, query: MultiDict): |  | ||||||
|         """ |  | ||||||
|         Return a dict with list as value from the MultiDict. |  | ||||||
|  |  | ||||||
|         The value will be wrapped in a list if the args spec is define as a list or if |  | ||||||
|         the multiple values are sent (i.e ?foo=1&foo=2) |  | ||||||
|         """ |  | ||||||
|         return { |  | ||||||
|             key: values |  | ||||||
|             if len(values := query.getall(key)) > 1 or key in self._is_multiple |  | ||||||
|             else value |  | ||||||
|             for key, value in query.items() |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class HeadersGetter(AbstractInjector): | class HeadersGetter(AbstractInjector): | ||||||
| @@ -148,162 +68,45 @@ class HeadersGetter(AbstractInjector): | |||||||
|     Validates and injects the HTTP headers inside the view kwargs. |     Validates and injects the HTTP headers inside the view kwargs. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     context = "headers" |     def __init__(self, args_spec: dict): | ||||||
|  |         self.model = type("HeaderModel", (BaseModel,), {"__annotations__": args_spec}) | ||||||
|     def __init__(self, args_spec: dict, default_values: dict): |  | ||||||
|         args_spec = args_spec.copy() |  | ||||||
|  |  | ||||||
|         self._groups = {} |  | ||||||
|         for group_name, group in args_spec.items(): |  | ||||||
|             if robuste_issubclass(group, Group): |  | ||||||
|                 self._groups[group_name] = (group, _get_group_signature(group)[0]) |  | ||||||
|  |  | ||||||
|         _unpack_group_in_signature(args_spec, default_values) |  | ||||||
|  |  | ||||||
|         attrs = {"__annotations__": args_spec} |  | ||||||
|         attrs.update(default_values) |  | ||||||
|         self.model = type("HeaderModel", (BaseModel,), attrs) |  | ||||||
|  |  | ||||||
|     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): |     def inject(self, request: BaseRequest, args_view: list, kwargs_view: dict): | ||||||
|         header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} |         header = {k.lower().replace("-", "_"): v for k, v in request.headers.items()} | ||||||
|         cleaned = self.model(**header).dict() |         kwargs_view.update(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) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Group(SimpleNamespace): | def _parse_func_signature(func: Callable) -> Tuple[dict, dict, dict, dict]: | ||||||
|     """ |     """ | ||||||
|     Class to group header or query string parameters. |     Analyse function signature and returns 4-tuple: | ||||||
|  |  | ||||||
|     The parameter from query string or header will be set in the group |  | ||||||
|     and the group will be passed as function parameter. |  | ||||||
|  |  | ||||||
|     Example: |  | ||||||
|  |  | ||||||
|     class Pagination(Group): |  | ||||||
|         current_page: int = 1 |  | ||||||
|         page_size: int = 15 |  | ||||||
|  |  | ||||||
|     class PetView(PydanticView): |  | ||||||
|         def get(self, page: Pagination): |  | ||||||
|             ... |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _get_group_signature(cls) -> Tuple[dict, dict]: |  | ||||||
|     """ |  | ||||||
|     Analyse Group subclass annotations and return them with default values. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     sig = {} |  | ||||||
|     defaults = {} |  | ||||||
|     mro = getmro(cls) |  | ||||||
|     for base in reversed(mro[: mro.index(Group)]): |  | ||||||
|         attrs = vars(base) |  | ||||||
|  |  | ||||||
|         # Use __annotations__ to know if an attribute is |  | ||||||
|         # overwrite to remove the default value. |  | ||||||
|         for attr_name, type_ in base.__annotations__.items(): |  | ||||||
|             if (default := attrs.get(attr_name)) is None: |  | ||||||
|                 defaults.pop(attr_name, None) |  | ||||||
|             else: |  | ||||||
|                 defaults[attr_name] = default |  | ||||||
|  |  | ||||||
|         # Use get_type_hints to have postponed annotations. |  | ||||||
|         for attr_name, type_ in get_type_hints(base).items(): |  | ||||||
|             sig[attr_name] = type_ |  | ||||||
|  |  | ||||||
|     return sig, defaults |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _parse_func_signature( |  | ||||||
|     func: Callable, unpack_group: bool = False |  | ||||||
| ) -> Tuple[dict, dict, dict, dict, dict]: |  | ||||||
|     """ |  | ||||||
|     Analyse function signature and returns 5-tuple: |  | ||||||
|         0 - arguments will be set from the url path |         0 - arguments will be set from the url path | ||||||
|         1 - argument will be set from the request body. |         1 - argument will be set from the request body. | ||||||
|         2 - argument will be set from the query string. |         2 - argument will be set from the query string. | ||||||
|         3 - argument will be set from the HTTP headers. |         3 - argument will be set from the HTTP headers. | ||||||
|         4 - Default value for each parameters |  | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     path_args = {} |     path_args = {} | ||||||
|     body_args = {} |     body_args = {} | ||||||
|     qs_args = {} |     qs_args = {} | ||||||
|     header_args = {} |     header_args = {} | ||||||
|     defaults = {} |  | ||||||
|  |  | ||||||
|     annotations = get_type_hints(func) |  | ||||||
|     for param_name, param_spec in signature(func).parameters.items(): |     for param_name, param_spec in signature(func).parameters.items(): | ||||||
|  |  | ||||||
|         if param_name == "self": |         if param_name == "self": | ||||||
|             continue |             continue | ||||||
|  |  | ||||||
|         if param_spec.annotation == param_spec.empty: |         if param_spec.annotation == param_spec.empty: | ||||||
|             raise RuntimeError(f"The parameter {param_name} must have an annotation") |             raise RuntimeError(f"The parameter {param_name} must have an annotation") | ||||||
|  |  | ||||||
|         annotation = annotations[param_name] |  | ||||||
|         if param_spec.default is not param_spec.empty: |  | ||||||
|             defaults[param_name] = param_spec.default |  | ||||||
|  |  | ||||||
|         if param_spec.kind is param_spec.POSITIONAL_ONLY: |         if param_spec.kind is param_spec.POSITIONAL_ONLY: | ||||||
|             path_args[param_name] = annotation |             path_args[param_name] = param_spec.annotation | ||||||
|  |  | ||||||
|         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: |         elif param_spec.kind is param_spec.POSITIONAL_OR_KEYWORD: | ||||||
|             if is_pydantic_base_model(annotation): |             if issubclass(param_spec.annotation, BaseModel): | ||||||
|                 body_args[param_name] = annotation |                 body_args[param_name] = param_spec.annotation | ||||||
|             else: |             else: | ||||||
|                 qs_args[param_name] = annotation |                 qs_args[param_name] = param_spec.annotation | ||||||
|         elif param_spec.kind is param_spec.KEYWORD_ONLY: |         elif param_spec.kind is param_spec.KEYWORD_ONLY: | ||||||
|             header_args[param_name] = annotation |             header_args[param_name] = param_spec.annotation | ||||||
|         else: |         else: | ||||||
|             raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") |             raise RuntimeError(f"You cannot use {param_spec.VAR_POSITIONAL} parameters") | ||||||
|  |  | ||||||
|     if unpack_group: |     return path_args, body_args, qs_args, header_args | ||||||
|         try: |  | ||||||
|             _unpack_group_in_signature(qs_args, defaults) |  | ||||||
|             _unpack_group_in_signature(header_args, defaults) |  | ||||||
|         except DuplicateNames as error: |  | ||||||
|             raise TypeError( |  | ||||||
|                 f"Parameters conflict in function {func}," |  | ||||||
|                 f" the group {error.group} has an attribute named {error.attr_name}" |  | ||||||
|             ) from None |  | ||||||
|  |  | ||||||
|     return path_args, body_args, qs_args, header_args, defaults |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class DuplicateNames(Exception): |  | ||||||
|     """ |  | ||||||
|     Raised when a same parameter name is used in group and function signature. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     group: Type[Group] |  | ||||||
|     attr_name: str |  | ||||||
|  |  | ||||||
|     def __init__(self, group: Type[Group], attr_name: str): |  | ||||||
|         self.group = group |  | ||||||
|         self.attr_name = attr_name |  | ||||||
|         super().__init__( |  | ||||||
|             f"Conflict with {group}.{attr_name} and function parameter name" |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _unpack_group_in_signature(args: dict, defaults: dict) -> None: |  | ||||||
|     """ |  | ||||||
|     Unpack in place each Group found in args. |  | ||||||
|     """ |  | ||||||
|     for group_name, group in args.copy().items(): |  | ||||||
|         if robuste_issubclass(group, Group): |  | ||||||
|             group_sig, group_default = _get_group_signature(group) |  | ||||||
|             for attr_name in group_sig: |  | ||||||
|                 if attr_name in args and attr_name != group_name: |  | ||||||
|                     raise DuplicateNames(group, attr_name) |  | ||||||
|  |  | ||||||
|             del args[group_name] |  | ||||||
|             args.update(group_sig) |  | ||||||
|             defaults.update(group_default) |  | ||||||
|   | |||||||
| @@ -1,11 +1,10 @@ | |||||||
|  | from typing import Iterable | ||||||
| from importlib import resources | from importlib import resources | ||||||
| from typing import Iterable, Optional |  | ||||||
|  |  | ||||||
| import jinja2 | import jinja2 | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
| from swagger_ui_bundle import swagger_ui_path |  | ||||||
|  |  | ||||||
| from .view import get_oas, oas_ui | from .view import get_oas, oas_ui | ||||||
|  | from swagger_ui_bundle import swagger_ui_path | ||||||
|  |  | ||||||
|  |  | ||||||
| def setup( | def setup( | ||||||
| @@ -13,19 +12,13 @@ def setup( | |||||||
|     apps_to_expose: Iterable[web.Application] = (), |     apps_to_expose: Iterable[web.Application] = (), | ||||||
|     url_prefix: str = "/oas", |     url_prefix: str = "/oas", | ||||||
|     enable: bool = True, |     enable: bool = True, | ||||||
|     version_spec: Optional[str] = None, |  | ||||||
|     title_spec: Optional[str] = None, |  | ||||||
|     custom_template: Optional[jinja2.Template] = None |  | ||||||
| ): | ): | ||||||
|     if enable: |     if enable: | ||||||
|         oas_app = web.Application() |         oas_app = web.Application() | ||||||
|         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) |         oas_app["apps to expose"] = tuple(apps_to_expose) or (app,) | ||||||
|         oas_app["index template"] = custom_template or jinja2.Template( |         oas_app["index template"] = jinja2.Template( | ||||||
|             resources.read_text("aiohttp_pydantic.oas", "index.j2") |             resources.read_text("aiohttp_pydantic.oas", "index.j2") | ||||||
|         ) |         ) | ||||||
|         oas_app["version_spec"] = version_spec |  | ||||||
|         oas_app["title_spec"] = title_spec |  | ||||||
|  |  | ||||||
|         oas_app.router.add_get("/spec", get_oas, name="spec") |         oas_app.router.add_get("/spec", get_oas, name="spec") | ||||||
|         oas_app.router.add_static("/static", swagger_ui_path, name="static") |         oas_app.router.add_static("/static", swagger_ui_path, name="static") | ||||||
|         oas_app.router.add_get("", oas_ui, name="index") |         oas_app.router.add_get("", oas_ui, name="index") | ||||||
|   | |||||||
| @@ -1,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> | <!DOCTYPE html> | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
|   <head> |   <head> | ||||||
|     <meta charset="UTF-8"> |     <meta charset="UTF-8"> | ||||||
|     <title>{{ title | default('Swagger UI') }}</title> |     <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('/') }}/swagger-ui.css" > | ||||||
|     <link rel="stylesheet" type="text/css" href="{{ static_url | trim('/') }}/index.css" /> |  | ||||||
|     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" /> |     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-32x32.png" sizes="32x32" /> | ||||||
|     <link rel="icon" type="image/png" href="{{ static_url | trim('/') }}/favicon-16x16.png" sizes="16x16" /> |     <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> |   </head> | ||||||
|  |  | ||||||
|   <body> |   <body> | ||||||
|     <div id="swagger-ui"></div> |     <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> |     <script> | ||||||
|     window.onload = function() { |     window.onload = function() { | ||||||
|       // Begin Swagger UI call region |       // Begin Swagger UI call region | ||||||
|       const ui = SwaggerUIBundle({ |       const ui = SwaggerUIBundle({ | ||||||
|         url: "{{ openapi_spec_url }}", |         url: "{{ openapi_spec_url }}", | ||||||
|         {% if urls is defined %} |  | ||||||
|         urls: {{ urls|tojson|safe }}, |  | ||||||
|         {% endif %} |  | ||||||
|         validatorUrl: {{ validatorUrl | default('null') }}, |         validatorUrl: {{ validatorUrl | default('null') }}, | ||||||
|         {% if configUrl is defined %} |         {% if configUrl is defined %} | ||||||
|         configUrl: "{{ configUrl }}", |         configUrl: "{{ configUrl }}", | ||||||
| @@ -36,15 +54,16 @@ | |||||||
|           SwaggerUIBundle.plugins.DownloadUrl |           SwaggerUIBundle.plugins.DownloadUrl | ||||||
|         ], |         ], | ||||||
|         layout: "StandaloneLayout" |         layout: "StandaloneLayout" | ||||||
|       }); |       }) | ||||||
|       {% if initOAuth is defined %} |       {% if initOAuth is defined %} | ||||||
|       ui.initOAuth( |       ui.initOAuth( | ||||||
|         {{ initOAuth|tojson|safe }} |         {{ initOAuth|tojson|safe }} | ||||||
|       ) |       ) | ||||||
|       {% endif %} |       {% endif %} | ||||||
|       // End Swagger UI call region |       // End Swagger UI call region | ||||||
|       window.ui = ui; |  | ||||||
|     }; |       window.ui = ui | ||||||
|  |     } | ||||||
|   </script> |   </script> | ||||||
|   </body> |   </body> | ||||||
| </html> | </html> | ||||||
| @@ -1,17 +1,10 @@ | |||||||
| """ |  | ||||||
| Utility to write Open Api Specifications using the Python language. |  | ||||||
| """ |  | ||||||
|  |  | ||||||
| from typing import Union, List |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Info: | class Info: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
|         self._spec = spec.setdefault("info", {}) |         self._spec = spec.setdefault("info", {}) | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def title(self): |     def title(self): | ||||||
|         return self._spec.get("title") |         return self._spec["title"] | ||||||
|  |  | ||||||
|     @title.setter |     @title.setter | ||||||
|     def title(self, title): |     def title(self, title): | ||||||
| @@ -19,7 +12,7 @@ class Info: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def description(self): |     def description(self): | ||||||
|         return self._spec.get("description") |         return self._spec["description"] | ||||||
|  |  | ||||||
|     @description.setter |     @description.setter | ||||||
|     def description(self, description): |     def description(self, description): | ||||||
| @@ -27,20 +20,12 @@ class Info: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def version(self): |     def version(self): | ||||||
|         return self._spec.get("version") |         return self._spec["version"] | ||||||
|  |  | ||||||
|     @version.setter |     @version.setter | ||||||
|     def version(self, version): |     def version(self, version): | ||||||
|         self._spec["version"] = 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: | class RequestBody: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
| @@ -55,8 +40,8 @@ class RequestBody: | |||||||
|         self._spec["description"] = description |         self._spec["description"] = description | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def required(self) -> bool: |     def required(self): | ||||||
|         return self._spec.get("required", False) |         return self._spec["required"] | ||||||
|  |  | ||||||
|     @required.setter |     @required.setter | ||||||
|     def required(self, required: bool): |     def required(self, required: bool): | ||||||
| @@ -130,40 +115,6 @@ class Parameters: | |||||||
|         return Parameter(spec) |         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: | class OperationObject: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
|         self._spec = spec |         self._spec = spec | ||||||
| @@ -192,32 +143,6 @@ class OperationObject: | |||||||
|     def parameters(self) -> Parameters: |     def parameters(self) -> Parameters: | ||||||
|         return Parameters(self._spec) |         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: | class PathItem: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
| @@ -255,22 +180,6 @@ class PathItem: | |||||||
|     def trace(self) -> OperationObject: |     def trace(self) -> OperationObject: | ||||||
|         return OperationObject(self._spec.setdefault("trace", {})) |         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: | class Paths: | ||||||
|     def __init__(self, spec: dict): |     def __init__(self, spec: dict): | ||||||
| @@ -295,7 +204,7 @@ class Server: | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def description(self) -> str: |     def description(self) -> str: | ||||||
|         return self._spec["description"] |         return self._spec["url"] | ||||||
|  |  | ||||||
|     @description.setter |     @description.setter | ||||||
|     def description(self, description: str): |     def description(self, description: str): | ||||||
| @@ -316,21 +225,9 @@ class Servers: | |||||||
|         return Server(spec) |         return Server(spec) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Components: |  | ||||||
|     def __init__(self, spec: dict): |  | ||||||
|         self._spec = spec.setdefault("components", {}) |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def schemas(self) -> dict: |  | ||||||
|         return self._spec.setdefault("schemas", {}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class OpenApiSpec3: | class OpenApiSpec3: | ||||||
|     def __init__(self): |     def __init__(self): | ||||||
|         self._spec = { |         self._spec = {"openapi": "3.0.0"} | ||||||
|             "openapi": "3.0.0", |  | ||||||
|             "info": {"version": "1.0.0", "title": "Aiohttp pydantic application"}, |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def info(self) -> Info: |     def info(self) -> Info: | ||||||
| @@ -344,10 +241,6 @@ class OpenApiSpec3: | |||||||
|     def paths(self) -> Paths: |     def paths(self) -> Paths: | ||||||
|         return Paths(self._spec) |         return Paths(self._spec) | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def components(self) -> Components: |  | ||||||
|         return Components(self._spec) |  | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def spec(self): |     def spec(self): | ||||||
|         return self._spec |         return self._spec | ||||||
|   | |||||||
| @@ -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 aiohttp.web import json_response, Response | ||||||
| from inspect import getdoc |  | ||||||
| from itertools import count |  | ||||||
| from typing import List, Type, Optional, get_type_hints |  | ||||||
|  |  | ||||||
| from aiohttp.web import Response, json_response |  | ||||||
| from aiohttp.web_app import Application |  | ||||||
| from pydantic import BaseModel, RootModel |  | ||||||
|  |  | ||||||
| from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | from aiohttp_pydantic.oas.struct import OpenApiSpec3, OperationObject, PathItem | ||||||
| from . import docstring_parser | from typing import Type | ||||||
|  |  | ||||||
| from ..injectors import _parse_func_signature | from ..injectors import _parse_func_signature | ||||||
| from ..utils import is_pydantic_base_model |  | ||||||
| from ..view import PydanticView, is_pydantic_view | from ..view import PydanticView, is_pydantic_view | ||||||
| from .typing import is_status_code_type |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class _OASResponseBuilder: | JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"} | ||||||
|     """ |  | ||||||
|     Parse the type annotated as returned by a function and |  | ||||||
|     generate the OAS operation response. |  | ||||||
|     """ |  | ||||||
|  |  | ||||||
|     def __init__(self, oas: OpenApiSpec3, oas_operation, status_code_descriptions): |  | ||||||
|         self._oas_operation = oas_operation |  | ||||||
|         self._oas = oas |  | ||||||
|         self._status_code_descriptions = status_code_descriptions |  | ||||||
|  |  | ||||||
|     def _handle_pydantic_base_model(self, obj): |  | ||||||
|         if is_pydantic_base_model(obj): |  | ||||||
|             response_schema = obj.schema( |  | ||||||
|                 ref_template="#/components/schemas/{model}" |  | ||||||
|             ).copy() |  | ||||||
|             if def_sub_schemas := response_schema.pop("$defs", None): |  | ||||||
|                 self._oas.components.schemas.update(def_sub_schemas) |  | ||||||
|             self._oas.components.schemas.update({response_schema['title']: response_schema}) |  | ||||||
|             return {'$ref': f'#/components/schemas/{response_schema["title"]}'} |  | ||||||
|         return {} |  | ||||||
|  |  | ||||||
|     def _handle_list(self, obj): |  | ||||||
|         if typing.get_origin(obj) is list: |  | ||||||
|             return { |  | ||||||
|                 "type": "array", |  | ||||||
|                 "items": self._handle_pydantic_base_model(typing.get_args(obj)[0]), |  | ||||||
|             } |  | ||||||
|         return self._handle_pydantic_base_model(obj) |  | ||||||
|  |  | ||||||
|     def _handle_status_code_type(self, obj): |  | ||||||
|         if is_status_code_type(typing.get_origin(obj)): |  | ||||||
|             status_code = typing.get_origin(obj).__name__[1:] |  | ||||||
|             self._oas_operation.responses[status_code].content = { |  | ||||||
|                 "application/json": { |  | ||||||
|                     "schema": self._handle_list(typing.get_args(obj)[0]) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|             desc = self._status_code_descriptions.get(int(status_code)) |  | ||||||
|             if desc: |  | ||||||
|                 self._oas_operation.responses[status_code].description = desc |  | ||||||
|  |  | ||||||
|         elif is_status_code_type(obj): |  | ||||||
|             status_code = obj.__name__[1:] |  | ||||||
|             self._oas_operation.responses[status_code].content = {} |  | ||||||
|             desc = self._status_code_descriptions.get(int(status_code)) |  | ||||||
|             if desc: |  | ||||||
|                 self._oas_operation.responses[status_code].description = desc |  | ||||||
|  |  | ||||||
|     def _handle_union(self, obj): |  | ||||||
|         if typing.get_origin(obj) is typing.Union: |  | ||||||
|             for arg in typing.get_args(obj): |  | ||||||
|                 self._handle_status_code_type(arg) |  | ||||||
|         self._handle_status_code_type(obj) |  | ||||||
|  |  | ||||||
|     def build(self, obj): |  | ||||||
|         self._handle_union(obj) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def _add_http_method_to_oas( | def _add_http_method_to_oas(oas_path: PathItem, method: str, view: Type[PydanticView]): | ||||||
|     oas: OpenApiSpec3, oas_path: PathItem, http_method: str, view: Type[PydanticView] |     method = method.lower() | ||||||
| ): |     mtd: OperationObject = getattr(oas_path, method) | ||||||
|     http_method = http_method.lower() |     handler = getattr(view, method) | ||||||
|     oas_operation: OperationObject = getattr(oas_path, http_method) |     path_args, body_args, qs_args, header_args = _parse_func_signature(handler) | ||||||
|     handler = getattr(view, http_method) |  | ||||||
|     path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( |  | ||||||
|         handler, unpack_group=True |  | ||||||
|     ) |  | ||||||
|     description = getdoc(handler) |  | ||||||
|     if description: |  | ||||||
|         oas_operation.description = docstring_parser.operation(description) |  | ||||||
|         oas_operation.tags = docstring_parser.tags(description) |  | ||||||
|         oas_operation.operation_id = docstring_parser.operation_id(description) |  | ||||||
|         status_code_descriptions = docstring_parser.status_code(description) |  | ||||||
|     else: |  | ||||||
|         status_code_descriptions = {} |  | ||||||
|  |  | ||||||
|     if body_args: |     if body_args: | ||||||
|         body_schema = ( |         mtd.request_body.content = { | ||||||
|             next(iter(body_args.values())) |             "application/json": {"schema": next(iter(body_args.values())).schema()} | ||||||
|             .schema(ref_template="#/components/schemas/{model}") |  | ||||||
|             .copy() |  | ||||||
|         ) |  | ||||||
|         if def_sub_schemas := body_schema.pop("$defs", None): |  | ||||||
|             oas.components.schemas.update(def_sub_schemas) |  | ||||||
|  |  | ||||||
|         oas_operation.request_body.content = { |  | ||||||
|             "application/json": {"schema": body_schema} |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     indexes = count() |     i = 0 | ||||||
|     for args_location, args in ( |     for i, (name, type_) in enumerate(path_args.items()): | ||||||
|         ("path", path_args.items()), |         mtd.parameters[i].required = True | ||||||
|         ("query", qs_args.items()), |         mtd.parameters[i].in_ = "path" | ||||||
|         ("header", header_args.items()), |         mtd.parameters[i].name = name | ||||||
|     ): |         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} | ||||||
|         for name, type_ in args: |  | ||||||
|             i = next(indexes) |  | ||||||
|             oas_operation.parameters[i].in_ = args_location |  | ||||||
|             oas_operation.parameters[i].name = name |  | ||||||
|  |  | ||||||
|             attrs = {"__annotations__": {"root": type_}} |     for i, (name, type_) in enumerate(qs_args.items(), i + 1): | ||||||
|             if name in defaults: |         mtd.parameters[i].required = False | ||||||
|                 attrs["root"] = defaults[name] |         mtd.parameters[i].in_ = "query" | ||||||
|                 oas_operation.parameters[i].required = False |         mtd.parameters[i].name = name | ||||||
|             else: |         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} | ||||||
|                 oas_operation.parameters[i].required = True |  | ||||||
|  |  | ||||||
|             oas_operation.parameters[i].schema = type(name, (RootModel,), attrs).schema( |     for i, (name, type_) in enumerate(header_args.items(), i + 1): | ||||||
|                 ref_template="#/components/schemas/{model}" |         mtd.parameters[i].required = False | ||||||
|             ) |         mtd.parameters[i].in_ = "header" | ||||||
|  |         mtd.parameters[i].name = name | ||||||
|             # move definitions |         mtd.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} | ||||||
|             if def_sub_schemas := oas_operation.parameters[i].schema.pop("$defs", None): |  | ||||||
|                 oas.components.schemas.update(def_sub_schemas) |  | ||||||
|  |  | ||||||
|     return_type = get_type_hints(handler).get("return") |  | ||||||
|     if return_type is not None: |  | ||||||
|         _OASResponseBuilder(oas, oas_operation, status_code_descriptions).build( |  | ||||||
|             return_type |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def generate_oas( | async def get_oas(request): | ||||||
|     apps: List[Application], |  | ||||||
|     version_spec: Optional[str] = None, |  | ||||||
|     title_spec: Optional[str] = None, |  | ||||||
| ) -> dict: |  | ||||||
|     """ |     """ | ||||||
|     Generate and return Open Api Specification from PydanticView in application. |     Generate Open Api Specification from PydanticView in application. | ||||||
|     """ |     """ | ||||||
|  |     apps = request.app["apps to expose"] | ||||||
|     oas = OpenApiSpec3() |     oas = OpenApiSpec3() | ||||||
|  |  | ||||||
|     if version_spec is not None: |  | ||||||
|         oas.info.version = version_spec |  | ||||||
|  |  | ||||||
|     if title_spec is not None: |  | ||||||
|         oas.info.title = title_spec |  | ||||||
|  |  | ||||||
|     for app in apps: |     for app in apps: | ||||||
|         for resources in app.router.resources(): |         for resources in app.router.resources(): | ||||||
|             for resource_route in resources: |             for resource_route in resources: | ||||||
|                 if not is_pydantic_view(resource_route.handler): |                 if is_pydantic_view(resource_route.handler): | ||||||
|                     continue |  | ||||||
|  |  | ||||||
|                     view: Type[PydanticView] = resource_route.handler |                     view: Type[PydanticView] = resource_route.handler | ||||||
|                     info = resource_route.get_info() |                     info = resource_route.get_info() | ||||||
|                     path = oas.paths[info.get("path", info.get("formatter"))] |                     path = oas.paths[info.get("path", info.get("formatter"))] | ||||||
|                     if resource_route.method == "*": |                     if resource_route.method == "*": | ||||||
|                         for method_name in view.allowed_methods: |                         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: |                     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 |     return json_response(oas.spec) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def get_oas(request): |  | ||||||
|     """ |  | ||||||
|     View to generate the Open Api Specification from PydanticView in application. |  | ||||||
|     """ |  | ||||||
|     apps = request.app["apps to expose"] |  | ||||||
|     version_spec = request.app["version_spec"] |  | ||||||
|     title_spec = request.app["title_spec"] |  | ||||||
|     return json_response(generate_oas(apps, version_spec, title_spec)) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def oas_ui(request): | async def oas_ui(request): | ||||||
| @@ -191,8 +71,6 @@ async def oas_ui(request): | |||||||
|  |  | ||||||
|     static_url = request.app.router["static"].url_for(filename="") |     static_url = request.app.router["static"].url_for(filename="") | ||||||
|     spec_url = request.app.router["spec"].url_for() |     spec_url = request.app.router["spec"].url_for() | ||||||
|     if request.scheme != request.headers.get('x-forwarded-proto', request.scheme): |  | ||||||
|         request = request.clone(scheme=request.headers['x-forwarded-proto']) |  | ||||||
|     host = request.url.origin() |     host = request.url.origin() | ||||||
|  |  | ||||||
|     return Response( |     return Response( | ||||||
|   | |||||||
| @@ -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 inspect import iscoroutinefunction | ||||||
| from typing import Any, Callable, Generator, Iterable, Set, ClassVar |  | ||||||
| import warnings |  | ||||||
|  |  | ||||||
| from aiohttp.abc import AbstractView | from aiohttp.abc import AbstractView | ||||||
| from aiohttp.hdrs import METH_ALL | from aiohttp.hdrs import METH_ALL | ||||||
| from aiohttp.web import json_response |  | ||||||
| from aiohttp.web_exceptions import HTTPMethodNotAllowed | from aiohttp.web_exceptions import HTTPMethodNotAllowed | ||||||
| from aiohttp.web_response import StreamResponse | from aiohttp.web_response import StreamResponse | ||||||
| from pydantic import ValidationError | from pydantic import ValidationError | ||||||
|  | from typing import Generator, Any, Callable, Type, Iterable | ||||||
|  | from aiohttp.web import json_response | ||||||
|  | from functools import update_wrapper | ||||||
|  |  | ||||||
| from pydantic_core import ErrorDetails |  | ||||||
|  |  | ||||||
| from .injectors import ( | from .injectors import ( | ||||||
|     AbstractInjector, |  | ||||||
|     BodyGetter, |  | ||||||
|     HeadersGetter, |  | ||||||
|     MatchInfoGetter, |     MatchInfoGetter, | ||||||
|  |     HeadersGetter, | ||||||
|     QueryGetter, |     QueryGetter, | ||||||
|  |     BodyGetter, | ||||||
|  |     AbstractInjector, | ||||||
|     _parse_func_signature, |     _parse_func_signature, | ||||||
|     CONTEXT, |  | ||||||
|     Group, |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PydanticValidationError(ErrorDetails): |  | ||||||
|     loc_in: CONTEXT |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PydanticView(AbstractView): | class PydanticView(AbstractView): | ||||||
|     """ |     """ | ||||||
|     An AIOHTTP View that validate request using function annotations. |     An AIOHTTP View that validate request using function annotations. | ||||||
|     """ |     """ | ||||||
|  |  | ||||||
|     # Allowed HTTP methods; overridden when subclassed. |  | ||||||
|     allowed_methods: ClassVar[Set[str]] = {} |  | ||||||
|  |  | ||||||
|     async def _iter(self) -> StreamResponse: |     async def _iter(self) -> StreamResponse: | ||||||
|         if (method_name := self.request.method) not in self.allowed_methods: |         method = getattr(self, self.request.method.lower(), None) | ||||||
|             self._raise_allowed_methods() |         resp = await method() | ||||||
|         return await getattr(self, method_name.lower())() |         return resp | ||||||
|  |  | ||||||
|     def __await__(self) -> Generator[Any, None, StreamResponse]: |     def __await__(self) -> Generator[Any, None, StreamResponse]: | ||||||
|         return self._iter().__await__() |         return self._iter().__await__() | ||||||
|  |  | ||||||
|     def __init_subclass__(cls, **kwargs) -> None: |     def __init_subclass__(cls, **kwargs): | ||||||
|         """Define allowed methods and decorate handlers. |  | ||||||
|  |  | ||||||
|         Handlers are decorated if and only if they directly bound on the PydanticView class or |  | ||||||
|         PydanticView subclass. This prevents that methods are decorated multiple times and that method |  | ||||||
|         defined in aiohttp.View parent class is decorated. |  | ||||||
|         """ |  | ||||||
|  |  | ||||||
|         cls.allowed_methods = { |         cls.allowed_methods = { | ||||||
|             meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) |             meth_name for meth_name in METH_ALL if hasattr(cls, meth_name.lower()) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         for meth_name in METH_ALL: |         for meth_name in METH_ALL: | ||||||
|             if meth_name.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()) |                 handler = getattr(cls, meth_name.lower()) | ||||||
|                 decorated_handler = inject_params(handler, cls.parse_func_signature) |                 decorated_handler = inject_params(handler, cls.parse_func_signature) | ||||||
|                 setattr(cls, meth_name.lower(), decorated_handler) |                 setattr(cls, meth_name.lower(), decorated_handler) | ||||||
|  |  | ||||||
|     def _raise_allowed_methods(self) -> None: |     async def raise_not_allowed(self): | ||||||
|         raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) |         raise HTTPMethodNotAllowed(self.request.method, self.allowed_methods) | ||||||
|  |  | ||||||
|     def raise_not_allowed(self) -> None: |  | ||||||
|         warnings.warn( |  | ||||||
|             "PydanticView.raise_not_allowed is deprecated and renamed _raise_allowed_methods", |  | ||||||
|             DeprecationWarning, |  | ||||||
|             stacklevel=2, |  | ||||||
|         ) |  | ||||||
|         self._raise_allowed_methods() |  | ||||||
|  |  | ||||||
|     @staticmethod |     @staticmethod | ||||||
|     def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: |     def parse_func_signature(func: Callable) -> Iterable[AbstractInjector]: | ||||||
|         path_args, body_args, qs_args, header_args, defaults = _parse_func_signature( |         path_args, body_args, qs_args, header_args = _parse_func_signature(func) | ||||||
|             func |  | ||||||
|         ) |  | ||||||
|         injectors = [] |         injectors = [] | ||||||
|  |  | ||||||
|         def default_value(args: dict) -> dict: |  | ||||||
|             """ |  | ||||||
|             Returns the default values of args. |  | ||||||
|             """ |  | ||||||
|             return {name: defaults[name] for name in args if name in defaults} |  | ||||||
|  |  | ||||||
|         if path_args: |         if path_args: | ||||||
|             injectors.append(MatchInfoGetter(path_args, default_value(path_args))) |             injectors.append(MatchInfoGetter(path_args)) | ||||||
|         if body_args: |         if body_args: | ||||||
|             injectors.append(BodyGetter(body_args, default_value(body_args))) |             injectors.append(BodyGetter(body_args)) | ||||||
|         if qs_args: |         if qs_args: | ||||||
|             injectors.append(QueryGetter(qs_args, default_value(qs_args))) |             injectors.append(QueryGetter(qs_args)) | ||||||
|         if header_args: |         if header_args: | ||||||
|             injectors.append(HeadersGetter(header_args, default_value(header_args))) |             injectors.append(HeadersGetter(header_args)) | ||||||
|         return injectors |         return injectors | ||||||
|  |  | ||||||
|     async def on_validation_error( |  | ||||||
|             self, exception: ValidationError, context: CONTEXT |  | ||||||
|     ) -> StreamResponse: |  | ||||||
|         """ |  | ||||||
|         This method is a hook to intercept ValidationError. |  | ||||||
|  |  | ||||||
|         This hook can be redefined to return a custom HTTP response error. |  | ||||||
|         The exception is a pydantic.ValidationError and the context is "body", |  | ||||||
|         "headers", "path" or "query string" |  | ||||||
|         """ |  | ||||||
|         errors = exception.errors() |  | ||||||
|         own_errors = [PydanticValidationError(**x, loc_in=context) for x in errors] |  | ||||||
|  |  | ||||||
|         return json_response(data=own_errors, status=400) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def inject_params( | def inject_params( | ||||||
|     handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] |     handler, parse_func_signature: Callable[[Callable], Iterable[AbstractInjector]] | ||||||
| @@ -132,7 +83,7 @@ def inject_params( | |||||||
|                 else: |                 else: | ||||||
|                     injector.inject(self.request, args, kwargs) |                     injector.inject(self.request, args, kwargs) | ||||||
|             except ValidationError as error: |             except ValidationError as error: | ||||||
|                 return await self.on_validation_error(error, injector.context) |                 return json_response(text=error.json(), status=400) | ||||||
|  |  | ||||||
|         return await handler(self, *args, **kwargs) |         return await handler(self, *args, **kwargs) | ||||||
|  |  | ||||||
| @@ -148,15 +99,3 @@ def is_pydantic_view(obj) -> bool: | |||||||
|         return issubclass(obj, PydanticView) |         return issubclass(obj, PydanticView) | ||||||
|     except TypeError: |     except TypeError: | ||||||
|         return False |         return False | ||||||
|  |  | ||||||
|  |  | ||||||
| __all__ = ( |  | ||||||
|     "PydanticValidationError", |  | ||||||
|     "AbstractInjector", |  | ||||||
|     "BodyGetter", |  | ||||||
|     "HeadersGetter", |  | ||||||
|     "MatchInfoGetter", |  | ||||||
|     "QueryGetter", |  | ||||||
|     "CONTEXT", |  | ||||||
|     "Group", |  | ||||||
| ) |  | ||||||
|   | |||||||
| @@ -1,5 +1,25 @@ | |||||||
| from aiohttp import web | 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) | 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 pydantic import BaseModel | ||||||
| from typing import List |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Friend(BaseModel): |  | ||||||
|     name: str |  | ||||||
|     age: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Pet(BaseModel): | class Pet(BaseModel): | ||||||
|     id: int |     id: int | ||||||
|     name: str |     name: str | ||||||
|     age: int |  | ||||||
|     friends: Friend |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Error(BaseModel): |  | ||||||
|     error: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Model: | 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 import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from .model import Pet | ||||||
| from aiohttp_pydantic.oas.typing import r200, r201, r204, r404 |  | ||||||
|  |  | ||||||
| from .model import Error, Pet |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetCollectionView(PydanticView): | class PetCollectionView(PydanticView): | ||||||
|     async def get(self, age: Optional[int] = None) -> r200[List[Pet]]: |     async def get(self): | ||||||
|         """ |  | ||||||
|         List all pets |  | ||||||
|  |  | ||||||
|         Status Codes: |  | ||||||
|             200: Successful operation |  | ||||||
|         """ |  | ||||||
|         pets = self.request.app["model"].list_pets() |         pets = self.request.app["model"].list_pets() | ||||||
|         return web.json_response( |         return web.json_response([pet.dict() for pet in pets]) | ||||||
|             [pet.dict() for pet in pets if age is None or age == pet.age] |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|     async def post(self, pet: Pet) -> r201[Pet]: |     async def post(self, pet: Pet): | ||||||
|         """ |  | ||||||
|         Add a new pet to the store |  | ||||||
|  |  | ||||||
|         Status Codes: |  | ||||||
|             201: Successful operation |  | ||||||
|         """ |  | ||||||
|         self.request.app["model"].add_pet(pet) |         self.request.app["model"].add_pet(pet) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetItemView(PydanticView): | class PetItemView(PydanticView): | ||||||
|     async def get(self, id: int, /) -> Union[r200[Pet], r404[Error]]: |     async def get(self, id: int, /): | ||||||
|         """ |  | ||||||
|         Find a pet by ID |  | ||||||
|  |  | ||||||
|         Status Codes: |  | ||||||
|             200: Successful operation |  | ||||||
|             404: Pet not found |  | ||||||
|         """ |  | ||||||
|         pet = self.request.app["model"].find_pet(id) |         pet = self.request.app["model"].find_pet(id) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|     async def put(self, id: int, /, pet: Pet) -> r200[Pet]: |     async def put(self, id: int, /, pet: Pet): | ||||||
|         """ |  | ||||||
|         Update an existing object |  | ||||||
|  |  | ||||||
|         Status Codes: |  | ||||||
|             200: Successful operation |  | ||||||
|             404: Pet not found |  | ||||||
|         """ |  | ||||||
|         self.request.app["model"].update_pet(id, pet) |         self.request.app["model"].update_pet(id, pet) | ||||||
|         return web.json_response(pet.dict()) |         return web.json_response(pet.dict()) | ||||||
|  |  | ||||||
|     async def delete(self, id: int, /) -> r204: |     async def delete(self, id: int, /): | ||||||
|         """ |  | ||||||
|         Deletes a pet |  | ||||||
|         """ |  | ||||||
|         self.request.app["model"].remove_pet(id) |         self.request.app["model"].remove_pet(id) | ||||||
|         return web.Response(status=204) |         return web.json_response(id) | ||||||
|   | |||||||
| @@ -4,6 +4,3 @@ requires = [ | |||||||
|   "wheel", |   "wheel", | ||||||
| ] | ] | ||||||
| build-backend = "setuptools.build_meta" | 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 | ||||||
|     Programming Language :: Python :: 3 |     Programming Language :: Python :: 3 | ||||||
|     Programming Language :: Python :: 3 :: Only |     Programming Language :: Python :: 3 :: Only | ||||||
|     Programming Language :: Python :: 3.10 |     Programming Language :: Python :: 3.8 | ||||||
|     Programming Language :: Python :: 3.11 |     Programming Language :: Python :: 3.9 | ||||||
|     Topic :: Software Development :: Libraries :: Application Frameworks |     Topic :: Software Development :: Libraries :: Application Frameworks | ||||||
|     Framework :: aiohttp |     Framework :: AsyncIO | ||||||
|     License :: OSI Approved :: MIT License |     License :: OSI Approved :: MIT License | ||||||
|  |  | ||||||
| [options] | [options] | ||||||
| zip_safe = False | zip_safe = False | ||||||
| include_package_data = True | include_package_data = True | ||||||
| packages = find: | packages = find: | ||||||
| python_requires = >=3.10 | python_requires = >=3.8 | ||||||
| install_requires = | install_requires = | ||||||
|     aiohttp |     aiohttp | ||||||
|     pydantic>=2.0.0 |     pydantic | ||||||
|     swagger-4-ui-bundle |     swagger-ui-bundle | ||||||
|  |  | ||||||
| [options.extras_require] | [options.extras_require] | ||||||
| test = | test = pytest; pytest-aiohttp | ||||||
|     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 |  | ||||||
|  |  | ||||||
| [options.packages.find] | [options.packages.find] | ||||||
| exclude = | exclude = | ||||||
|     tests* |     tests | ||||||
|     demo* |     demo | ||||||
|  |  | ||||||
| [options.package_data] | [options.package_data] | ||||||
| aiohttp_pydantic.oas = index.j2 | aiohttp_pydantic.oas = index.j2 | ||||||
|   | |||||||
							
								
								
									
										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 pydantic.main import BaseModel | ||||||
|  | from aiohttp_pydantic import PydanticView, oas | ||||||
| from enum import Enum | from aiohttp import web | ||||||
| from typing import List, Optional, Union, Literal, Annotated |  | ||||||
| from uuid import UUID |  | ||||||
|  |  | ||||||
| import pytest | 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): | class Pet(BaseModel): | ||||||
|     id: int |     id: int | ||||||
|     name: Optional[str] = Field(None) |     name: str | ||||||
|     toys: List[Toy] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Error(BaseModel): |  | ||||||
|     code: int |  | ||||||
|     text: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Cat(BaseModel): |  | ||||||
|     pet_type: Literal['cat'] |  | ||||||
|     meows: int |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Dog(BaseModel): |  | ||||||
|     pet_type: Literal['dog'] |  | ||||||
|     barks: float |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Animal(RootModel): |  | ||||||
|     root: Annotated[Union[Cat, Dog], Field(discriminator='pet_type')] |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetCollectionView(PydanticView): | class PetCollectionView(PydanticView): | ||||||
|     async def get( |     async def get(self): | ||||||
|             self, format: str, lang: Lang = Lang.EN, name: Optional[str] = None, *, promo: Optional[UUID] = None |  | ||||||
|     ) -> r200[List[Pet]]: |  | ||||||
|         """ |  | ||||||
|         Get a list of pets |  | ||||||
|  |  | ||||||
|         Tags: pet |  | ||||||
|         Status Codes: |  | ||||||
|           200: Successful operation |  | ||||||
|         OperationId: createPet |  | ||||||
|         """ |  | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|     async def post(self, pet: Pet) -> r201[Pet]: |     async def post(self, pet: Pet): | ||||||
|         """Create a Pet""" |  | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
| class PetItemView(PydanticView): | class PetItemView(PydanticView): | ||||||
|     async def get( |     async def get(self, id: int, /): | ||||||
|             self, |  | ||||||
|             id: int, |  | ||||||
|             /, |  | ||||||
|             size: Union[int, Literal["x", "l", "s"]], |  | ||||||
|             day: Union[int, Literal["now"]] = "now", |  | ||||||
|     ) -> Union[r200[Pet], r404[Error], r400[Error]]: |  | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|     async def put(self, id: int, /, pet: Pet): |     async def put(self, id: int, /, pet: Pet): | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|     async def delete(self, id: int, /) -> r204: |     async def delete(self, id: int, /): | ||||||
|         """ |  | ||||||
|         Status Code: |  | ||||||
|           204: Empty but OK |  | ||||||
|         """ |  | ||||||
|         return web.json_response() |         return web.json_response() | ||||||
|  |  | ||||||
|  |  | ||||||
| class ViewResponseReturnASimpleType(PydanticView): |  | ||||||
|     async def get(self) -> r200[int]: |  | ||||||
|         """ |  | ||||||
|         Status Codes: |  | ||||||
|           200: The new number |  | ||||||
|         """ |  | ||||||
|         return web.json_response() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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 | @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 = web.Application() | ||||||
|     app.router.add_view("/pets", PetCollectionView) |     app.router.add_view("/pets", PetCollectionView) | ||||||
|     app.router.add_view("/pets/{id}", PetItemView) |     app.router.add_view("/pets/{id}", PetItemView) | ||||||
|     app.router.add_view("/simple-type", ViewResponseReturnASimpleType) |  | ||||||
|     app.router.add_view("/animals", DiscriminatedView) |  | ||||||
|     oas.setup(app) |     oas.setup(app) | ||||||
|  |  | ||||||
|     return await ensure_content_durability(await aiohttp_client(app)) |     client = await aiohttp_client(app) | ||||||
|  |     response = await client.get("/oas/spec") | ||||||
|  |     assert response.status == 200 | ||||||
| async def test_generated_oas_should_have_components_schemas(generated_oas): |     assert response.content_type == "application/json" | ||||||
|     assert generated_oas["components"]["schemas"] == { |     return await response.json() | ||||||
|         'Cat': {'properties': {'meows': {'title': 'Meows', 'type': 'integer'}, |  | ||||||
|                                'pet_type': {'const': 'cat', 'title': 'Pet Type'}}, |  | ||||||
|                 'required': ['pet_type', 'meows'], |  | ||||||
|                 'title': 'Cat', |  | ||||||
|                 'type': 'object'}, |  | ||||||
|         "Color": { |  | ||||||
|             "description": "Pet color", |  | ||||||
|             "enum": ["red", "green", "pink"], |  | ||||||
|             "title": "Color", |  | ||||||
|             "type": "string", |  | ||||||
|         }, |  | ||||||
|         'Dog': {'properties': {'barks': {'title': 'Barks', 'type': 'number'}, |  | ||||||
|                                'pet_type': {'const': 'dog', 'title': 'Pet Type'}}, |  | ||||||
|                 'required': ['pet_type', 'barks'], |  | ||||||
|                 'title': 'Dog', |  | ||||||
|                 'type': 'object'}, |  | ||||||
|         'Error': { |  | ||||||
|             'properties': { |  | ||||||
|                 'code': {'title': 'Code', 'type': 'integer'}, |  | ||||||
|                 'text': {'title': 'Text', 'type': 'string'}}, |  | ||||||
|             'required': ['code', 'text'], |  | ||||||
|             'title': 'Error', |  | ||||||
|             'type': 'object' |  | ||||||
|         }, |  | ||||||
|         'Lang': { |  | ||||||
|             'enum': ['en', 'fr'], |  | ||||||
|             'title': 'Lang', |  | ||||||
|             'type': 'string' |  | ||||||
|         }, |  | ||||||
|         "Toy": { |  | ||||||
|             "properties": { |  | ||||||
|                 "color": {"$ref": "#/components/schemas/Color"}, |  | ||||||
|                 "name": {"title": "Name", "type": "string"}, |  | ||||||
|             }, |  | ||||||
|             "required": ["name", "color"], |  | ||||||
|             "title": "Toy", |  | ||||||
|             "type": "object", |  | ||||||
|         }, |  | ||||||
|         'Pet': { |  | ||||||
|             'properties': { |  | ||||||
|                 'id': {'title': 'Id', 'type': 'integer'}, |  | ||||||
|                 'name': { |  | ||||||
|                     'anyOf': [ |  | ||||||
|                         {'type': 'string'}, |  | ||||||
|                         {'type': 'null'} |  | ||||||
|                     ], |  | ||||||
|                     'default': None, |  | ||||||
|                     'title': 'Name'}, |  | ||||||
|                 'toys': { |  | ||||||
|                     'items': {'$ref': '#/components/schemas/Toy'}, |  | ||||||
|                     'title': 'Toys', |  | ||||||
|                     'type': 'array' |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             'required': ['id', 'toys'], |  | ||||||
|             'title': 'Pet', |  | ||||||
|             'type': 'object' |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_generated_oas_should_have_pets_paths(generated_oas): | 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): | async def test_pets_route_should_have_get_method(generated_oas): | ||||||
|     assert generated_oas["paths"]["/pets"]["get"] == { |     assert generated_oas["paths"]["/pets"]["get"] == {} | ||||||
|         "description": "Get a list of pets", |  | ||||||
|         "operationId": "createPet", |  | ||||||
|         "tags": ["pet"], |  | ||||||
|         "parameters": [ |  | ||||||
|             { |  | ||||||
|                 "in": "query", |  | ||||||
|                 "name": "format", |  | ||||||
|                 "required": True, |  | ||||||
|                 "schema": {"title": "format", "type": "string"}, |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 'in': 'query', |  | ||||||
|                 'name': 'lang', |  | ||||||
|                 'required': False, |  | ||||||
|                 'schema': { |  | ||||||
|                     'allOf': [{'$ref': '#/components/schemas/Lang'}], |  | ||||||
|                     'default': 'en', |  | ||||||
|                     'title': 'lang' |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 "in": "query", |  | ||||||
|                 "name": "name", |  | ||||||
|                 "required": False, |  | ||||||
|                 "schema": { |  | ||||||
|                     'anyOf': [{'type': 'string'}, {'type': 'null'}], |  | ||||||
|                     'default': None, |  | ||||||
|                     'title': 'name' |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|             { |  | ||||||
|                 "in": "header", |  | ||||||
|                 "name": "promo", |  | ||||||
|                 "required": False, |  | ||||||
|                 "schema": { |  | ||||||
|                     'anyOf': [ |  | ||||||
|                         {'format': 'uuid', 'type': 'string'}, |  | ||||||
|                         {'type': 'null'} |  | ||||||
|                     ], |  | ||||||
|                     'default': None, |  | ||||||
|                     'title': 'promo' |  | ||||||
|                 }, |  | ||||||
|             }, |  | ||||||
|         ], |  | ||||||
|         "responses": { |  | ||||||
|             "200": { |  | ||||||
|                 "description": "Successful operation", |  | ||||||
|                 "content": { |  | ||||||
|                     "application/json": { |  | ||||||
|                         "schema": { |  | ||||||
|                             "items": {'$ref': '#/components/schemas/Pet'}, |  | ||||||
|                             "type": "array", |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|             } |  | ||||||
|         }, |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_pets_route_should_have_post_method(generated_oas): | async def test_pets_route_should_have_post_method(generated_oas): | ||||||
|     assert generated_oas["paths"]["/pets"]["post"] == { |     assert generated_oas["paths"]["/pets"]["post"] == { | ||||||
|         "description": "Create a Pet", |  | ||||||
|         "requestBody": { |         "requestBody": { | ||||||
|             "content": { |             "content": { | ||||||
|                 "application/json": { |                 "application/json": { | ||||||
|                     "schema": { |                     "schema": { | ||||||
|                         "properties": { |                         "properties": { | ||||||
|                             "id": {"title": "Id", "type": "integer"}, |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|                             "name": { |                             "name": {"title": "Name", "type": "string"}, | ||||||
|                                 'anyOf': [ |  | ||||||
|                                     {'type': 'string'}, |  | ||||||
|                                     {'type': 'null'} |  | ||||||
|                                 ], |  | ||||||
|                                 'default': None, |  | ||||||
|                                 'title': 'Name' |  | ||||||
|                         }, |                         }, | ||||||
|                             "toys": { |                         "required": ["id", "name"], | ||||||
|                                 "items": {"$ref": "#/components/schemas/Toy"}, |  | ||||||
|                                 "title": "Toys", |  | ||||||
|                                 "type": "array", |  | ||||||
|                             }, |  | ||||||
|                         }, |  | ||||||
|                         "required": ["id", "toys"], |  | ||||||
|                         "title": "Pet", |                         "title": "Pet", | ||||||
|                         "type": "object", |                         "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): | async def test_pets_id_route_should_have_delete_method(generated_oas): | ||||||
|     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { |     assert generated_oas["paths"]["/pets/{id}"]["delete"] == { | ||||||
|         "description": "", |  | ||||||
|         "parameters": [ |         "parameters": [ | ||||||
|             { |             { | ||||||
|                 "in": "path", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "required": True, |                 "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", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "required": True, |                 "required": True, | ||||||
|                 "schema": {"title": "id", "type": "integer"}, |                 "schema": {"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': ''} |  | ||||||
|             } |             } | ||||||
|  |         ] | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -384,7 +108,7 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | |||||||
|                 "in": "path", |                 "in": "path", | ||||||
|                 "name": "id", |                 "name": "id", | ||||||
|                 "required": True, |                 "required": True, | ||||||
|                 "schema": {"title": "id", "type": "integer"}, |                 "schema": {"type": "integer"}, | ||||||
|             } |             } | ||||||
|         ], |         ], | ||||||
|         "requestBody": { |         "requestBody": { | ||||||
| @@ -393,20 +117,9 @@ async def test_pets_id_route_should_have_put_method(generated_oas): | |||||||
|                     "schema": { |                     "schema": { | ||||||
|                         "properties": { |                         "properties": { | ||||||
|                             "id": {"title": "Id", "type": "integer"}, |                             "id": {"title": "Id", "type": "integer"}, | ||||||
|                             "name": { |                             "name": {"title": "Name", "type": "string"}, | ||||||
|                                 'anyOf': [ |  | ||||||
|                                     {'type': 'string'}, |  | ||||||
|                                     {'type': 'null'} |  | ||||||
|                                 ], |  | ||||||
|                                 'default': None, |  | ||||||
|                                 'title': 'Name'}, |  | ||||||
|                             "toys": { |  | ||||||
|                                 "items": {"$ref": "#/components/schemas/Toy"}, |  | ||||||
|                                 "title": "Toys", |  | ||||||
|                                 "type": "array", |  | ||||||
|                         }, |                         }, | ||||||
|                         }, |                         "required": ["id", "name"], | ||||||
|                         "required": ["id", "toys"], |  | ||||||
|                         "title": "Pet", |                         "title": "Pet", | ||||||
|                         "type": "object", |                         "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 aiohttp_pydantic.injectors import _parse_func_signature | ||||||
|  | from pydantic import BaseModel | ||||||
|  | from uuid import UUID | ||||||
|  |  | ||||||
|  |  | ||||||
| class User(BaseModel): | 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): |     def path_body_qs_and_header(self, id: str, /, user: User, page: int, *, auth: UUID): | ||||||
|         pass |         pass | ||||||
|  |  | ||||||
|     assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}, {}) |     assert _parse_func_signature(body_only) == ({}, {"user": User}, {}, {}) | ||||||
|     assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}, {}) |     assert _parse_func_signature(path_only) == ({"id": str}, {}, {}, {}) | ||||||
|     assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}, {}) |     assert _parse_func_signature(qs_only) == ({}, {}, {"page": int}, {}) | ||||||
|     assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}, {}) |     assert _parse_func_signature(header_only) == ({}, {}, {}, {"auth": UUID}) | ||||||
|     assert _parse_func_signature(path_and_qs) == ( |     assert _parse_func_signature(path_and_qs) == ({"id": str}, {}, {"page": int}, {}) | ||||||
|         {"id": str}, |  | ||||||
|         {}, |  | ||||||
|         {"page": int}, |  | ||||||
|         {}, |  | ||||||
|         {}, |  | ||||||
|     ) |  | ||||||
|     assert _parse_func_signature(path_and_header) == ( |     assert _parse_func_signature(path_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {}, |         {}, | ||||||
|         {}, |         {}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|         {}, |  | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(qs_and_header) == ( |     assert _parse_func_signature(qs_and_header) == ( | ||||||
|         {}, |         {}, | ||||||
|         {}, |         {}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|         {}, |  | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(path_qs_and_header) == ( |     assert _parse_func_signature(path_qs_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {}, |         {}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|         {}, |  | ||||||
|     ) |     ) | ||||||
|     assert _parse_func_signature(path_body_qs_and_header) == ( |     assert _parse_func_signature(path_body_qs_and_header) == ( | ||||||
|         {"id": str}, |         {"id": str}, | ||||||
|         {"user": User}, |         {"user": User}, | ||||||
|         {"page": int}, |         {"page": int}, | ||||||
|         {"auth": UUID}, |         {"auth": UUID}, | ||||||
|         {}, |  | ||||||
|     ) |     ) | ||||||
|   | |||||||
| @@ -1,35 +1,21 @@ | |||||||
| from __future__ import annotations | from pydantic import BaseModel | ||||||
|  | from typing import Optional | ||||||
| from typing import Iterator, List, Optional |  | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
| from pydantic import BaseModel, RootModel |  | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleModel(BaseModel): | class ArticleModel(BaseModel): | ||||||
|     name: str |     name: str | ||||||
|     nb_page: Optional[int] = None |     nb_page: Optional[int] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleModels(RootModel): |  | ||||||
|     root: List[ArticleModel] |  | ||||||
|  |  | ||||||
|     def __iter__(self) -> Iterator[ArticleModel]: |  | ||||||
|         return iter(self.root) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|     async def post(self, article: ArticleModel): |     async def post(self, article: ArticleModel): | ||||||
|         return web.json_response(article.dict()) |         return web.json_response(article.dict()) | ||||||
|  |  | ||||||
|     async def put(self, articles: ArticleModels): |  | ||||||
|         return web.json_response([article.dict() for article in articles]) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_an_article_without_required_field_should_return_an_error_message( | 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 = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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={}) |     resp = await client.post("/article", json={}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         {"loc": ["name"], "msg": "field required", "type": "value_error.missing"} | ||||||
|             'input': {}, |  | ||||||
|             'loc': ['name'], |  | ||||||
|             'loc_in': 'body', |  | ||||||
|             'msg': 'Field required', |  | ||||||
|             'type': 'missing', |  | ||||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing' |  | ||||||
|         } |  | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_an_article_with_wrong_type_field_should_return_an_error_message( | 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 = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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 resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|             'input': 'foo', |             "loc": ["nb_page"], | ||||||
|             'loc': ['nb_page'], |             "msg": "value is not a valid integer", | ||||||
|             'loc_in': 'body', |             "type": "type_error.integer", | ||||||
|             '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' |  | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_post_an_array_json_is_supported(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) |  | ||||||
|  |  | ||||||
|     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): |  | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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 import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
| from aiohttp_pydantic.injectors import Group | from datetime import datetime | ||||||
|  | import json | ||||||
|  |  | ||||||
|  |  | ||||||
| class JSONEncoder(json.JSONEncoder): | 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( | async def test_get_article_without_required_header_should_return_an_error_message( | ||||||
|         aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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={}) |     resp = await client.get("/article", headers={}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|  |     assert await resp.json() == [ | ||||||
|     result = await resp.json() |  | ||||||
|     assert len(result) == 1 |  | ||||||
|     result[0].pop('input') |  | ||||||
|  |  | ||||||
|     assert result == [ |  | ||||||
|         { |         { | ||||||
|             'type': 'missing', |             "loc": ["signature_expired"], | ||||||
|             'loc': ['signature_expired'], |             "msg": "field required", | ||||||
|             'msg': 'Field required', |             "type": "value_error.missing", | ||||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing', |  | ||||||
|             'loc_in': 'headers' |  | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_wrong_header_type_should_return_an_error_message( | 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 = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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"}) |     resp = await client.get("/article", headers={"signature_expired": "foo"}) | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|             'type': 'datetime_parsing', |             "loc": ["signature_expired"], | ||||||
|             'loc': ['signature_expired'], |             "msg": "invalid datetime format", | ||||||
|             'msg': 'Input should be a valid datetime, input is too short', |             "type": "value_error.datetime", | ||||||
|             'input': 'foo', |  | ||||||
|             'ctx': {'error': 'input is too short'}, |  | ||||||
|             'url': 'https://errors.pydantic.dev/2.3/v/datetime_parsing', |  | ||||||
|             'loc_in': 'headers' |  | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_valid_header_should_return_the_parsed_type( | async def test_get_article_with_valid_header_should_return_the_parsed_type( | ||||||
|         aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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( | async def test_get_article_with_valid_header_containing_hyphen_should_be_returned( | ||||||
|         aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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.status == 200 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {"signature": "2020-10-04T18:01:00"} |     assert await resp.json() == {"signature": "2020-10-04T18:01:00"} | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_wrong_value_to_header_defined_with_str_enum(aiohttp_client, event_loop): |  | ||||||
|     app = web.Application() |  | ||||||
|     app.router.add_view("/coord", ViewWithEnumType) |  | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |  | ||||||
|     resp = await client.get("/coord", headers={"format": "WGS84"}) |  | ||||||
|  |  | ||||||
|     assert ( |  | ||||||
|             await resp.json() |  | ||||||
|             == [ |  | ||||||
|                 { |  | ||||||
|                     'ctx': {'expected': "'UMT' or 'MGRS'"}, |  | ||||||
|                     'input': 'WGS84', |  | ||||||
|                     'loc': ['format'], |  | ||||||
|                     'loc_in': 'headers', |  | ||||||
|                     'msg': "Input should be 'UMT' or 'MGRS'", |  | ||||||
|                     'type': 'enum' |  | ||||||
|                 } |  | ||||||
|             ] |  | ||||||
|             != {"signature": "2020-10-04T18:01:00"} |  | ||||||
|     ) |  | ||||||
|     assert resp.status == 400 |  | ||||||
|     assert resp.content_type == "application/json" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_correct_value_to_header_defined_with_str_enum(aiohttp_client, event_loop): |  | ||||||
|     app = web.Application() |  | ||||||
|     app.router.add_view("/coord", ViewWithEnumType) |  | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |  | ||||||
|     resp = await client.get("/coord", headers={"format": "UMT"}) |  | ||||||
|     assert await resp.json() == {"format": "UMT"} |  | ||||||
|     assert resp.status == 200 |  | ||||||
|     assert resp.content_type == "application/json" |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_with_signature_group(aiohttp_client, event_loop): |  | ||||||
|     app = web.Application() |  | ||||||
|     app.router.add_view("/article", ArticleViewWithSignatureGroup) |  | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |  | ||||||
|     resp = await client.get( |  | ||||||
|         "/article", |  | ||||||
|         headers={ |  | ||||||
|             "signature_expired": "2020-10-04T18:01:00", |  | ||||||
|             "signature.scope": "write", |  | ||||||
|         }, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     assert await resp.json() == {"expired": "2020-10-04T18:01:00", "scope": "read"} |  | ||||||
|     assert resp.status == 200 |  | ||||||
|     assert resp.content_type == "application/json" |  | ||||||
|   | |||||||
| @@ -1,7 +1,4 @@ | |||||||
| from __future__ import annotations |  | ||||||
|  |  | ||||||
| from aiohttp import web | from aiohttp import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -10,8 +7,8 @@ class ArticleView(PydanticView): | |||||||
|         return web.json_response({"path": [author_id, tag, date]}) |         return web.json_response({"path": [author_id, tag, date]}) | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_correct_path_parameters_should_return_parameters_in_path( | async def test_get_article_without_required_qs_should_return_an_error_message( | ||||||
|     aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) |     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) | ||||||
| @@ -21,26 +18,3 @@ async def test_get_article_with_correct_path_parameters_should_return_parameters | |||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|     assert await resp.json() == {"path": ["1234", "music", 1980]} |     assert await resp.json() == {"path": ["1234", "music", 1980]} | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_wrong_path_parameters_should_return_error( |  | ||||||
|     aiohttp_client, event_loop |  | ||||||
| ): |  | ||||||
|     app = web.Application() |  | ||||||
|     app.router.add_view("/article/{author_id}/tag/{tag}/before/{date}", ArticleView) |  | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |  | ||||||
|     resp = await client.get("/article/1234/tag/music/before/now") |  | ||||||
|     assert resp.status == 400 |  | ||||||
|     assert resp.content_type == "application/json" |  | ||||||
|  |  | ||||||
|     assert await resp.json() == [ |  | ||||||
|         { |  | ||||||
|             'input': 'now', |  | ||||||
|             'loc': ['date'], |  | ||||||
|             'loc_in': 'path', |  | ||||||
|             'msg': 'Input should be a valid integer, unable to parse string as an integer', |  | ||||||
|             'type': 'int_parsing', |  | ||||||
|             'url': 'https://errors.pydantic.dev/2.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 import web | ||||||
|  |  | ||||||
| from aiohttp_pydantic import PydanticView | from aiohttp_pydantic import PydanticView | ||||||
| from aiohttp_pydantic.injectors import Group |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleView(PydanticView): | class ArticleView(PydanticView): | ||||||
|     async def get( |     async def get(self, with_comments: bool): | ||||||
|             self, |         return web.json_response({"with_comments": with_comments}) | ||||||
|             with_comments: bool, |  | ||||||
|             age: Optional[int] = None, |  | ||||||
|             nb_items: int = 7, |  | ||||||
|             tags: List[str] = Field(default_factory=list), |  | ||||||
|     ): |  | ||||||
|         return web.json_response( |  | ||||||
|             { |  | ||||||
|                 "with_comments": with_comments, |  | ||||||
|                 "age": age, |  | ||||||
|                 "nb_items": nb_items, |  | ||||||
|                 "tags": tags, |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Pagination(Group): |  | ||||||
|     page_num: int |  | ||||||
|     page_size: int = 20 |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def num(self) -> int: |  | ||||||
|         return self.page_num |  | ||||||
|  |  | ||||||
|     @property |  | ||||||
|     def size(self) -> int: |  | ||||||
|         return self.page_size |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleViewWithPaginationGroup(PydanticView): |  | ||||||
|     async def get( |  | ||||||
|             self, |  | ||||||
|             with_comments: bool, |  | ||||||
|             page: Pagination, |  | ||||||
|     ): |  | ||||||
|         return web.json_response( |  | ||||||
|             { |  | ||||||
|                 "with_comments": with_comments, |  | ||||||
|                 "page_num": page.num, |  | ||||||
|                 "page_size": page.size, |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Lang(str, Enum): |  | ||||||
|     EN = 'en' |  | ||||||
|     FR = 'fr' |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class ArticleViewWithEnumInQuery(PydanticView): |  | ||||||
|     async def get(self, lang: Lang): |  | ||||||
|         return web.json_response( |  | ||||||
|             { |  | ||||||
|                 "lang": lang |  | ||||||
|             } |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_without_required_qs_should_return_an_error_message( | async def test_get_article_without_required_qs_should_return_an_error_message( | ||||||
|         aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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") |     resp = await client.get("/article") | ||||||
|     assert resp.status == 400 |     assert resp.status == 400 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|  |  | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|             'input': {}, |             "loc": ["with_comments"], | ||||||
|             'loc': ['with_comments'], |             "msg": "field required", | ||||||
|             'loc_in': 'query string', |             "type": "value_error.missing", | ||||||
|             'msg': 'Field required', |  | ||||||
|             'type': 'missing', |  | ||||||
|             'url': 'https://errors.pydantic.dev/2.3/v/missing' |  | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_wrong_qs_type_should_return_an_error_message( | 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 = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     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 resp.content_type == "application/json" | ||||||
|     assert await resp.json() == [ |     assert await resp.json() == [ | ||||||
|         { |         { | ||||||
|             'input': 'foo', |             "loc": ["with_comments"], | ||||||
|             'loc': ['with_comments'], |             "msg": "value could not be parsed to a boolean", | ||||||
|             'loc_in': 'query string', |             "type": "type_error.bool", | ||||||
|             'msg': 'Input should be a valid boolean, unable to interpret input', |  | ||||||
|             'type': 'bool_parsing', |  | ||||||
|             'url': 'https://errors.pydantic.dev/2.3/v/bool_parsing' |  | ||||||
|         } |         } | ||||||
|     ] |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
| async def test_get_article_with_valid_qs_should_return_the_parsed_type( | async def test_get_article_with_valid_qs_should_return_the_parsed_type( | ||||||
|         aiohttp_client, event_loop |     aiohttp_client, loop | ||||||
| ): | ): | ||||||
|     app = web.Application() |     app = web.Application() | ||||||
|     app.router.add_view("/article", ArticleView) |     app.router.add_view("/article", ArticleView) | ||||||
|  |  | ||||||
|     client = await aiohttp_client(app) |     client = await aiohttp_client(app) | ||||||
|  |  | ||||||
|     resp = await client.get("/article", params={"with_comments": "yes", "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"}) |     resp = await client.get("/article", params={"with_comments": "yes"}) | ||||||
|     assert await resp.json() == { |  | ||||||
|         "with_comments": True, |  | ||||||
|         "age": None, |  | ||||||
|         "nb_items": 7, |  | ||||||
|         "tags": [], |  | ||||||
|     } |  | ||||||
|     assert resp.status == 200 |     assert resp.status == 200 | ||||||
|     assert resp.content_type == "application/json" |     assert resp.content_type == "application/json" | ||||||
|  |     assert await resp.json() == {"with_comments": True} | ||||||
|  |  | ||||||
| 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" |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user