├── example ├── __init__.py ├── schemas.py ├── __main__.py ├── application.py └── views.py ├── aioapi ├── inspect │ ├── __init__.py │ ├── entities.py │ ├── exceptions.py │ └── inspector.py ├── py.typed ├── __init__.py ├── exceptions.py ├── middlewares.py ├── typedefs.py ├── routedef.py └── handlers.py ├── .flake8 ├── .editorconfig ├── .github └── workflows │ ├── watch-started.yml │ ├── default.yml │ └── release-created.yml ├── tests ├── conftest.py ├── test_routedef.py ├── inspect │ └── test_inspector.py └── test_handler.py ├── docs ├── release_notes.md ├── tutorial │ ├── data_validation.md │ ├── components.md │ ├── intro.md │ ├── path_parameters.md │ ├── query_parameters.md │ ├── request_body.md │ └── handling_errors.md └── index.md ├── .coveragerc ├── mkdocs.yml ├── LICENSE ├── Makefile ├── pyproject.toml ├── .gitignore └── README.md /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aioapi/inspect/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aioapi/py.typed: -------------------------------------------------------------------------------- 1 | Marker 2 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 6 | -------------------------------------------------------------------------------- /example/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | __all__ = ("HelloBodyRequest",) 4 | 5 | 6 | class HelloBodyRequest(BaseModel): 7 | name: str 8 | age: int = 27 9 | -------------------------------------------------------------------------------- /example/__main__.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from example.application import get_application 4 | 5 | 6 | def main(): 7 | web.run_app(get_application()) 8 | 9 | 10 | if __name__ == "__main__": 11 | main() 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.py] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.{hcl,jsonnet,md,toml,yml}] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [Makefile] 18 | indent_style = tab 19 | indent_size = tab 20 | -------------------------------------------------------------------------------- /aioapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .routedef import delete, get, head, options, patch, post, put, view 2 | from .typedefs import Body, PathParam, QueryParam 3 | 4 | __all__ = ( 5 | "delete", 6 | "get", 7 | "head", 8 | "options", 9 | "patch", 10 | "post", 11 | "put", 12 | "view", 13 | "Body", 14 | "PathParam", 15 | "QueryParam", 16 | ) 17 | -------------------------------------------------------------------------------- /.github/workflows/watch-started.yml: -------------------------------------------------------------------------------- 1 | name: watch-started 2 | 3 | on: 4 | watch: 5 | types: [started] 6 | 7 | jobs: 8 | notify: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: appleboy/telegram-action@0.0.7 13 | with: 14 | to: ${{ secrets.TELEGRAM_CHAT_ID }} 15 | token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 16 | format: markdown 17 | message: ${{ github.repository }} starred! 18 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | 4 | from aioapi.middlewares import validation_error_middleware 5 | 6 | 7 | @pytest.fixture 8 | def client_for(aiohttp_client): 9 | async def _client_for(*, routes): 10 | app = web.Application() 11 | app.add_routes(routes) 12 | app.middlewares.append(validation_error_middleware) 13 | 14 | return await aiohttp_client(app) 15 | 16 | return _client_for 17 | -------------------------------------------------------------------------------- /aioapi/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | from pydantic import ValidationError 3 | 4 | __all__ = ("HTTPBadRequest",) 5 | 6 | 7 | class HTTPBadRequest(web.HTTPBadRequest): 8 | __slots__ = ("_validation_error",) 9 | 10 | @property 11 | def validation_error(self) -> ValidationError: 12 | return self._validation_error 13 | 14 | def __init__(self, *, validation_error: ValidationError, **kwargs) -> None: 15 | self._validation_error = validation_error 16 | super().__init__(**kwargs) 17 | -------------------------------------------------------------------------------- /docs/release_notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## Next release 4 | 5 | * Add cookie parameters support. 6 | * Add header parameters support. 7 | * Add response body support. 8 | * Add nested components support. 9 | * Add `RouteTableDef` support. 10 | * Add benchmarks. 11 | 12 | ## 0.2.0 13 | 14 | * **[backward incompatible]** Rename project to `aioapi`. 15 | * Add path parameters support. 16 | * Add query parameters support. 17 | * Add request body support. 18 | * Support class-based views. 19 | * Allow to define bad request handler. 20 | -------------------------------------------------------------------------------- /aioapi/inspect/entities.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any, Dict, Optional, Tuple, Type 3 | 4 | from pydantic import BaseModel 5 | 6 | __all__ = ("HandlerMeta",) 7 | 8 | 9 | @dataclass(frozen=True) 10 | class HandlerMeta: 11 | name: str 12 | components_mapping: Optional[Dict[str, Any]] = None 13 | request_type: Optional[Type[BaseModel]] = None 14 | request_body_pair: Optional[Tuple[str, Any]] = None 15 | request_path_mapping: Optional[Dict[str, Any]] = None 16 | request_query_mapping: Optional[Dict[str, Any]] = None 17 | -------------------------------------------------------------------------------- /docs/tutorial/data_validation.md: -------------------------------------------------------------------------------- 1 | # Data Validation 2 | 3 | `AIOAPI` relies on `pydantic` for data validation. To know how to work with `pydantic` and to get know all about its features please follow [official documentation](https://pydantic-docs.helpmanual.io/). 4 | 5 | Below you can find a simple example of `pydantic`s model declaration: 6 | 7 | ```python 8 | from datetime import datetime 9 | from typing import List 10 | 11 | from pydantic import BaseModel 12 | 13 | 14 | class User(BaseModel): 15 | id: int 16 | name = 'John Doe' 17 | signup_ts: datetime = None 18 | friends: List[int] = [] 19 | ``` 20 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | omit = 5 | 6 | [report] 7 | # Regexes for lines to exclude from consideration 8 | exclude_lines = 9 | # Have to re-enable the standard pragma 10 | pragma: no cover 11 | 12 | # Don't complain about missing debug-only code: 13 | def __repr__ 14 | if self\.debug 15 | 16 | # Don't complain about some magic methods: 17 | def __str__ 18 | 19 | # Don't complain if tests don't hit defensive assertion code: 20 | raise AssertionError 21 | raise NotImplementedError 22 | 23 | # Don't complain if non-runnable code isn't run: 24 | if 0: 25 | if __name__ == .__main__.: 26 | 27 | ignore_errors = True 28 | -------------------------------------------------------------------------------- /docs/tutorial/components.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | `AIOAPI` supports components which you can use in your views, using standard Python type annotations. 4 | 5 | Supported components: 6 | 7 | * `aiohttp.web.Request` 8 | * `aiohttp.web.Application` 9 | 10 | Below you can find a real example of request and application components usage: 11 | 12 | ```python hl_lines="5" 13 | import aioapi as api 14 | from aiohttp import web 15 | 16 | 17 | async def hello_components(request: web.Request, app: web.Application): 18 | return web.Response() 19 | 20 | 21 | def main(): 22 | app = web.Application() 23 | 24 | app.add_routes([api.get("/hello_components", hello_components)]) 25 | 26 | web.run_app(app) 27 | 28 | 29 | if __name__ == "__main__": 30 | main() 31 | ``` 32 | -------------------------------------------------------------------------------- /aioapi/middlewares.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from aiohttp import web 4 | 5 | from aioapi.exceptions import HTTPBadRequest 6 | 7 | __all__ = ("validation_error_middleware",) 8 | 9 | 10 | @web.middleware 11 | async def validation_error_middleware(request, handler): 12 | try: 13 | resp = await handler(request) 14 | except HTTPBadRequest as e: 15 | raise web.HTTPBadRequest( 16 | content_type="application/json", 17 | text=json.dumps( 18 | { 19 | "type": "validation_error", 20 | "title": "Your request parameters didn't validate.", 21 | "invalid_params": e.validation_error.errors(), 22 | } 23 | ), 24 | ) 25 | 26 | return resp 27 | -------------------------------------------------------------------------------- /example/application.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | import aioapi as api 4 | from aioapi.middlewares import validation_error_middleware 5 | from example import views 6 | 7 | __all__ = ("get_application",) 8 | 9 | 10 | def get_application(): 11 | app = web.Application() 12 | 13 | app.add_routes( 14 | [ 15 | web.get("/hello_batman", views.hello_batman), 16 | api.get("/hello_components", views.hello_components), 17 | api.get("/hello/{name}", views.hello_path), 18 | api.get("/hello_query", views.hello_query), 19 | api.post("/hello_body", views.hello_body), 20 | api.view("/hello_view", views.HelloView), 21 | ] 22 | ) 23 | app.middlewares.append(validation_error_middleware) 24 | 25 | return app 26 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: AIOAPI 2 | theme: 3 | name: material 4 | 5 | repo_name: Gr1N/aioapi 6 | repo_url: https://github.com/Gr1N/aioapi 7 | 8 | extra: 9 | social: 10 | - type: github 11 | link: https://github.com/Gr1N 12 | - type: linkedin 13 | link: https://linkedin.com/in/ngrishko 14 | 15 | markdown_extensions: 16 | - admonition 17 | - codehilite: 18 | guess_lang: false 19 | linenums: true 20 | - toc: 21 | permalink: true 22 | 23 | nav: 24 | - Home: index.md 25 | - Tutorial: 26 | Intro: tutorial/intro.md 27 | Data Validation: tutorial/data_validation.md 28 | Path Parameters: tutorial/path_parameters.md 29 | Query Parameters: tutorial/query_parameters.md 30 | Request Body: tutorial/request_body.md 31 | Components: tutorial/components.md 32 | Handling Errors: tutorial/handling_errors.md 33 | - Release Notes: release_notes.md 34 | -------------------------------------------------------------------------------- /aioapi/inspect/exceptions.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | "HandlerInspectorError", 3 | "HandlerMultipleBodyError", 4 | "HandlerParamUnknownTypeError", 5 | ) 6 | 7 | 8 | class HandlerInspectorError(Exception): 9 | __slots__ = ("_handler", "_param") 10 | 11 | @property 12 | def handler(self) -> str: 13 | return self._handler 14 | 15 | @property 16 | def param(self) -> str: 17 | return self._param 18 | 19 | def __init__(self, *, handler: str, param: str) -> None: 20 | self._handler = handler 21 | self._param = param 22 | 23 | def __str__(self) -> str: 24 | return repr(self) 25 | 26 | def __repr__(self) -> str: 27 | return ( 28 | f"<{self.__class__.__name__} handler={self._handler} param={self._param}>" 29 | ) 30 | 31 | 32 | class HandlerMultipleBodyError(HandlerInspectorError): 33 | pass 34 | 35 | 36 | class HandlerParamUnknownTypeError(HandlerInspectorError): 37 | pass 38 | -------------------------------------------------------------------------------- /.github/workflows/default.yml: -------------------------------------------------------------------------------- 1 | name: default 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: [3.8] 12 | 13 | fail-fast: true 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - uses: Gr1N/setup-poetry@v1 21 | with: 22 | poetry-version: 1.0.0 23 | - uses: actions/cache@v1 24 | with: 25 | path: ~/.cache/pypoetry/virtualenvs 26 | key: ${{ runner.os }}-${{ matrix.python-version }}-poetry-${{ hashFiles('pyproject.toml') }} 27 | restore-keys: | 28 | ${{ runner.os }}-${{ matrix.python-version }}-poetry- 29 | - run: make install-deps 30 | - run: make lint 31 | if: matrix.python-version == 3.8 32 | - run: make test 33 | - run: make codecov 34 | if: matrix.python-version == 3.8 35 | env: 36 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nikita Grishko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_routedef.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from aiohttp import web 5 | 6 | import aioapi as api 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "method", ("head", "options", "post", "put", "patch", "delete") 11 | ) 12 | async def test_simple(client_for, method): 13 | async def handler(): 14 | return web.json_response({"super": "simple"}) 15 | 16 | route = getattr(api, method)("/test/simple", handler) 17 | client = await client_for(routes=[route]) 18 | 19 | req = getattr(client, method) 20 | resp = await req("/test/simple") 21 | 22 | assert resp.status == HTTPStatus.OK 23 | 24 | 25 | async def test_view(client_for): 26 | class View(web.View): 27 | async def get(self): 28 | return web.json_response({}) 29 | 30 | async def post(self): 31 | return web.json_response({}) 32 | 33 | client = await client_for(routes=[api.view("/test/view", View)]) 34 | 35 | resp = await client.get("/test/view") 36 | assert resp.status == HTTPStatus.OK 37 | 38 | resp = await client.post("/test/view") 39 | assert resp.status == HTTPStatus.OK 40 | -------------------------------------------------------------------------------- /aioapi/typedefs.py: -------------------------------------------------------------------------------- 1 | from typing import Generic, TypeVar 2 | 3 | __all__ = ("Body", "PathParam", "QueryParam") 4 | 5 | 6 | TVBody = TypeVar("TVBody") 7 | TVPathParam = TypeVar("TVPathParam") 8 | TVQueryParam = TypeVar("TVQueryParam") 9 | 10 | 11 | class Body(Generic[TVBody]): 12 | __slots__ = ("_cleaned",) 13 | 14 | @property 15 | def cleaned(self) -> TVBody: 16 | return self._cleaned 17 | 18 | def __init__(self, cleaned: TVBody) -> None: 19 | self._cleaned = cleaned 20 | 21 | def __str__(self) -> str: 22 | return f"" 23 | 24 | 25 | class PathParam(Generic[TVPathParam]): 26 | __slots__ = ("_cleaned",) 27 | 28 | @property 29 | def cleaned(self) -> TVPathParam: 30 | return self._cleaned 31 | 32 | def __init__(self, cleaned: TVPathParam) -> None: 33 | self._cleaned = cleaned 34 | 35 | def __str__(self) -> str: 36 | return f"" 37 | 38 | 39 | class QueryParam(Generic[TVQueryParam]): 40 | __slots__ = ("_cleaned",) 41 | 42 | @property 43 | def cleaned(self) -> TVQueryParam: 44 | return self._cleaned 45 | 46 | def __init__(self, cleaned: TVQueryParam) -> None: 47 | self._cleaned = cleaned 48 | 49 | def __str__(self) -> str: 50 | return f"" 51 | -------------------------------------------------------------------------------- /example/views.py: -------------------------------------------------------------------------------- 1 | from aiohttp import web 2 | 3 | from aioapi import Body, PathParam, QueryParam 4 | from example.schemas import HelloBodyRequest 5 | 6 | __all__ = ( 7 | "HelloView", 8 | "hello_batman", 9 | "hello_components", 10 | "hello_body", 11 | "hello_path", 12 | "hello_query", 13 | ) 14 | 15 | 16 | DEFAULT_AGE_QUERY_PARAM = QueryParam(27) 17 | 18 | 19 | class HelloView(web.View): 20 | async def get( 21 | self, name: QueryParam[str], age: QueryParam[int] = DEFAULT_AGE_QUERY_PARAM 22 | ): 23 | return web.json_response({"whoami": name.cleaned, "age": age.cleaned}) 24 | 25 | async def post(self, request: web.Request, body: Body[HelloBodyRequest]): 26 | cleaned = body.cleaned 27 | return web.json_response( 28 | {"whoami": cleaned.name, "age": cleaned.age, "whoareyou": id(request)} 29 | ) 30 | 31 | 32 | async def hello_batman(request): 33 | return web.json_response({"whoami": "I'm Batman!"}) 34 | 35 | 36 | async def hello_components(request: web.Request, app: web.Application): 37 | return web.json_response({"whoami": id(request), "whoareyou": id(app)}) 38 | 39 | 40 | async def hello_body(body: Body[HelloBodyRequest]): 41 | cleaned = body.cleaned 42 | return web.json_response({"whoami": cleaned.name, "age": cleaned.age}) 43 | 44 | 45 | async def hello_path(name: PathParam[str]): 46 | return web.json_response({"whoami": name.cleaned}) 47 | 48 | 49 | async def hello_query( 50 | name: QueryParam[str], age: QueryParam[int] = DEFAULT_AGE_QUERY_PARAM 51 | ): 52 | return web.json_response({"whoami": name.cleaned, "age": age.cleaned}) 53 | -------------------------------------------------------------------------------- /docs/tutorial/intro.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | 3 | This tutorial shows you how to use `AIOAPI` with all its features, step by step. 4 | 5 | ## Install AIOAPI 6 | 7 | Install `AIOAPI`: 8 | 9 | ```bash 10 | $ pip install aioapi 11 | ``` 12 | 13 | ## First Steps 14 | 15 | Create simple `AIOHTTP` application, during tutorial we will extend it step by step: 16 | 17 | ```python 18 | import aioapi as api 19 | from aioapi.middlewares import validation_error_middleware 20 | from aiohttp import web 21 | 22 | 23 | async def hello_aioapi(): 24 | return web.json_response({"hello": "AIOAPI"}) 25 | 26 | 27 | def main(): 28 | app = web.Application() 29 | 30 | app.add_routes([api.get("/", hello_aioapi)]) 31 | app.middlewares.append(validation_error_middleware) 32 | 33 | web.run_app(app) 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | ``` 39 | 40 | Copy that to a file `main.py` and run the live server: 41 | 42 | ```bash 43 | $ python main.py 44 | ``` 45 | 46 | Open browser at [http://127.0.0.1:8080](http://127.0.0.1:8080) or use command line tool like [cURL](https://curl.haxx.se/) or [HTTPie](https://httpie.org/) to check that server is up and running: 47 | 48 | ```bash 49 | $ http :8080 50 | HTTP/1.1 200 OK 51 | Content-Length: 19 52 | Content-Type: application/json; charset=utf-8 53 | Date: Fri, 12 Apr 2019 17:44:31 GMT 54 | Server: Python/3.7 aiohttp/3.5.4 55 | 56 | { 57 | "hello": "AIOAPI" 58 | } 59 | ``` 60 | 61 | ## Examples 62 | 63 | You can also skip the tutorial and jump into [`examples/`](https://github.com/Gr1N/aioapi/tree/master/example) directory where you can find an example application which shows all power of `AIOAPI` library. 64 | -------------------------------------------------------------------------------- /docs/tutorial/path_parameters.md: -------------------------------------------------------------------------------- 1 | # Path Parameters 2 | 3 | You can declare path parameters and their types, using standard Python type annotations: 4 | 5 | ```python hl_lines="7" 6 | import aioapi as api 7 | from aioapi import PathParam 8 | from aioapi.middlewares import validation_error_middleware 9 | from aiohttp import web 10 | 11 | 12 | async def hello_path(number: PathParam[int]): 13 | return web.json_response({"hello", number.cleaned}) 14 | 15 | 16 | def main(): 17 | app = web.Application() 18 | 19 | app.add_routes([api.get("/hello/{number}", hello_path)]) 20 | app.middlewares.append(validation_error_middleware) 21 | 22 | web.run_app(app) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | ``` 28 | 29 | If you run this example and send a request to `/hello/42` route you will see: 30 | 31 | ```bash 32 | $ http :8080/hello/42 33 | HTTP/1.1 200 OK 34 | Content-Length: 13 35 | Content-Type: application/json; charset=utf-8 36 | Date: Fri, 12 Apr 2019 19:18:54 GMT 37 | Server: Python/3.7 aiohttp/3.5.4 38 | 39 | { 40 | "hello": 42 41 | } 42 | ``` 43 | 44 | But if you send a request to `/hello/batman` route you will see an error: 45 | 46 | ```bash 47 | $ http :8080/hello/batman 48 | HTTP/1.1 400 Bad Request 49 | Content-Length: 199 50 | Content-Type: application/json; charset=utf-8 51 | Date: Fri, 12 Apr 2019 19:20:44 GMT 52 | Server: Python/3.7 aiohttp/3.5.4 53 | 54 | { 55 | "invalid_params": [ 56 | { 57 | "loc": [ 58 | "path", 59 | "number" 60 | ], 61 | "msg": "value is not a valid integer", 62 | "type": "type_error.integer" 63 | } 64 | ], 65 | "title": "Your request parameters didn't validate.", 66 | "type": "validation_error" 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | POETRY ?= $(HOME)/.poetry/bin/poetry 2 | 3 | .PHONY: example 4 | example: 5 | @$(POETRY) run python example 6 | 7 | .PHONY: docs-serve 8 | docs-serve: 9 | @$(POETRY) run mkdocs serve 10 | 11 | .PHONY: docs-build 12 | docs-build: 13 | @$(POETRY) run mkdocs build 14 | 15 | .PHONY: install-poetry 16 | install-poetry: 17 | @curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python 18 | 19 | .PHONY: install-deps 20 | install-deps: 21 | @$(POETRY) install -vv 22 | 23 | .PHONY: install 24 | install: install-poetry install-deps 25 | 26 | .PHONY: lint-black 27 | lint-black: 28 | @echo "\033[92m< linting using black...\033[0m" 29 | @$(POETRY) run black --check --diff . 30 | @echo "\033[92m> done\033[0m" 31 | @echo 32 | 33 | .PHONY: lint-flake8 34 | lint-flake8: 35 | @echo "\033[92m< linting using flake8...\033[0m" 36 | @$(POETRY) run flake8 aioapi example tests 37 | @echo "\033[92m> done\033[0m" 38 | @echo 39 | 40 | .PHONY: lint-isort 41 | lint-isort: 42 | @echo "\033[92m< linting using isort...\033[0m" 43 | @$(POETRY) run isort --check-only --diff --recursive . 44 | @echo "\033[92m> done\033[0m" 45 | @echo 46 | 47 | .PHONY: lint-mypy 48 | lint-mypy: 49 | @echo "\033[92m< linting using mypy...\033[0m" 50 | @$(POETRY) run mypy --ignore-missing-imports --follow-imports=silent aioapi example tests 51 | @echo "\033[92m> done\033[0m" 52 | @echo 53 | 54 | .PHONY: lint 55 | lint: lint-black lint-flake8 lint-isort lint-mypy 56 | 57 | .PHONY: test 58 | test: 59 | @$(POETRY) run pytest --cov-report term --cov-report html --cov=aioapi -vv 60 | 61 | .PHONY: codecov 62 | codecov: 63 | @$(POETRY) run codecov --token=$(CODECOV_TOKEN) 64 | 65 | .PHONY: publish 66 | publish: 67 | @$(POETRY) publish --username=$(PYPI_USERNAME) --password=$(PYPI_PASSWORD) --build 68 | -------------------------------------------------------------------------------- /aioapi/routedef.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Type 2 | 3 | from aiohttp import hdrs 4 | from aiohttp.abc import AbstractView 5 | from aiohttp.web_routedef import RouteDef, _HandlerType 6 | 7 | from aioapi import handlers 8 | 9 | __all__ = ("head", "options", "get", "post", "put", "patch", "delete", "view") 10 | 11 | 12 | def head(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 13 | return route(hdrs.METH_HEAD, path, handler, **kwargs) 14 | 15 | 16 | def options(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 17 | return route(hdrs.METH_OPTIONS, path, handler, **kwargs) 18 | 19 | 20 | def get( 21 | path: str, 22 | handler: _HandlerType, 23 | *, 24 | name: Optional[str] = None, 25 | allow_head: bool = True, 26 | **kwargs: Any, 27 | ) -> RouteDef: 28 | return route( 29 | hdrs.METH_GET, path, handler, name=name, allow_head=allow_head, **kwargs 30 | ) 31 | 32 | 33 | def post(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 34 | return route(hdrs.METH_POST, path, handler, **kwargs) 35 | 36 | 37 | def put(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 38 | return route(hdrs.METH_PUT, path, handler, **kwargs) 39 | 40 | 41 | def patch(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 42 | return route(hdrs.METH_PATCH, path, handler, **kwargs) 43 | 44 | 45 | def delete(path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 46 | return route(hdrs.METH_DELETE, path, handler, **kwargs) 47 | 48 | 49 | def view(path: str, handler: Type[AbstractView], **kwargs: Any) -> RouteDef: 50 | return route(hdrs.METH_ANY, path, handler, **kwargs) 51 | 52 | 53 | def route(method: str, path: str, handler: _HandlerType, **kwargs: Any) -> RouteDef: 54 | return RouteDef(method, path, handlers.wraps(handler), kwargs) 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 88 3 | target-version = ["py38"] 4 | include = '\.pyi?$' 5 | exclude = ''' 6 | /( 7 | \.git 8 | | \.hg 9 | | \.mypy_cache 10 | | \.venv 11 | | \.vscode 12 | | _build 13 | | buck-out 14 | | build 15 | | dist 16 | )/ 17 | ''' 18 | 19 | [tool.isort] 20 | combine_as_imports = true 21 | default_section = "LOCALFOLDER" 22 | force_grid_wrap = false 23 | include_trailing_comma = true 24 | known_first_party = """ 25 | aioapi 26 | example 27 | """ 28 | known_third_party = """ 29 | aiohttp 30 | pydantic 31 | pytest 32 | """ 33 | line_length = 88 34 | multi_line_output = 3 35 | not_skip = "__init__.py" 36 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER" 37 | skip = ".eggs,.venv,venv" 38 | 39 | [tool.poetry] 40 | name = "aioapi" 41 | version = "0.3.0-alpha.2" 42 | description = "Yet another way to build APIs using AIOHTTP framework" 43 | authors = [ 44 | "Nikita Grishko " 45 | ] 46 | license = "MIT" 47 | 48 | readme = "README.md" 49 | 50 | homepage = "https://github.com/Gr1N/aioapi" 51 | repository = "https://github.com/Gr1N/aioapi" 52 | documentation = "https://gr1n.github.io/aioapi" 53 | 54 | keywords = ["asyncio", "aiohttp", "api", "typing"] 55 | 56 | classifiers = [ 57 | "Development Status :: 4 - Beta", 58 | "Framework :: AsyncIO", 59 | "Intended Audience :: Developers", 60 | "Topic :: Software Development :: Libraries :: Python Modules" 61 | ] 62 | 63 | [tool.poetry.dependencies] 64 | python = "^3.8" 65 | 66 | aiohttp = ">=3.6" 67 | pydantic = ">=1.0" 68 | 69 | [tool.poetry.dev-dependencies] 70 | black = { version = ">=19.10b0", allow-prereleases = true } 71 | codecov = ">=2.0.15" 72 | flake8 = ">=3.7.7" 73 | flake8-bugbear = ">=18.8.0" 74 | isort = { version = ">=4.3.15", extras = ["pyproject"] } 75 | mkdocs-material = ">=4.1.1" 76 | mypy = ">=0.761" 77 | pytest = ">=4.3.0" 78 | pytest-aiohttp = ">=0.3.0" 79 | pytest-cov = ">=2.6.1" 80 | 81 | [build-system] 82 | requires = ["poetry>=0.12"] 83 | build-backend = "poetry.masonry.api" 84 | -------------------------------------------------------------------------------- /.github/workflows/release-created.yml: -------------------------------------------------------------------------------- 1 | name: release-created 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build-docs: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v1 14 | with: 15 | python-version: 3.8 16 | - uses: Gr1N/setup-poetry@v1 17 | with: 18 | poetry-version: 1.0.0 19 | - uses: actions/cache@v1 20 | with: 21 | path: ~/.cache/pypoetry/virtualenvs 22 | key: ${{ runner.os }}-3.8-poetry-${{ hashFiles('pyproject.toml') }} 23 | restore-keys: | 24 | ${{ runner.os }}-3.8-poetry- 25 | - run: make install-deps 26 | - run: make docs-build 27 | - uses: peaceiris/actions-gh-pages@v2 28 | env: 29 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 30 | PUBLISH_BRANCH: gh-pages 31 | PUBLISH_DIR: ./site 32 | 33 | build-package: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: actions/setup-python@v1 39 | with: 40 | python-version: 3.8 41 | - uses: Gr1N/setup-poetry@v1 42 | with: 43 | poetry-version: 1.0.0 44 | - uses: actions/cache@v1 45 | with: 46 | path: ~/.cache/pypoetry/virtualenvs 47 | key: ${{ runner.os }}-3.8-poetry-${{ hashFiles('pyproject.toml') }} 48 | restore-keys: | 49 | ${{ runner.os }}-3.8-poetry- 50 | - run: make install-deps 51 | - run: make publish 52 | env: 53 | PYPI_USERNAME: ${{ secrets.PYPI_USERNAME }} 54 | PYPI_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 55 | 56 | build-notify: 57 | runs-on: ubuntu-latest 58 | 59 | needs: [build-docs, build-package] 60 | 61 | steps: 62 | - uses: appleboy/telegram-action@0.0.7 63 | with: 64 | to: ${{ secrets.TELEGRAM_CHAT_ID }} 65 | token: ${{ secrets.TELEGRAM_BOT_TOKEN }} 66 | format: markdown 67 | message: ${{ github.repository }} publish ${{ github.ref }} succeeded 68 | -------------------------------------------------------------------------------- /docs/tutorial/query_parameters.md: -------------------------------------------------------------------------------- 1 | # Query Parameters 2 | 3 | You can declare query parameters and their types, using standard Python type annotations: 4 | 5 | ```python hl_lines="7" 6 | import aioapi as api 7 | from aioapi import QueryParam 8 | from aioapi.middlewares import validation_error_middleware 9 | from aiohttp import web 10 | 11 | 12 | async def hello_query(name: QueryParam[str]): 13 | return web.json_response({"name": name.cleaned}) 14 | 15 | 16 | def main(): 17 | app = web.Application() 18 | 19 | app.add_routes([api.get("/hello_query", hello_query)]) 20 | app.middlewares.append(validation_error_middleware) 21 | 22 | web.run_app(app) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | ``` 28 | 29 | Query parameters can have default values: 30 | 31 | ```python hl_lines="3" 32 | async def hello_query( 33 | name: QueryParam[str], 34 | age: QueryParam[int] = QueryParam(42), 35 | ): 36 | return web.json_response({ 37 | "name": name.cleaned, 38 | "age": age.cleaned, 39 | }) 40 | ``` 41 | 42 | If you run this example and send a request to `/hello_query?name=batman` route you will see: 43 | 44 | ```bash 45 | $ http :8080/hello_query?name=batman 46 | HTTP/1.1 200 OK 47 | Content-Length: 30 48 | Content-Type: application/json; charset=utf-8 49 | Date: Fri, 12 Apr 2019 19:29:18 GMT 50 | Server: Python/3.7 aiohttp/3.5.4 51 | 52 | { 53 | "age": 42, 54 | "name": "batman" 55 | } 56 | ``` 57 | 58 | But if you send a request to `/hello_query` route you will see an error: 59 | 60 | ```bash 61 | $ http :8080/hello_query 62 | HTTP/1.1 400 Bad Request 63 | Content-Length: 185 64 | Content-Type: application/json; charset=utf-8 65 | Date: Fri, 12 Apr 2019 19:33:45 GMT 66 | Server: Python/3.7 aiohttp/3.5.4 67 | 68 | { 69 | "invalid_params": [ 70 | { 71 | "loc": [ 72 | "query", 73 | "name" 74 | ], 75 | "msg": "field required", 76 | "type": "value_error.missing" 77 | } 78 | ], 79 | "title": "Your request parameters didn't validate.", 80 | "type": "validation_error" 81 | } 82 | ``` 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/osx,python,visualstudiocode 3 | 4 | ### OSX ### 5 | *.DS_Store 6 | .AppleDouble 7 | .LSOverride 8 | 9 | # Icon must end with two \r 10 | Icon 11 | 12 | # Thumbnails 13 | ._* 14 | 15 | # Files that might appear in the root of a volume 16 | .DocumentRevisions-V100 17 | .fseventsd 18 | .Spotlight-V100 19 | .TemporaryItems 20 | .Trashes 21 | .VolumeIcon.icns 22 | .com.apple.timemachine.donotpresent 23 | 24 | # Directories potentially created on remote AFP share 25 | .AppleDB 26 | .AppleDesktop 27 | Network Trash Folder 28 | Temporary Items 29 | .apdisk 30 | 31 | ### Python ### 32 | # Byte-compiled / optimized / DLL files 33 | __pycache__/ 34 | *.py[cod] 35 | *$py.class 36 | 37 | # C extensions 38 | *.so 39 | 40 | # Distribution / packaging 41 | .Python 42 | build/ 43 | develop-eggs/ 44 | dist/ 45 | downloads/ 46 | eggs/ 47 | .eggs/ 48 | lib/ 49 | lib64/ 50 | parts/ 51 | pip-wheel-metadata/ 52 | sdist/ 53 | var/ 54 | wheels/ 55 | *.egg-info/ 56 | .installed.cfg 57 | *.egg 58 | poetry.lock 59 | 60 | # PyInstaller 61 | # Usually these files are written by a python script from a template 62 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 63 | *.manifest 64 | *.spec 65 | 66 | # Installer logs 67 | pip-log.txt 68 | pip-delete-this-directory.txt 69 | 70 | # Unit test / coverage reports 71 | htmlcov/ 72 | .coverage 73 | .coverage.* 74 | .cache 75 | .pytest_cache/ 76 | nosetests.xml 77 | coverage.xml 78 | *.cover 79 | .hypothesis/ 80 | 81 | # Translations 82 | *.mo 83 | *.pot 84 | 85 | # Flask stuff: 86 | instance/ 87 | .webassets-cache 88 | 89 | # Scrapy stuff: 90 | .scrapy 91 | 92 | # Sphinx documentation 93 | docs/_build/ 94 | 95 | # PyBuilder 96 | target/ 97 | 98 | # Jupyter Notebook 99 | .ipynb_checkpoints 100 | 101 | # pyenv 102 | .python-version 103 | 104 | # celery beat schedule file 105 | celerybeat-schedule.* 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | 132 | ### VisualStudioCode ### 133 | .vscode/ 134 | .history 135 | 136 | 137 | # End of https://www.gitignore.io/api/osx,python,visualstudiocode 138 | 139 | ### asdf #### 140 | .tool-versions 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aioapi 2 | 3 | [![Build Status](https://github.com/Gr1N/aioapi/workflows/default/badge.svg)](https://github.com/Gr1N/aioapi/actions?query=workflow%3Adefault) [![codecov](https://codecov.io/gh/Gr1N/aioapi/branch/master/graph/badge.svg)](https://codecov.io/gh/Gr1N/aioapi) ![PyPI](https://img.shields.io/pypi/v/aioapi.svg?label=pypi%20version) ![PyPI - Downloads](https://img.shields.io/pypi/dm/aioapi.svg?label=pypi%20downloads) ![GitHub](https://img.shields.io/github/license/Gr1N/aioapi.svg) 4 | 5 | Yet another way to build APIs using [`AIOHTTP`](https://aiohttp.readthedocs.io/) framework. 6 | 7 | Follow [documentation](https://gr1n.github.io/aioapi/) to know what you can do with `AIOAPI`. 8 | 9 | ## Installation 10 | 11 | ```sh 12 | $ pip install aioapi 13 | ``` 14 | 15 | ## Usage & Examples 16 | 17 | Below you can find a simple, but powerful example of `AIOAPI` library usage: 18 | 19 | ```python 20 | import aioapi as api 21 | from aioapi import Body, PathParam 22 | from aioapi.middlewares import validation_error_middleware 23 | from aiohttp import web 24 | from pydantic import BaseModel 25 | 26 | 27 | class User(BaseModel): 28 | name: str 29 | age: int = 42 30 | 31 | 32 | async def hello_body(user_id: PathParam[int], body: Body[User]): 33 | user = body.cleaned 34 | return web.json_response( 35 | {"id": user_id.cleaned, "name": user.name, "age": user.age} 36 | ) 37 | 38 | 39 | def main(): 40 | app = web.Application() 41 | 42 | app.add_routes([api.post("/hello/{user_id}", hello_body)]) 43 | app.middlewares.append(validation_error_middleware) 44 | 45 | web.run_app(app) 46 | 47 | 48 | if __name__ == "__main__": 49 | main() 50 | ``` 51 | 52 | And there are also more examples of usage at [`examples/`](https://github.com/Gr1N/aioapi/tree/master/example) directory. 53 | 54 | To run them use command below: 55 | 56 | ```sh 57 | $ make example 58 | ``` 59 | 60 | ## Contributing 61 | 62 | To work on the `AIOAPI` codebase, you'll want to clone the project locally and install the required dependencies via [poetry](https://poetry.eustace.io): 63 | 64 | ```sh 65 | $ git clone git@github.com:Gr1N/aioapi.git 66 | $ make install 67 | ``` 68 | 69 | To run tests and linters use command below: 70 | 71 | ```sh 72 | $ make lint && make test 73 | ``` 74 | 75 | If you want to run only tests or linters you can explicitly specify what you want to run, e.g.: 76 | 77 | ```sh 78 | $ make lint-black 79 | ``` 80 | 81 | ## Milestones 82 | 83 | If you're interesting in project's future you can find milestones and plans at [projects](https://github.com/Gr1N/aioapi/projects) page. 84 | 85 | ## License 86 | 87 | `AIOAPI` is licensed under the MIT license. See the license file for details. 88 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # AIOAPI 2 | 3 | [![Build Status](https://cloud.drone.io/api/badges/Gr1N/aioapi/status.svg)](https://cloud.drone.io/Gr1N/aioapi) [![codecov](https://codecov.io/gh/Gr1N/aioapi/branch/master/graph/badge.svg)](https://codecov.io/gh/Gr1N/aioapi) ![PyPI](https://img.shields.io/pypi/v/aioapi.svg?label=pypi%20version) ![PyPI - Downloads](https://img.shields.io/pypi/dm/aioapi.svg?label=pypi%20downloads) ![GitHub](https://img.shields.io/github/license/Gr1N/aioapi.svg) 4 | 5 | `AIOAPI` is a library for building APIs with [`AIOHTTP`](https://aiohttp.readthedocs.io/) framework and Python 3.7+ based on standard Python type hints. 6 | 7 | ## Install 8 | 9 | Just: 10 | 11 | ```bash 12 | $ pip install aioapi 13 | ``` 14 | 15 | ## Requirements 16 | 17 | `AIOAPI` depends on [`AIOHTTP`](https://aiohttp.readthedocs.io/) framework and tries to extend the view layer in the right way, also `AIOAPI` depends on [`pydantic`](https://pydantic-docs.helpmanual.io/) — a great data validation library. 18 | 19 | ## At a glance 20 | 21 | Look at simple application below and pay attention to the highlighted lines to see the power of `AIOAPI`: 22 | 23 | ```python hl_lines="24 31" 24 | from http import HTTPStatus 25 | from uuid import UUID 26 | 27 | import aioapi as api 28 | from aioapi import Body, PathParam 29 | from aiohttp import web 30 | from pydantic import BaseModel 31 | 32 | 33 | class Database: 34 | async def get_user(self, *, user_id): 35 | ... 36 | 37 | async def create_user(self, *, user_id, name, age): 38 | ... 39 | 40 | 41 | class User(BaseModel): 42 | user_id: UUID 43 | name: string 44 | age: int = 42 45 | 46 | 47 | async def get_user(app: web.Application, user_id: PathParam[UUID]): 48 | user = await app["db"].get_user(user_id=user_id) 49 | 50 | return web.json_response( 51 | {"user_id": user.user_id, "name": user.name, "age": user.age} 52 | ) 53 | 54 | async def create_user(app: web.Application, body: Body[User]) 55 | user = body.cleaned 56 | await app["db"].create_user( 57 | user_id=user.user_id, name=user.name, age=user.age 58 | ) 59 | 60 | return web.Response(status=HTTPStatus.CREATED) 61 | 62 | 63 | def main(): 64 | app = web.Application() 65 | 66 | app["db"] = Database() 67 | app.add_routes([ 68 | api.post("/users", create_user), 69 | api.get("/users/{user_id}", get_user), 70 | ]) 71 | 72 | web.run_app(app) 73 | 74 | 75 | if __name__ == "__main__": 76 | main() 77 | ``` 78 | 79 | That simple example shows you how `AIOAPI` can help you to simplify your daily routine with data serialization and validation in APIs. As you can see you need just to define the right types and `AIOAPI` will do all other job for you. 80 | 81 | Looks interesting for you? Go ahead and explore documentation, `AIOAPI` can surprise you! 82 | -------------------------------------------------------------------------------- /docs/tutorial/request_body.md: -------------------------------------------------------------------------------- 1 | # Request Body 2 | 3 | You can declare a request body and its type, using standard Python type annotations. 4 | 5 | The request body, as well as query parameters, can be defined with a default value. 6 | 7 | To declare body type use `pydantic` models. 8 | 9 | And you can always combine and use path and query parameters, and request body in one view. 10 | 11 | ```python hl_lines="5 8 9 10 13" 12 | import aioapi as api 13 | from aioapi import Body, PathParam 14 | from aioapi.middlewares import validation_error_middleware 15 | from aiohttp import web 16 | from pydantic import BaseModel 17 | 18 | 19 | class User(BaseModel): 20 | name: str 21 | age: int = 42 22 | 23 | 24 | async def hello_body(user_id: PathParam[int], body: Body[User]): 25 | user = body.cleaned 26 | return web.json_response( 27 | {"id": user_id.cleaned, "name": user.name, "age": user.age} 28 | ) 29 | 30 | 31 | def main(): 32 | app = web.Application() 33 | 34 | app.add_routes([api.post("/hello/{user_id}", hello_body)]) 35 | app.middlewares.append(validation_error_middleware) 36 | 37 | web.run_app(app) 38 | 39 | 40 | if __name__ == "__main__": 41 | main() 42 | ``` 43 | 44 | If you run this example and send a request to `/hello/42` route you will see: 45 | 46 | ```bash 47 | $ http :8080/hello/42 name=batman 48 | HTTP/1.1 200 OK 49 | Content-Length: 39 50 | Content-Type: application/json; charset=utf-8 51 | Date: Fri, 12 Apr 2019 19:56:47 GMT 52 | Server: Python/3.7 aiohttp/3.5.4 53 | 54 | { 55 | "age": 42, 56 | "id": 42, 57 | "name": "batman" 58 | } 59 | ``` 60 | 61 | But if you send a request to `/hello/batman` route you will see an error: 62 | 63 | ```bash 64 | $ http :8080/hello/batman age=random 65 | HTTP/1.1 400 Bad Request 66 | Content-Length: 378 67 | Content-Type: application/json; charset=utf-8 68 | Date: Fri, 12 Apr 2019 19:57:49 GMT 69 | Server: Python/3.7 aiohttp/3.5.4 70 | 71 | { 72 | "invalid_params": [ 73 | { 74 | "loc": [ 75 | "body", 76 | "name" 77 | ], 78 | "msg": "field required", 79 | "type": "value_error.missing" 80 | }, 81 | { 82 | "loc": [ 83 | "body", 84 | "age" 85 | ], 86 | "msg": "value is not a valid integer", 87 | "type": "type_error.integer" 88 | }, 89 | { 90 | "loc": [ 91 | "path", 92 | "user_id" 93 | ], 94 | "msg": "value is not a valid integer", 95 | "type": "type_error.integer" 96 | } 97 | ], 98 | "title": "Your request parameters didn't validate.", 99 | "type": "validation_error" 100 | } 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/tutorial/handling_errors.md: -------------------------------------------------------------------------------- 1 | # Handling Errors 2 | 3 | `AIOAPI` by default returns empty `400 Bad Request` response in case of any validation error. 4 | 5 | If you run example below: 6 | 7 | ```python 8 | import aioapi as api 9 | from aioapi import QueryParam 10 | from aiohttp import web 11 | 12 | 13 | async def hello_errors(name: QueryParam[str]): 14 | return web.Response() 15 | 16 | 17 | def main(): 18 | app = web.Application() 19 | 20 | app.add_routes([api.get("/hello_errors", hello_errors)]) 21 | 22 | web.run_app(app) 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | ``` 28 | 29 | And send request to `/hello_errors` route you will see: 30 | 31 | ```bash 32 | $ http :8080/hello_errors 33 | HTTP/1.1 400 Bad Request 34 | Content-Length: 16 35 | Content-Type: text/plain; charset=utf-8 36 | Date: Fri, 12 Apr 2019 20:24:50 GMT 37 | Server: Python/3.7 aiohttp/3.5.4 38 | 39 | 400: Bad Request 40 | ``` 41 | 42 | To get more fancy `400 Bad Request` response you can use `validation_error_middleware` middleware: 43 | 44 | ```python hl_lines="3 15" 45 | import aioapi as api 46 | from aioapi import QueryParam 47 | from aioapi.middlewares import validation_error_middleware 48 | from aiohttp import web 49 | 50 | 51 | async def hello_errors(name: QueryParam[str]): 52 | return web.Response() 53 | 54 | 55 | def main(): 56 | app = web.Application() 57 | 58 | app.add_routes([api.get("/hello_errors", hello_errors)]) 59 | app.middlewares.append(validation_error_middleware) 60 | 61 | web.run_app(app) 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | ``` 67 | 68 | If you send a request to `/hello_errors` route you will see an error: 69 | 70 | ```bash 71 | $ http :8080/hello_errors 72 | HTTP/1.1 400 Bad Request 73 | Content-Length: 185 74 | Content-Type: application/json; charset=utf-8 75 | Date: Fri, 12 Apr 2019 20:31:49 GMT 76 | Server: Python/3.7 aiohttp/3.5.4 77 | 78 | { 79 | "invalid_params": [ 80 | { 81 | "loc": [ 82 | "query", 83 | "name" 84 | ], 85 | "msg": "field required", 86 | "type": "value_error.missing" 87 | } 88 | ], 89 | "title": "Your request parameters didn't validate.", 90 | "type": "validation_error" 91 | } 92 | ``` 93 | 94 | And also you can write your own middleware to handle validation errors: 95 | 96 | ```python hl_lines="13 14 15 16 17 18 19 20 21 22 23 30" 97 | import json 98 | 99 | import aioapi as api 100 | from aioapi import QueryParam 101 | from aioapi.exceptions import HTTPBadRequest 102 | from aiohttp import web 103 | 104 | 105 | async def hello_errors(name: QueryParam[str]): 106 | return web.Response() 107 | 108 | 109 | @web.middleware 110 | async def custom_error_middleware(request, handler): 111 | try: 112 | resp = await handler(request) 113 | except HTTPBadRequest as e: 114 | raise web.HTTPBadRequest( 115 | content_type="application/json", 116 | text=json.dumps(e.validation_error.errors()), 117 | ) 118 | 119 | return resp 120 | 121 | 122 | def main(): 123 | app = web.Application() 124 | 125 | app.add_routes([api.get("/hello_errors", hello_errors)]) 126 | app.middlewares.append(custom_error_middleware) 127 | 128 | web.run_app(app) 129 | 130 | 131 | if __name__ == "__main__": 132 | main() 133 | ``` 134 | 135 | If you send a request to `/hello_errors` route you will see an error: 136 | 137 | ```bash 138 | $ http :8080/hello_errors 139 | HTTP/1.1 400 Bad Request 140 | Content-Length: 84 141 | Content-Type: application/json; charset=utf-8 142 | Date: Fri, 12 Apr 2019 20:34:56 GMT 143 | Server: Python/3.7 aiohttp/3.5.4 144 | 145 | [ 146 | { 147 | "loc": [ 148 | "query", 149 | "name" 150 | ], 151 | "msg": "field required", 152 | "type": "value_error.missing" 153 | } 154 | ] 155 | ``` 156 | -------------------------------------------------------------------------------- /aioapi/inspect/inspector.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import partial 3 | from typing import Any, Awaitable, Callable, Optional, Tuple 4 | 5 | from aiohttp.web import Application, Request 6 | from pydantic import Required, create_model 7 | 8 | from aioapi.inspect.entities import HandlerMeta 9 | from aioapi.inspect.exceptions import ( 10 | HandlerMultipleBodyError, 11 | HandlerParamUnknownTypeError, 12 | ) 13 | from aioapi.typedefs import Body, PathParam, QueryParam 14 | 15 | __all__ = ("HandlerInspector", "param_of") 16 | 17 | NOT_INITIALIZED = object() 18 | 19 | 20 | class HandlerInspector: 21 | __slots__ = ("_handler", "_handler_name") 22 | 23 | def __init__( 24 | self, *, handler: Callable[..., Awaitable], handler_name: Optional[str] = None 25 | ) -> None: 26 | self._handler = handler 27 | self._handler_name = handler_name or f"{handler.__module__}.{handler.__name__}" 28 | 29 | def __call__(self) -> HandlerMeta: 30 | components_mapping = {} 31 | body_pair = None 32 | path_mapping = {} 33 | query_mapping = {} 34 | 35 | signature = inspect.signature(self._handler) 36 | for param in signature.parameters.values(): 37 | param_name = param.name 38 | # We allow to skip inspection for some parameters, e.g. `self`. 39 | if param_name in ("self",): 40 | continue 41 | 42 | param_type = param.annotation 43 | param_of_type = partial(param_of, type_=param_type) 44 | 45 | if param_of_type(is_=Application) or param_of_type(is_=Request): 46 | components_mapping[param_name] = param_type 47 | elif param_of_type(is_=Body): 48 | # We allow only one parameter of body type, so if there are more 49 | # parameters of body type we will raise a corresponding error. 50 | if body_pair is not None: 51 | raise HandlerMultipleBodyError( 52 | handler=self._handler_name, param=param_name 53 | ) 54 | 55 | body_pair = ( 56 | param_name, 57 | inspect_param_type(param_type, inspect_default=param.default), 58 | ) 59 | elif param_of_type(is_=PathParam): 60 | path_mapping[param_name] = inspect_param_type(param_type) 61 | elif param_of_type(is_=QueryParam): 62 | query_mapping[param_name] = inspect_param_type( 63 | param_type, inspect_default=param.default 64 | ) 65 | else: 66 | raise HandlerParamUnknownTypeError( 67 | handler=self._handler_name, param=param_name 68 | ) 69 | 70 | request_mapping = {} 71 | if body_pair: 72 | _, body_type = body_pair 73 | request_mapping["body"] = body_type 74 | 75 | for k, mapping in (("path", path_mapping), ("query", query_mapping)): 76 | if not mapping: 77 | continue 78 | 79 | request_mapping[k] = ( 80 | create_model( 81 | k.title(), **{k: v for k, v in mapping.items()} # type: ignore 82 | ), 83 | Required, 84 | ) 85 | 86 | request_type = ( 87 | create_model("Request", **request_mapping) # type: ignore 88 | if request_mapping 89 | else None 90 | ) 91 | 92 | return HandlerMeta( 93 | name=self._handler_name, 94 | components_mapping=components_mapping or None, 95 | request_type=request_type, 96 | request_body_pair=body_pair, 97 | request_path_mapping=path_mapping or None, 98 | request_query_mapping=query_mapping or None, 99 | ) 100 | 101 | 102 | def param_of(*, type_, is_) -> bool: 103 | return getattr(type_, "__origin__", type_) is is_ 104 | 105 | 106 | def inspect_param_type( 107 | type_, *, inspect_default: Any = NOT_INITIALIZED 108 | ) -> Tuple[Any, Any]: 109 | return ( 110 | inspect_param_inner_type(type_), 111 | ( 112 | inspect_param_default(inspect_default) 113 | if inspect_default is not NOT_INITIALIZED 114 | else Required 115 | ), 116 | ) 117 | 118 | 119 | def inspect_param_inner_type(type_) -> Any: 120 | return getattr(type_, "__args__", (Any,))[0] 121 | 122 | 123 | def inspect_param_default(default: Any) -> Any: 124 | if default == inspect.Signature.empty: 125 | return Required 126 | 127 | return getattr(default, "cleaned", default) 128 | -------------------------------------------------------------------------------- /tests/inspect/test_inspector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiohttp import web 3 | from pydantic import BaseModel 4 | 5 | from aioapi import Body, PathParam, QueryParam 6 | from aioapi.inspect.exceptions import ( 7 | HandlerMultipleBodyError, 8 | HandlerParamUnknownTypeError, 9 | ) 10 | from aioapi.inspect.inspector import HandlerInspector 11 | 12 | 13 | class TestInspector: 14 | def test_unknown_param_type(self): 15 | async def handler(unknown: int): 16 | pass 17 | 18 | with pytest.raises(HandlerParamUnknownTypeError) as exc_info: 19 | HandlerInspector(handler=handler)() 20 | assert exc_info.value.handler == "test_inspector.handler" 21 | assert exc_info.value.param == "unknown" 22 | 23 | def test_skip_param(self): 24 | async def handler(self): 25 | pass 26 | 27 | meta = HandlerInspector(handler=handler)() 28 | assert meta.name == "test_inspector.handler" 29 | assert meta.components_mapping is None 30 | assert meta.request_type is None 31 | assert meta.request_body_pair is None 32 | assert meta.request_path_mapping is None 33 | assert meta.request_query_mapping is None 34 | 35 | def test_request_and_application(self): 36 | async def handler(request: web.Request, app: web.Application): 37 | pass 38 | 39 | meta = HandlerInspector(handler=handler)() 40 | assert meta.name == "test_inspector.handler" 41 | assert meta.components_mapping == { 42 | "request": web.Request, 43 | "app": web.Application, 44 | } 45 | assert meta.request_type is None 46 | assert meta.request_body_pair is None 47 | assert meta.request_path_mapping is None 48 | assert meta.request_query_mapping is None 49 | 50 | def test_body(self): 51 | async def handler(b: Body[int]): 52 | pass 53 | 54 | meta = HandlerInspector(handler=handler)() 55 | assert meta.name == "test_inspector.handler" 56 | assert meta.components_mapping is None 57 | assert issubclass(meta.request_type, BaseModel) 58 | assert meta.request_body_pair == ("b", (int, ...)) 59 | assert meta.request_path_mapping is None 60 | assert meta.request_query_mapping is None 61 | 62 | def test_body_default(self): 63 | async def handler(b: Body[int] = Body(42)): # noqa: B009 64 | pass 65 | 66 | meta = HandlerInspector(handler=handler)() 67 | assert meta.name == "test_inspector.handler" 68 | assert meta.components_mapping is None 69 | assert issubclass(meta.request_type, BaseModel) 70 | assert meta.request_body_pair == ("b", (int, 42)) 71 | assert meta.request_path_mapping is None 72 | assert meta.request_query_mapping is None 73 | 74 | def test_body_multiple(self): 75 | async def handler(b1: Body[int], b2: Body[str]): 76 | pass 77 | 78 | with pytest.raises(HandlerMultipleBodyError) as exc_info: 79 | HandlerInspector(handler=handler)() 80 | assert exc_info.value.handler == "test_inspector.handler" 81 | assert exc_info.value.param == "b2" 82 | 83 | def test_path_param(self): 84 | async def handler( 85 | pp: PathParam[int], ppd: PathParam[float] = PathParam(5.1) # noqa: B009 86 | ): 87 | pass 88 | 89 | meta = HandlerInspector(handler=handler)() 90 | assert meta.name == "test_inspector.handler" 91 | assert meta.components_mapping is None 92 | assert issubclass(meta.request_type, BaseModel) 93 | assert meta.request_body_pair is None 94 | assert meta.request_path_mapping == {"pp": (int, ...), "ppd": (float, ...)} 95 | assert meta.request_query_mapping is None 96 | 97 | def test_query_param(self): 98 | async def handler( 99 | qp: QueryParam[str], qpd: QueryParam[bool] = QueryParam(False) # noqa: B009 100 | ): 101 | pass 102 | 103 | meta = HandlerInspector(handler=handler)() 104 | assert meta.name == "test_inspector.handler" 105 | assert meta.components_mapping is None 106 | assert issubclass(meta.request_type, BaseModel) 107 | assert meta.request_body_pair is None 108 | assert meta.request_path_mapping is None 109 | assert meta.request_query_mapping == {"qp": (str, ...), "qpd": (bool, False)} 110 | 111 | def test_multiple(self): 112 | async def handler( 113 | request: web.Request, 114 | b: Body[int], 115 | pp: PathParam[int], 116 | qp: QueryParam[str], 117 | qpd: QueryParam[bool] = QueryParam(False), # noqa: B009 118 | ): 119 | pass 120 | 121 | meta = HandlerInspector(handler=handler)() 122 | assert meta.name == "test_inspector.handler" 123 | assert meta.components_mapping == {"request": web.Request} 124 | assert issubclass(meta.request_type, BaseModel) 125 | assert meta.request_body_pair == ("b", (int, ...)) 126 | assert meta.request_path_mapping == {"pp": (int, ...)} 127 | assert meta.request_query_mapping == {"qp": (str, ...), "qpd": (bool, False)} 128 | -------------------------------------------------------------------------------- /aioapi/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import dataclass 3 | from typing import Awaitable, Callable, Dict, Iterator, Tuple, Union, cast 4 | 5 | from aiohttp import hdrs, web 6 | from aiohttp.abc import AbstractView 7 | from aiohttp.web_routedef import _HandlerType, _SimpleHandler 8 | from pydantic import BaseModel, ValidationError 9 | 10 | from aioapi.exceptions import HTTPBadRequest 11 | from aioapi.inspect.entities import HandlerMeta 12 | from aioapi.inspect.inspector import HandlerInspector, param_of 13 | from aioapi.typedefs import Body, PathParam, QueryParam 14 | 15 | __all__ = ("wraps", "wraps_simple", "wraps_method") 16 | 17 | _HandlerCallable = Callable[..., Awaitable] 18 | _HandlerParams = Union[Body, PathParam, QueryParam, web.Application, web.Request] 19 | _HandlerKwargs = Dict[str, _HandlerParams] 20 | 21 | _GenRawDataResult = Tuple[str, dict] 22 | _GenRawDataCallable = Callable[[web.Request], Awaitable[_GenRawDataResult]] 23 | 24 | _GenKwargsResult = Iterator[Tuple[str, _HandlerParams]] 25 | _GenKwargsCallable = Callable[[HandlerMeta, BaseModel], _GenKwargsResult] 26 | 27 | 28 | @dataclass(frozen=True) 29 | class DataGenerators: 30 | raw: Tuple[_GenRawDataCallable, ...] 31 | kwargs: Tuple[_GenKwargsCallable, ...] 32 | 33 | 34 | def wraps(handler: _HandlerType) -> _HandlerType: 35 | try: 36 | issubclass(handler, AbstractView) # type: ignore 37 | except TypeError: 38 | return wraps_simple(cast(_SimpleHandler, handler)) 39 | 40 | for method in hdrs.METH_ALL: 41 | method = method.lower() 42 | 43 | handler_callable = getattr(handler, method, None) 44 | if handler_callable is None: 45 | continue 46 | 47 | handler_name = ( 48 | f"{handler.__module__}.{handler.__name__}" # type: ignore 49 | f".{handler_callable.__name__}" 50 | ) 51 | setattr( 52 | handler, 53 | method, 54 | wraps_method(handler=handler_callable, handler_name=handler_name), 55 | ) 56 | 57 | return handler 58 | 59 | 60 | def wraps_simple(handler: _SimpleHandler) -> _SimpleHandler: 61 | handler_casted = cast(_HandlerCallable, handler) 62 | handler_meta = HandlerInspector(handler=handler_casted)() 63 | data_generators = _get_data_generators(handler_meta) 64 | 65 | async def wrapped(request: web.Request) -> web.StreamResponse: 66 | kwargs = await _handle_kwargs(handler_meta, data_generators, request) 67 | resp = await handler_casted(**kwargs) 68 | 69 | return resp 70 | 71 | return wrapped 72 | 73 | 74 | def wraps_method(*, handler: _HandlerCallable, handler_name: str) -> _HandlerCallable: 75 | handler_meta = HandlerInspector(handler=handler, handler_name=handler_name)() 76 | data_generators = _get_data_generators(handler_meta) 77 | 78 | async def wrapped(self) -> web.StreamResponse: 79 | kwargs = await _handle_kwargs(handler_meta, data_generators, self.request) 80 | resp = await handler(self, **kwargs) 81 | 82 | return resp 83 | 84 | return wrapped 85 | 86 | 87 | async def _handle_kwargs( 88 | meta: HandlerMeta, data_generators: DataGenerators, request: web.Request 89 | ) -> _HandlerKwargs: 90 | kwargs_composed = _compose_kwargs(meta, request) 91 | kwargs_validated = await _validate_kwargs(meta, data_generators, request) 92 | 93 | return dict(**kwargs_composed, **kwargs_validated) 94 | 95 | 96 | def _compose_kwargs(meta: HandlerMeta, request: web.Request) -> _HandlerKwargs: 97 | composed: _HandlerKwargs = {} 98 | if meta.components_mapping is None: 99 | return composed 100 | 101 | for k, type_ in meta.components_mapping.items(): 102 | if param_of(type_=type_, is_=web.Application): 103 | composed[k] = request.app 104 | elif param_of(type_=type_, is_=web.Request): # pragma: no branch 105 | composed[k] = request 106 | 107 | return composed 108 | 109 | 110 | async def _validate_kwargs( 111 | meta: HandlerMeta, data_generators: DataGenerators, request: web.Request 112 | ) -> _HandlerKwargs: 113 | validated: _HandlerKwargs = {} 114 | if meta.request_type is None: 115 | return validated 116 | 117 | raw = {} 118 | for raw_data_generator in data_generators.raw: 119 | k, raw_data = await raw_data_generator(request) 120 | raw[k] = raw_data 121 | 122 | try: 123 | cleaned = cast(BaseModel, meta.request_type).parse_obj(raw) 124 | except ValidationError as e: 125 | raise HTTPBadRequest(validation_error=e) from e 126 | 127 | for kwargs_data_generator in data_generators.kwargs: 128 | for k, param in kwargs_data_generator(meta, cleaned): 129 | validated[k] = param 130 | 131 | return validated 132 | 133 | 134 | def _get_data_generators(meta: HandlerMeta) -> DataGenerators: 135 | return DataGenerators( 136 | raw=tuple(_gen_raw_data_generators(meta)), 137 | kwargs=tuple(_gen_kwargs_data_generators(meta)), 138 | ) 139 | 140 | 141 | def _gen_raw_data_generators(meta: HandlerMeta) -> Iterator[_GenRawDataCallable]: 142 | if meta.request_body_pair: 143 | yield _gen_body_raw_data 144 | 145 | if meta.request_path_mapping: 146 | yield _gen_path_raw_data 147 | 148 | if meta.request_query_mapping: 149 | yield _gen_query_raw_data 150 | 151 | 152 | async def _gen_body_raw_data(request: web.Request) -> _GenRawDataResult: 153 | try: 154 | raw = await request.json() 155 | except json.JSONDecodeError: 156 | raw = {} 157 | 158 | return "body", raw 159 | 160 | 161 | async def _gen_path_raw_data(request: web.Request) -> _GenRawDataResult: 162 | return "path", dict(request.match_info) 163 | 164 | 165 | async def _gen_query_raw_data(request: web.Request) -> _GenRawDataResult: 166 | return "query", dict(request.query) 167 | 168 | 169 | def _gen_kwargs_data_generators(meta: HandlerMeta) -> Iterator[_GenKwargsCallable]: 170 | if meta.request_body_pair: 171 | yield _gen_body_kwargs 172 | 173 | if meta.request_path_mapping: 174 | yield _gen_path_kwargs 175 | 176 | if meta.request_query_mapping: 177 | yield _gen_query_kwargs 178 | 179 | 180 | def _gen_body_kwargs(meta: HandlerMeta, cleaned: BaseModel) -> _GenKwargsResult: 181 | k, _ = cast(tuple, meta.request_body_pair) 182 | yield k, Body(cleaned.body) # type: ignore 183 | 184 | 185 | def _gen_path_kwargs(_meta: HandlerMeta, cleaned: BaseModel) -> _GenKwargsResult: 186 | for k, v in cleaned.path: # type: ignore 187 | yield k, PathParam(v) 188 | 189 | 190 | def _gen_query_kwargs(_meta: HandlerMeta, cleaned: BaseModel) -> _GenKwargsResult: 191 | for k, v in cleaned.query: # type: ignore 192 | yield k, QueryParam(v) 193 | -------------------------------------------------------------------------------- /tests/test_handler.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from aiohttp import web 5 | from pydantic import BaseModel 6 | 7 | import aioapi as api 8 | 9 | 10 | class TestSimple: 11 | async def test_simple(self, client_for): 12 | async def handler(): 13 | return web.json_response({"super": "simple"}) 14 | 15 | client = await client_for(routes=[api.get("/test/simple", handler)]) 16 | resp = await client.get("/test/simple") 17 | 18 | assert resp.status == HTTPStatus.OK 19 | assert await resp.json() == {"super": "simple"} 20 | 21 | async def test_request_and_application(self, client_for): 22 | async def handler(request: web.Request, app: web.Application): 23 | return web.json_response({"request": id(request), "app": id(app)}) 24 | 25 | client = await client_for(routes=[api.get("/test/reqapp", handler)]) 26 | resp = await client.get("/test/reqapp") 27 | 28 | assert resp.status == HTTPStatus.OK 29 | result = await resp.json() 30 | assert isinstance(result["request"], int) 31 | assert isinstance(result["app"], int) 32 | 33 | @pytest.mark.parametrize( 34 | "req, resp_status, resp_body", 35 | ( 36 | ( 37 | {"name": "Walter"}, 38 | HTTPStatus.OK, 39 | {"user": {"name": "Walter", "age": 42}}, 40 | ), 41 | ( 42 | {}, 43 | HTTPStatus.BAD_REQUEST, 44 | { 45 | "type": "validation_error", 46 | "title": "Your request parameters didn't validate.", 47 | "invalid_params": [ 48 | { 49 | "loc": ["body", "name"], 50 | "msg": "field required", 51 | "type": "value_error.missing", 52 | } 53 | ], 54 | }, 55 | ), 56 | ( 57 | None, 58 | HTTPStatus.BAD_REQUEST, 59 | { 60 | "type": "validation_error", 61 | "title": "Your request parameters didn't validate.", 62 | "invalid_params": [ 63 | { 64 | "loc": ["body", "name"], 65 | "msg": "field required", 66 | "type": "value_error.missing", 67 | } 68 | ], 69 | }, 70 | ), 71 | ( 72 | {"name": "Walter", "age": "random"}, 73 | HTTPStatus.BAD_REQUEST, 74 | { 75 | "type": "validation_error", 76 | "title": "Your request parameters didn't validate.", 77 | "invalid_params": [ 78 | { 79 | "loc": ["body", "age"], 80 | "msg": "value is not a valid integer", 81 | "type": "type_error.integer", 82 | } 83 | ], 84 | }, 85 | ), 86 | ), 87 | ) 88 | async def test_body(self, client_for, req, resp_status, resp_body): 89 | class User(BaseModel): 90 | name: str 91 | age: int = 42 92 | 93 | async def handler(body: api.Body[User]): 94 | return web.json_response({"user": body.cleaned.dict()}) 95 | 96 | client = await client_for(routes=[api.post("/test/body", handler)]) 97 | resp = await client.post("/test/body", json=req) 98 | 99 | assert resp.status == resp_status 100 | assert await resp.json() == resp_body 101 | 102 | @pytest.mark.parametrize( 103 | "req, resp_status, resp_body", 104 | ( 105 | (42, HTTPStatus.OK, {"pp": 42}), 106 | ( 107 | "string", 108 | HTTPStatus.BAD_REQUEST, 109 | { 110 | "type": "validation_error", 111 | "title": "Your request parameters didn't validate.", 112 | "invalid_params": [ 113 | { 114 | "loc": ["path", "pp"], 115 | "msg": "value is not a valid integer", 116 | "type": "type_error.integer", 117 | } 118 | ], 119 | }, 120 | ), 121 | ), 122 | ) 123 | async def test_path_param(self, client_for, req, resp_status, resp_body): 124 | async def handler(pp: api.PathParam[int]): 125 | return web.json_response({"pp": pp.cleaned}) 126 | 127 | client = await client_for(routes=[api.get("/test/{pp}", handler)]) 128 | resp = await client.get(f"/test/{req}") 129 | 130 | assert resp.status == resp_status 131 | assert await resp.json() == resp_body 132 | 133 | @pytest.mark.parametrize( 134 | "req, resp_status, resp_body", 135 | ( 136 | ({"qp": 42}, HTTPStatus.OK, {"qp": 42, "qpd": "random"}), 137 | ( 138 | {"qp": "string"}, 139 | HTTPStatus.BAD_REQUEST, 140 | { 141 | "type": "validation_error", 142 | "title": "Your request parameters didn't validate.", 143 | "invalid_params": [ 144 | { 145 | "loc": ["query", "qp"], 146 | "msg": "value is not a valid integer", 147 | "type": "type_error.integer", 148 | } 149 | ], 150 | }, 151 | ), 152 | ), 153 | ) 154 | async def test_query_param(self, client_for, req, resp_status, resp_body): 155 | async def handler( 156 | qp: api.QueryParam[int], 157 | qpd: api.QueryParam[str] = api.QueryParam("random"), # noqa: B009 158 | ): 159 | return web.json_response({"qp": qp.cleaned, "qpd": qpd.cleaned}) 160 | 161 | client = await client_for(routes=[api.get("/test/query", handler)]) 162 | resp = await client.get("/test/query", params=req) 163 | 164 | assert resp.status == resp_status 165 | assert await resp.json() == resp_body 166 | 167 | @pytest.mark.parametrize( 168 | "req, resp_status, resp_body", 169 | ( 170 | ( 171 | {"path": 42, "query": {"qp": 84}, "body": {"name": "Walter"}}, 172 | HTTPStatus.OK, 173 | { 174 | "user": {"name": "Walter", "age": 42}, 175 | "pp": 42, 176 | "qp": 84, 177 | "qpd": "random", 178 | }, 179 | ), 180 | ( 181 | {"path": 42, "query": {"qp": "random"}, "body": {}}, 182 | HTTPStatus.BAD_REQUEST, 183 | { 184 | "type": "validation_error", 185 | "title": "Your request parameters didn't validate.", 186 | "invalid_params": [ 187 | { 188 | "loc": ["body", "name"], 189 | "msg": "field required", 190 | "type": "value_error.missing", 191 | }, 192 | { 193 | "loc": ["query", "qp"], 194 | "msg": "value is not a valid integer", 195 | "type": "type_error.integer", 196 | }, 197 | ], 198 | }, 199 | ), 200 | ), 201 | ) 202 | async def test_multiple(self, client_for, req, resp_status, resp_body): 203 | class User(BaseModel): 204 | name: str 205 | age: int = 42 206 | 207 | async def handler( 208 | request: web.Request, 209 | body: api.Body[User], 210 | pp: api.PathParam[int], 211 | qp: api.QueryParam[int], 212 | qpd: api.QueryParam[str] = api.QueryParam("random"), # noqa: B009 213 | ): 214 | return web.json_response( 215 | { 216 | "user": body.cleaned.dict(), 217 | "pp": pp.cleaned, 218 | "qp": qp.cleaned, 219 | "qpd": qpd.cleaned, 220 | } 221 | ) 222 | 223 | client = await client_for(routes=[api.put("/test/{pp}", handler)]) 224 | resp = await client.put( 225 | f"/test/{req['path']}", json=req["body"], params=req["query"] 226 | ) 227 | 228 | assert resp.status == resp_status 229 | assert await resp.json() == resp_body 230 | 231 | 232 | class TestCBV: 233 | async def test_simple(self, client_for): 234 | class View(web.View): 235 | async def get(self): 236 | return web.json_response({"super": "simple"}) 237 | 238 | client = await client_for(routes=[api.view("/test/simple", View)]) 239 | resp = await client.get("/test/simple") 240 | 241 | assert resp.status == HTTPStatus.OK 242 | assert await resp.json() == {"super": "simple"} 243 | 244 | async def test_request_and_application(self, client_for): 245 | class View(web.View): 246 | async def get(self, request: web.Request, app: web.Application): 247 | return web.json_response({"request": id(request), "app": id(app)}) 248 | 249 | client = await client_for(routes=[api.view("/test/reqapp", View)]) 250 | resp = await client.get("/test/reqapp") 251 | 252 | assert resp.status == HTTPStatus.OK 253 | result = await resp.json() 254 | assert isinstance(result["request"], int) 255 | assert isinstance(result["app"], int) 256 | 257 | @pytest.mark.parametrize( 258 | "req, resp_status, resp_body", 259 | ( 260 | ( 261 | {"name": "Walter"}, 262 | HTTPStatus.OK, 263 | {"user": {"name": "Walter", "age": 42}}, 264 | ), 265 | ( 266 | {}, 267 | HTTPStatus.BAD_REQUEST, 268 | { 269 | "type": "validation_error", 270 | "title": "Your request parameters didn't validate.", 271 | "invalid_params": [ 272 | { 273 | "loc": ["body", "name"], 274 | "msg": "field required", 275 | "type": "value_error.missing", 276 | } 277 | ], 278 | }, 279 | ), 280 | ( 281 | {"name": "Walter", "age": "random"}, 282 | HTTPStatus.BAD_REQUEST, 283 | { 284 | "type": "validation_error", 285 | "title": "Your request parameters didn't validate.", 286 | "invalid_params": [ 287 | { 288 | "loc": ["body", "age"], 289 | "msg": "value is not a valid integer", 290 | "type": "type_error.integer", 291 | } 292 | ], 293 | }, 294 | ), 295 | ), 296 | ) 297 | async def test_body(self, client_for, req, resp_status, resp_body): 298 | class User(BaseModel): 299 | name: str 300 | age: int = 42 301 | 302 | class View(web.View): 303 | async def post(self, body: api.Body[User]): 304 | return web.json_response({"user": body.cleaned.dict()}) 305 | 306 | client = await client_for(routes=[api.view("/test/body", View)]) 307 | resp = await client.post("/test/body", json=req) 308 | 309 | assert resp.status == resp_status 310 | assert await resp.json() == resp_body 311 | 312 | @pytest.mark.parametrize( 313 | "req, resp_status, resp_body", 314 | ( 315 | (42, HTTPStatus.OK, {"pp": 42}), 316 | ( 317 | "string", 318 | HTTPStatus.BAD_REQUEST, 319 | { 320 | "type": "validation_error", 321 | "title": "Your request parameters didn't validate.", 322 | "invalid_params": [ 323 | { 324 | "loc": ["path", "pp"], 325 | "msg": "value is not a valid integer", 326 | "type": "type_error.integer", 327 | } 328 | ], 329 | }, 330 | ), 331 | ), 332 | ) 333 | async def test_path_param(self, client_for, req, resp_status, resp_body): 334 | class View(web.View): 335 | async def get(self, pp: api.PathParam[int]): 336 | return web.json_response({"pp": pp.cleaned}) 337 | 338 | client = await client_for(routes=[api.view("/test/{pp}", View)]) 339 | resp = await client.get(f"/test/{req}") 340 | 341 | assert resp.status == resp_status 342 | assert await resp.json() == resp_body 343 | 344 | @pytest.mark.parametrize( 345 | "req, resp_status, resp_body", 346 | ( 347 | ({"qp": 42}, HTTPStatus.OK, {"qp": 42, "qpd": "random"}), 348 | ( 349 | {"qp": "string"}, 350 | HTTPStatus.BAD_REQUEST, 351 | { 352 | "type": "validation_error", 353 | "title": "Your request parameters didn't validate.", 354 | "invalid_params": [ 355 | { 356 | "loc": ["query", "qp"], 357 | "msg": "value is not a valid integer", 358 | "type": "type_error.integer", 359 | } 360 | ], 361 | }, 362 | ), 363 | ), 364 | ) 365 | async def test_query_param(self, client_for, req, resp_status, resp_body): 366 | class View(web.View): 367 | async def get( 368 | self, 369 | qp: api.QueryParam[int], 370 | qpd: api.QueryParam[str] = api.QueryParam("random"), # noqa: B009 371 | ): 372 | return web.json_response({"qp": qp.cleaned, "qpd": qpd.cleaned}) 373 | 374 | client = await client_for(routes=[api.view("/test/query", View)]) 375 | resp = await client.get("/test/query", params=req) 376 | 377 | assert resp.status == resp_status 378 | assert await resp.json() == resp_body 379 | 380 | @pytest.mark.parametrize( 381 | "req, resp_status, resp_body", 382 | ( 383 | ( 384 | {"path": 42, "query": {"qp": 84}, "body": {"name": "Walter"}}, 385 | HTTPStatus.OK, 386 | { 387 | "user": {"name": "Walter", "age": 42}, 388 | "pp": 42, 389 | "qp": 84, 390 | "qpd": "random", 391 | }, 392 | ), 393 | ( 394 | {"path": 42, "query": {"qp": "random"}, "body": {}}, 395 | HTTPStatus.BAD_REQUEST, 396 | { 397 | "type": "validation_error", 398 | "title": "Your request parameters didn't validate.", 399 | "invalid_params": [ 400 | { 401 | "loc": ["body", "name"], 402 | "msg": "field required", 403 | "type": "value_error.missing", 404 | }, 405 | { 406 | "loc": ["query", "qp"], 407 | "msg": "value is not a valid integer", 408 | "type": "type_error.integer", 409 | }, 410 | ], 411 | }, 412 | ), 413 | ), 414 | ) 415 | async def test_multiple(self, client_for, req, resp_status, resp_body): 416 | class User(BaseModel): 417 | name: str 418 | age: int = 42 419 | 420 | class View(web.View): 421 | async def put( 422 | self, 423 | request: web.Request, 424 | body: api.Body[User], 425 | pp: api.PathParam[int], 426 | qp: api.QueryParam[int], 427 | qpd: api.QueryParam[str] = api.QueryParam("random"), # noqa: B009 428 | ): 429 | return web.json_response( 430 | { 431 | "user": body.cleaned.dict(), 432 | "pp": pp.cleaned, 433 | "qp": qp.cleaned, 434 | "qpd": qpd.cleaned, 435 | } 436 | ) 437 | 438 | client = await client_for(routes=[api.view("/test/{pp}", View)]) 439 | resp = await client.put( 440 | f"/test/{req['path']}", json=req["body"], params=req["query"] 441 | ) 442 | 443 | assert resp.status == resp_status 444 | assert await resp.json() == resp_body 445 | --------------------------------------------------------------------------------