├── 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 | [](https://github.com/Gr1N/aioapi/actions?query=workflow%3Adefault) [](https://codecov.io/gh/Gr1N/aioapi)   
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 | [](https://cloud.drone.io/Gr1N/aioapi) [](https://codecov.io/gh/Gr1N/aioapi)   
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 |
--------------------------------------------------------------------------------