Repository: michael-sulyak/aiohttp-rpc (language: python)

Issue:
Provide a way to send notification from the server
To deliver notifications from the server to the client, it's conventional for the server to act as a client by sending an `id`-less request to the client - the JSON-RPC spec defines a client as the origin of request and the server as the origin of response objects and either node can fill either one or both of those roles.  Would you consider providing a way for the server to send `Request` objects to the client?


Discussion / hints:
If I understood you correctly, then there is already this opportunity. You can process a request and send requests to others in the same code.

You can use this in the same code:
https://github.com/expert-m/aiohttp-rpc#http-server-example
https://github.com/expert-m/aiohttp-rpc#http-client-example
Unless I'm missing something, there doesn't seem to be a way to send arbitrary requests (notifications) from the server to the client - you can only respond to a request with exactly one response.
But you can write own middleware that will create `rpc_client` or write some decorator.

```python3
async def some_middleware(request: protocol.JsonRpcRequest, handler: typing.Callable) -> protocol.JsonRpcResponse:
    async with aiohttp_rpc.JsonRpcClient(...) as rpc_client:
        request.extra_args['rpc_client'] = rpc_client
        return await handler(request)

...

async def ping(rpc_request, rpc_client):
    await rpc_client.ping()
    await rpc_client.ping()
```

Or maybe you want something like that (it does not work now, but I can think about it):

```python3
async def ping(rpc_request):
    yield 'pong'
    yield 'pong'
    yield 'pong'
    yield 'pong'
    yield 'pong'
```

Can you provide examples? Maybe then I can help better.
Well, that would require me to spawn a client in the request's call chain.  My client is already alive - what I want to do is send a message or several messages to it which look like requests rather than responses, during the lifetime of a request.  Something akin to:

```py
async def notify_middleware(
    request: JsonRpcRequest, handler: Callable[..., Any]
) -> JsonRpcResponse:
    async def notify(method: str, params: Mapping[str, Any]) -> None:
        # "notify" would construct a `JsonRpcRequest` with an "id" value of "null"
        # and send it to the client
        ...

    request.extra_args['notify'] = notify
    return await handler(request)

...

async def handle_request(
    rpc_request: JsonRpcRequest,
    notify: Callable[[str, Mapping[str, Any]], Awaitable[None]],
) -> JsonRpcResponse:
    for item in range(7):
        await notify(
            'status_report',
            {'status': f'processing item with number {item}'},
        )

    return {...}  # The actual "Response"

```

```python3
async def notify(method: str, params: Mapping[str, Any]) -> None:
```
- You want to call a specific method on the client, but the client does not have a list of methods (and I think that not need to change). 

Addition to my examples with the message above:
1. You can use pattern `repository` in this example. It can reduce created clients.
2. I see that you want to send some progress. So I can propose this variant:
```python3
# Server
async def do_task(rpc_request):
    for item in range(7):
        yeild {'status': f'processing item with number {item}'}

# Client
async for result in rpc.do_task():
    print(result['status'])
```
You are right, the client should not have methods.  However, the web client is able to function as a JSON-RPC server - the JSON-RPC 'server' does not have to map to a web server.  This sort of setup isn't possible with HTTP but it is possible with WebSockets.  My client is not in Python - it does not use `aiohttp-rpc` - so the proposed solution won't work for me.  I suppose I could just send a request to query for progress until I receive the result in the client, but I'd have to push the response state up the stack somehow, e.g. with `contextvars`.
Ahem, I should've probably mentioned this is meant for use with WebSockets earlier.

Fix patch:
diff --git a/aiohttp_rpc/__init__.py b/aiohttp_rpc/__init__.py
index ca108c1..60eaff9 100644
--- a/aiohttp_rpc/__init__.py
+++ b/aiohttp_rpc/__init__.py
@@ -1,5 +1,5 @@
 from . import client, constants, decorators, errors, middlewares, protocol, server, utils
-from .client import BaseJsonRpcClient, JsonRpcClient, UnlinkedResults, WsJsonRpcClient
+from .client import BaseJsonRpcClient, JsonRpcClient, WsJsonRpcClient
 from .decorators import rpc_method
-from .protocol import BaseJsonRpcMethod, JsonRpcMethod, JsonRpcRequest, JsonRpcResponse
+from .protocol import *
 from .server import BaseJsonRpcServer, JsonRpcServer, WsJsonRpcServer, rpc_server
diff --git a/aiohttp_rpc/client/base.py b/aiohttp_rpc/client/base.py
index 6c47b10..435b75f 100644
--- a/aiohttp_rpc/client/base.py
+++ b/aiohttp_rpc/client/base.py
@@ -1,19 +1,18 @@
 import abc
 import types
 import typing
-import uuid
-from dataclasses import dataclass
 from functools import partial
 
-from .. import constants, errors, utils
-from ..protocol import JsonRpcRequest, JsonRpcResponse
+from .. import constants, errors, protocol, utils
 
 
 __all__ = (
     'BaseJsonRpcClient',
-    'UnlinkedResults',
 )
 
+MethodDescription = typing.Union[str, list, tuple, protocol.JsonRpcRequest]
+MethodDescriptionList = typing.Iterable[typing.Union[MethodDescription, protocol.JsonRpcBatchRequest]]
+
 
 class BaseJsonRpcClient(abc.ABC):
     error_map: typing.Dict[int, errors.JsonRpcError] = {
@@ -32,6 +31,9 @@ async def __aexit__(self,
                         traceback: typing.Optional[types.TracebackType]) -> None:
         await self.disconnect()
 
+    def __getattr__(self, method_name: str) -> typing.Callable:
+        return partial(self.call, method_name)
+
     @abc.abstractmethod
     async def connect(self) -> None:
         pass
@@ -40,46 +42,83 @@ async def connect(self) -> None:
     async def disconnect(self) -> None:
         pass
 
-    async def call(self, method: str, *args, **kwargs) -> typing.Any:
-        request = JsonRpcRequest(msg_id=str(uuid.uuid4()), method=method, args=args, kwargs=kwargs)
+    async def call(self, method_name: str, *args, **kwargs) -> typing.Any:
+        request = protocol.JsonRpcRequest(id=utils.get_random_id(), method_name=method_name, args=args, kwargs=kwargs)
         response = await self.direct_call(request)
 
-        if response.error:
+        if response.error not in constants.EMPTY_VALUES:
             raise response.error
 
         return response.result
 
-    async def notify(self, method: str, *args, **kwargs) -> None:
-        request = JsonRpcRequest(method=method, args=args, kwargs=kwargs)
-        await self.send_json(request.to_dict(), without_response=True)
-
-    async def batch(self, methods: typing.Iterable[typing.Union[str, list, tuple]]) -> typing.Any:
-        requests = [self._parse_batch_method(method) for method in methods]
-        responses = await self.direct_batch(requests)
-        return self._collect_batch_result(requests, responses)
-
-    async def batch_notify(self, methods: typing.Iterable[typing.Union[str, list, tuple]]) -> None:
-        requests = [self._parse_batch_method(method, is_notification=True) for method in methods]
-        data = [request.to_dict() for request in requests]
-        await self.send_json(data, without_response=True)
+    async def notify(self, method_name: str, *args, **kwargs) -> None:
+        request = protocol.JsonRpcRequest(method_name=method_name, args=args, kwargs=kwargs)
+        await self.direct_call(request)
+
+    async def batch(self,
+                    method_descriptions: MethodDescriptionList, *,
+                    save_order: bool = True) -> typing.Any:
+        if isinstance(method_descriptions, protocol.JsonRpcBatchRequest):
+            batch_request = method_descriptions
+        else:
+            requests = [
+                self._parse_method_description(method_description)
+                for method_description in method_descriptions
+            ]
+            batch_request = protocol.JsonRpcBatchRequest(requests=requests)
+
+        batch_response = await self.direct_batch(batch_request)
+
+        if save_order:
+            return self._collect_batch_result(batch_request, batch_response)
+        else:
+            return [
+                response.result if response.error in constants.EMPTY_VALUES else response.error
+                for response in batch_response.responses
+            ]
+
+    async def batch_notify(self, method_descriptions: MethodDescriptionList) -> None:
+        if isinstance(method_descriptions, protocol.JsonRpcBatchRequest):
+            batch_request = method_descriptions
+        else:
+            requests = [
+                self._parse_method_description(method_description, is_notification=True)
+                for method_description in method_descriptions
+            ]
+            batch_request = protocol.JsonRpcBatchRequest(requests=requests)
+
+        await self.direct_batch(batch_request)
+
+    async def direct_call(self, request: protocol.JsonRpcRequest) -> typing.Optional[protocol.JsonRpcResponse]:
+        json_response, context = await self.send_json(request.to_dict(), without_response=request.is_notification)
+
+        if request.is_notification:
+            return None
 
-    async def direct_call(self, request: JsonRpcRequest) -> JsonRpcResponse:
-        json_response, context = await self.send_json(request.to_dict())
-        response = JsonRpcResponse.from_dict(
+        response = protocol.JsonRpcResponse.from_dict(
             json_response,
             error_map=self.error_map,
             context=context,
         )
+
         return response
 
-    async def direct_batch(self, requests: typing.List[JsonRpcRequest]) -> typing.List[JsonRpcResponse]:
-        data = [request.to_dict() for request in requests]
-        json_response, context = await self.send_json(data)
+    async def direct_batch(self,
+                           batch_request: protocol.JsonRpcBatchRequest,
+                           ) -> typing.Optional[protocol.JsonRpcBatchResponse]:
+        if not batch_request.requests:
+            raise errors.InvalidRequest('You can not send an empty batch request.')
 
-        return [
-            JsonRpcResponse.from_dict(item, error_map=self.error_map, context=context)
-            for item in json_response
-        ]
+        is_notification = batch_request.is_notification
+        json_response, context = await self.send_json(batch_request.to_list(), without_response=is_notification)
+
+        if is_notification:
+            return None
+
+        if not json_response:
+            raise errors.ParseError('Server returned an empty batch response.')
+
+        return protocol.JsonRpcBatchResponse.from_list(json_response)
 
     @abc.abstractmethod
     async def send_json(self,
@@ -87,64 +126,80 @@ async def send_json(self,
                         without_response: bool = False) -> typing.Tuple[typing.Any, typing.Optional[dict]]:
         pass
 
-    def __getattr__(self, method) -> typing.Callable:
-        return partial(self.call, method)
-
     @staticmethod
-    def _collect_batch_result(requests: typing.List[JsonRpcRequest], responses: typing.List[JsonRpcResponse]) -> list:
-        unlinked_results = UnlinkedResults(data=[])
+    def _collect_batch_result(batch_request: protocol.JsonRpcBatchRequest,
+                              batch_response: protocol.JsonRpcBatchResponse) -> list:
+        unlinked_results = protocol.UnlinkedResults()
         responses_map = {}
 
-        for response in responses:
-            value = response.error or response.result
+        for response in batch_response.responses:
+            if response.error in constants.EMPTY_VALUES:
+                value = response.result
+            else:
+                value = response.error
 
-            if response.msg_id in constants.EMPTY_VALUES:
-                unlinked_results.data.append(value)
+            if response.id in constants.EMPTY_VALUES:
+                unlinked_results.add(value)
                 continue
 
-            responses_map[response.msg_id] = value
+            if response.id in responses_map:
+                if isinstance(responses_map[response.id], protocol.DuplicatedResults):
+                    responses_map[response.id].add(value)
+                else:
+                    responses_map[response.id] = protocol.DuplicatedResults(data=[
+                        responses_map[response.id],
+                        value,
+                    ])
+            else:
+                responses_map[response.id] = value
+
+        if not unlinked_results:
+            unlinked_results = None
 
-        unlinked_results = unlinked_results.compile()
         result = []
 
-        for request in requests:
-            if request.msg_id in constants.EMPTY_VALUES:
+        for request in batch_request.requests:
+            if request.is_notification:
                 result.append(unlinked_results)
                 continue
 
-            result.append(responses_map.get(request.msg_id, unlinked_results))
+            result.append(responses_map.get(request.id, unlinked_results))
 
         return result
 
     @staticmethod
-    def _parse_batch_method(batch_method: typing.Union[str, list, tuple], *,
-                            is_notification: bool = False) -> JsonRpcRequest:
-        msg_id = constants.NOTHING if is_notification else str(uuid.uuid4())
-
-        if isinstance(batch_method, str):
-            return JsonRpcRequest(msg_id=msg_id, method=batch_method)
-
-        if len(batch_method) == 1:
-            return JsonRpcRequest(msg_id=msg_id, method=batch_method[0])
-
-        if len(batch_method) == 2:
-            return JsonRpcRequest(msg_id=msg_id, method=batch_method[0], params=batch_method[1])
-
-        if len(batch_method) == 3:
-            return JsonRpcRequest(msg_id=msg_id, method=batch_method[0], args=batch_method[1], kwargs=batch_method[2])
+    def _parse_method_description(method_description: MethodDescription, *,
+                                  is_notification: bool = False) -> protocol.JsonRpcRequest:
+        if isinstance(method_description, protocol.JsonRpcRequest):
+            return method_description
+
+        request_id = constants.NOTHING if is_notification else utils.get_random_id()
+
+        if isinstance(method_description, str):
+            return protocol.JsonRpcRequest(
+                id=request_id,
+                method_name=method_description,
+            )
+
+        if len(method_description) == 1:
+            return protocol.JsonRpcRequest(
+                id=request_id,
+                method_name=method_description[0],
+            )
+
+        if len(method_description) == 2:
+            return protocol.JsonRpcRequest(
+                id=request_id,
+                method_name=method_description[0],
+                params=method_description[1],
+            )
+
+        if len(method_description) == 3:
+            return protocol.JsonRpcRequest(
+                id=request_id,
+                method_name=method_description[0],
+                args=method_description[1],
+                kwargs=method_description[2],
+            )
 
         raise errors.InvalidParams('Use string or list (length less than or equal to 3).')
-
-
-@dataclass
-class UnlinkedResults:
-    data: list
-
-    def compile(self) -> typing.Any:
-        if not self.data:
-            return None
-
-        if len(self.data) == 1:
-            return self.data[0]
-
-        return self
diff --git a/aiohttp_rpc/client/http.py b/aiohttp_rpc/client/http.py
index cd98c4f..d15a0ce 100644
--- a/aiohttp_rpc/client/http.py
+++ b/aiohttp_rpc/client/http.py
@@ -15,7 +15,7 @@ class JsonRpcClient(BaseJsonRpcClient):
     url: str
     session: typing.Optional[aiohttp.ClientSession]
     request_kwargs: dict
-    _is_outer_session: bool
+    _session_is_outer: bool
 
     def __init__(self,
                  url: str, *,
@@ -24,14 +24,14 @@ def __init__(self,
         self.url = url
         self.session = session
         self.request_kwargs = request_kwargs
-        self._is_outer_session = session is not None
+        self._session_is_outer = session is not None
 
     async def connect(self) -> None:
         if not self.session:
             self.session = aiohttp.ClientSession(json_serialize=self.json_serialize)
 
     async def disconnect(self) -> None:
-        if not self._is_outer_session:
+        if self.session and not self._session_is_outer:
             await self.session.close()
 
     async def send_json(self,
@@ -39,6 +39,11 @@ async def send_json(self,
                         without_response: bool = False) -> typing.Tuple[typing.Any, typing.Optional[dict]]:
         http_response = await self.session.post(self.url, json=data, **self.request_kwargs)
 
+        try:
+            http_response.raise_for_status()
+        except aiohttp.ClientResponseError as e:
+            raise errors.ServerError(f'Server responded with code {http_response.status}.') from e
+
         try:
             json_response = await http_response.json()
         except aiohttp.ContentTypeError as e:
diff --git a/aiohttp_rpc/client/websocket.py b/aiohttp_rpc/client/websocket.py
index 74149c1..a6ff899 100644
--- a/aiohttp_rpc/client/websocket.py
+++ b/aiohttp_rpc/client/websocket.py
@@ -4,6 +4,7 @@
 import typing
 
 import aiohttp
+from aiohttp import http_websocket, web_ws
 
 from .base import BaseJsonRpcClient
 from .. import errors, utils
@@ -15,44 +16,63 @@
 
 logger = logging.getLogger(__name__)
 
+WSConnectType = typing.Union[aiohttp.ClientWebSocketResponse, web_ws.WebSocketResponse]
+
 
 class WsJsonRpcClient(BaseJsonRpcClient):
-    ws_connect = None
-    notify_about_result: typing.Optional[typing.Callable] = None
+    url: typing.Optional[str]
+    ws_connect: typing.Optional[WSConnectType]
     timeout: typing.Optional[int]
     ws_connect_kwargs: dict
     _pending: typing.Dict[typing.Any, asyncio.Future]
     _message_worker: typing.Optional[asyncio.Future] = None
+    _session_is_outer: bool
+    _ws_connect_is_outer: bool
+    _json_request_handler: typing.Optional[typing.Callable] = None
+    _unprocessed_json_response_handler: typing.Optional[typing.Callable] = None
 
     def __init__(self,
-                 url: str, *,
+                 url: typing.Optional[str] = None, *,
                  session: typing.Optional[aiohttp.ClientSession] = None,
+                 ws_connect: typing.Optional[WSConnectType] = None,
                  timeout: typing.Optional[int] = 5,
+                 json_request_handler: typing.Optional[typing.Callable] = None,
+                 unprocessed_json_response_handler: typing.Optional[typing.Callable] = None,
                  **ws_connect_kwargs) -> None:
+        assert (session is not None) or (url is not None and session is None) or (ws_connect is not None)
+
         self.url = url
-        self.session = session
-        self._is_outer_session = session is not None
-        self._pending = {}
         self.timeout = timeout
+
+        self.session = session
+        self._session_is_outer = session is not None
+
+        self.ws_connect = ws_connect
         self.ws_connect_kwargs = ws_connect_kwargs
+        self._ws_connect_is_outer = ws_connect is not None
+
+        self._pending = {}
+        self._json_request_handler = json_request_handler
+        self._unprocessed_json_response_handler = unprocessed_json_response_handler
 
     async def connect(self) -> None:
-        if not self.session:
+        if not self.session and not self.ws_connect:
             self.session = aiohttp.ClientSession(json_serialize=self.json_serialize)
 
-        try:
-            self.ws_connect = await self.session.ws_connect(self.url, **self.ws_connect_kwargs)
-        except Exception:
-            await self.disconnect()
-            raise
+        if not self.ws_connect:
+            try:
+                self.ws_connect = await self.session.ws_connect(self.url, **self.ws_connect_kwargs)
+            except Exception:
+                await self.disconnect()
+                raise
 
         self._message_worker = asyncio.ensure_future(self._handle_ws_messages())
 
     async def disconnect(self) -> None:
-        if self.ws_connect:
+        if self.ws_connect and not self._ws_connect_is_outer:
             await self.ws_connect.close()
 
-        if not self._is_outer_session:
+        if self.session and not self._session_is_outer:
             await self.session.close()
 
         if self._message_worker:
@@ -65,15 +85,15 @@ async def send_json(self,
             await self.ws_connect.send_str(self.json_serialize(data))
             return None, None
 
-        msg_ids = self._get_msg_ids_from_json(data)
+        request_ids = self._get_ids_from_json(data)
         future = asyncio.Future()
 
-        for msg_id in msg_ids:
-            self._pending[msg_id] = future
+        for request_id in request_ids:
+            self._pending[request_id] = future
 
         await self.ws_connect.send_str(self.json_serialize(data))
 
-        if not msg_ids:
+        if not request_ids:
             return None, None
 
         if self.timeout is not None:
@@ -84,10 +104,10 @@ async def send_json(self,
         return result, None
 
     def clear_pending(self) -> None:
-        self._pending = {}
+        self._pending.clear()
 
     @staticmethod
-    def _get_msg_ids_from_json(data: typing.Any) -> typing.Optional[list]:
+    def _get_ids_from_json(data: typing.Any) -> typing.Optional[list]:
         if not data:
             return []
 
@@ -98,60 +118,100 @@ def _get_msg_ids_from_json(data: typing.Any) -> typing.Optional[list]:
             return [
                 item['id']
                 for item in data
-                if item.get('id') is not None
+                if isinstance(item, dict) and item.get('id') is not None
             ]
 
         return []
 
     async def _handle_ws_messages(self) -> typing.NoReturn:
-        while not self.ws_connect.closed:
+        async for ws_msg in self.ws_connect:
+            if ws_msg.type != http_websocket.WSMsgType.TEXT:
+                continue
+
             try:
-                ws_msg = await self.ws_connect.receive()
-                self._handle_ws_message(ws_msg)
+                asyncio.ensure_future(self._handle_single_ws_message(ws_msg))
             except asyncio.CancelledError as e:
                 error = errors.ServerError(utils.get_exc_message(e)).with_traceback()
                 self._notify_all_about_error(error)
                 raise
-            except Exception as e:
-                logger.exception(e)
+            except Exception:
+                logger.warning('Can not process WS message.', exc_info=True)
 
-    def _handle_ws_message(self, ws_msg: aiohttp.WSMessage) -> None:
+    async def _handle_single_ws_message(self, ws_msg: aiohttp.WSMessage) -> None:
         if ws_msg.type != aiohttp.WSMsgType.text:
             return
 
-        json_response = json.loads(ws_msg.data)
+        try:
+            json_response = json.loads(ws_msg.data)
+        except Exception:
+            logger.warning('Can not parse json.', exc_info=True)
+            return
 
-        if isinstance(json_response, dict) and 'id' in json_response:
-            self._notify_about_result(json_response['id'], json_response)
+        if not json_response:
+            return
+
+        if isinstance(json_response, dict):
+            await self._handle_single_json_response(json_response, ws_msg=ws_msg)
             return
 
         if isinstance(json_response, list):
-            self._notify_about_results(
-                [
-                    item['id']
-                    for item in json_response
-                    if isinstance(item, dict) and 'id' in item
-                ],
-                json_response,
+            await self._handle_json_responses(json_response, ws_msg=ws_msg)
+            return
+
+        logger.warning('Couldn\'t process the response.', extra={
+            'json_response': json_response,
+        })
+
+    async def _handle_single_json_response(self, json_response: dict, *, ws_msg: aiohttp.WSMessage) -> None:
+        if 'method' in json_response:
+            if self._json_request_handler:
+                await self._json_request_handler(
+                    ws_connect=self.ws_connect,
+                    ws_msg=ws_msg,
+                    json_request=json_response,
+                )
+        elif 'id' in json_response and json_response['id'] in self._pending:
+            self._notify_about_result(json_response['id'], json_response)
+        elif self._unprocessed_json_response_handler:
+            self._unprocessed_json_response_handler(
+                ws_connect=self.ws_connect,
+                ws_msg=ws_msg,
+                json_response=json_response,
             )
 
+    async def _handle_json_responses(self, json_responses: list, *, ws_msg: aiohttp.WSMessage) -> None:
+        if isinstance(json_responses[0], dict) and 'method' in json_responses[0]:
+            if self._json_request_handler:
+                await self._json_request_handler(ws_connect=self.ws_connect, ws_msg=ws_msg)
+        else:
+            response_ids = self._get_ids_from_json(json_responses)
+
+            if response_ids:
+                self._notify_about_results(response_ids, json_responses)
+            else:
+                self._unprocessed_json_response_handler(
+                    ws_connect=self.ws_connect,
+                    ws_msg=ws_msg,
+                    json_response=json_responses,
+                )
+
     def _notify_all_about_error(self, error: errors.JsonRpcError) -> None:
         for future in self._pending.values():
             future.set_exception(error)
 
-        self._pending = {}
+        self.clear_pending()
 
-    def _notify_about_result(self, msg_id: typing.Any, json_response: dict) -> None:
-        future = self._pending.pop(msg_id, None)
+    def _notify_about_result(self, response_id: typing.Any, json_response: dict) -> None:
+        future = self._pending.pop(response_id, None)
 
         if future:
             future.set_result(json_response)
 
-    def _notify_about_results(self, msg_ids: list, json_response: list) -> None:
+    def _notify_about_results(self, response_ids: list, json_response: list) -> None:
         is_processed = False
 
-        for msg_id in msg_ids:
-            future = self._pending.pop(msg_id, None)
+        for response_id in response_ids:
+            future = self._pending.pop(response_id, None)
 
             if future and not is_processed:
                 future.set_result(json_response)
diff --git a/aiohttp_rpc/errors.py b/aiohttp_rpc/errors.py
index 53d7f94..5a35a4d 100644
--- a/aiohttp_rpc/errors.py
+++ b/aiohttp_rpc/errors.py
@@ -30,6 +30,20 @@ def __init__(self,
         self.code = code or self.code
         assert self.code, 'Error without code is not allowed.'
 
+    def __repr__(self) -> str:
+        return f'JsonRpcError({self.code}): {self.message}'
+
+    def __str__(self) -> str:
+        return self.__repr__()
+
+    def __eq__(self, other: typing.Any) -> bool:
+        return (
+                isinstance(other, JsonRpcError)
+                and self.code == other.code
+                and self.message == other.message
+                and self.data == other.data
+        )
+
     def with_traceback(self, exc_info=None, traceback_exception=None) -> 'JsonRpcError':
         if not traceback_exception:
             traceback_exception = traceback.TracebackException(*sys.exc_info())
@@ -42,12 +56,6 @@ def with_traceback(self, exc_info=None, traceback_exception=None) -> 'JsonRpcErr
 
         return self
 
-    def __repr__(self) -> str:
-        return f'JsonRpcError({self.code}): {self.message}'
-
-    def __str__(self) -> str:
-        return self.__repr__()
-
 
 class ServerError(JsonRpcError):
     code = -32000
diff --git a/aiohttp_rpc/middlewares.py b/aiohttp_rpc/middlewares.py
index dd18e2c..e9c425d 100644
--- a/aiohttp_rpc/middlewares.py
+++ b/aiohttp_rpc/middlewares.py
@@ -1,13 +1,14 @@
 import logging
 import typing
 
-from . import errors, protocol
+from . import errors, protocol, client
 
 
 __all__ = (
     'exception_middleware',
     'extra_args_middleware',
     'logging_middleware',
+    'ws_client_for_server_response',
     'DEFAULT_MIDDLEWARES',
 )
 
@@ -23,16 +24,16 @@ async def exception_middleware(request: protocol.JsonRpcRequest, handler: typing
     try:
         response = await handler(request)
     except errors.JsonRpcError as e:
-        logging.warning('Unprocessed errors.JsonRpcError', exc_info=True)
+        logger.warning('Unprocessed errors.JsonRpcError', exc_info=True)
         response = protocol.JsonRpcResponse(
-            msg_id=request.msg_id,
+            id=request.id,
             jsonrpc=request.jsonrpc,
             error=e,
         )
     except Exception as e:
         logger.exception(e)
         response = protocol.JsonRpcResponse(
-            msg_id=request.msg_id,
+            id=request.id,
             jsonrpc=request.jsonrpc,
             error=errors.InternalError().with_traceback(),
         )
@@ -68,6 +69,14 @@ async def logging_middleware(request: protocol.JsonRpcRequest, handler: typing.C
     return response
 
 
+async def ws_client_for_server_response(request: protocol.JsonRpcRequest,
+                                        handler: typing.Callable) -> protocol.JsonRpcResponse:
+    ws_connect = request.context['ws_connect']
+    request.context['ws_client'] = client.WsJsonRpcClient(ws_connect=ws_connect)
+    request.extra_args['rpc_ws_client'] = request.context['ws_client']
+    return await handler(request)
+
+
 DEFAULT_MIDDLEWARES = (
     exception_middleware,
     extra_args_middleware,
diff --git a/aiohttp_rpc/protocol/request.py b/aiohttp_rpc/protocol/request.py
index d6158ba..3c54704 100644
--- a/aiohttp_rpc/protocol/request.py
+++ b/aiohttp_rpc/protocol/request.py
@@ -6,13 +6,14 @@
 
 __all__ = (
     'JsonRpcRequest',
+    'JsonRpcBatchRequest',
 )
 
 
 @dataclass
 class JsonRpcRequest:
-    method: str
-    msg_id: typing.Any = constants.NOTHING
+    method_name: str
+    id: typing.Any = constants.NOTHING
     jsonrpc: str = constants.VERSION_2_0
     extra_args: dict = field(default_factory=dict)
     context: dict = field(default_factory=dict)
@@ -20,7 +21,7 @@ class JsonRpcRequest:
     args: typing.Optional[typing.Union[list, tuple]] = None
     kwargs: typing.Optional[dict] = None
 
-    def __post_init__(self):
+    def __post_init__(self) -> None:
         utils.validate_jsonrpc(self.jsonrpc)
 
         if self.params is constants.NOTHING:
@@ -39,15 +40,15 @@ def set_args_and_kwargs(self, args: typing.Optional[list] = None, kwargs: typing
 
     @property
     def is_notification(self) -> bool:
-        return self.msg_id is constants.NOTHING
+        return self.id in constants.EMPTY_VALUES
 
     @classmethod
     def from_dict(cls, data: typing.Dict[str, typing.Any], **kwargs) -> 'JsonRpcRequest':
         cls._validate_json_request(data)
 
         return cls(
-            msg_id=data.get('id', constants.NOTHING),
-            method=data['method'],
+            id=data.get('id', constants.NOTHING),
+            method_name=data['method'],
             params=data.get('params', constants.NOTHING),
             jsonrpc=data['jsonrpc'],
             **kwargs,
@@ -55,12 +56,12 @@ def from_dict(cls, data: typing.Dict[str, typing.Any], **kwargs) -> 'JsonRpcRequ
 
     def to_dict(self) -> dict:
         data = {
-            'method': self.method,
+            'method': self.method_name,
             'jsonrpc': self.jsonrpc,
         }
 
         if not self.is_notification:
-            data['id'] = self.msg_id
+            data['id'] = self.id
 
         if self.params is not constants.NOTHING:
             data['params'] = self.params
@@ -76,3 +77,25 @@ def _validate_json_request(data: typing.Any) -> None:
             raise errors.InvalidRequest('A request must contain "method" and "jsonrpc".')
 
         utils.validate_jsonrpc(data['jsonrpc'])
+
+
+@dataclass
+class JsonRpcBatchRequest:
+    requests: typing.List[JsonRpcRequest] = field(default_factory=list)
+
+    @property
+    def is_notification(self) -> bool:
+        return all(request.is_notification for request in self.requests)
+
+    def to_list(self) -> typing.List[dict]:
+        return [request.to_dict() for request in self.requests]
+
+    @classmethod
+    def from_list(cls, data: list, **kwargs) -> 'JsonRpcBatchRequest':
+        requests = [
+            JsonRpcRequest.from_dict(item,  **kwargs)
+            for item in data
+        ]
+
+        return cls(requests=requests)
+
diff --git a/aiohttp_rpc/protocol/response.py b/aiohttp_rpc/protocol/response.py
index 81013dc..a3244d9 100644
--- a/aiohttp_rpc/protocol/response.py
+++ b/aiohttp_rpc/protocol/response.py
@@ -6,27 +6,30 @@
 
 __all__ = (
     'JsonRpcResponse',
+    'JsonRpcBatchResponse',
+    'UnlinkedResults',
+    'DuplicatedResults',
 )
 
 
 @dataclass
 class JsonRpcResponse:
     jsonrpc: str = constants.VERSION_2_0
-    msg_id: typing.Any = constants.NOTHING
+    id: typing.Any = constants.NOTHING
     result: typing.Any = constants.NOTHING
     error: typing.Optional[errors.JsonRpcError] = None
     context: dict = field(default_factory=dict)
 
     @property
     def is_notification(self) -> bool:
-        return self.msg_id is constants.NOTHING
+        return self.id in constants.EMPTY_VALUES
 
     @classmethod
     def from_dict(cls, data: dict, *, error_map: typing.Optional[dict] = None, **kwargs) -> 'JsonRpcResponse':
         cls._validate_json_response(data)
 
         response = cls(
-            msg_id=data.get('id', constants.NOTHING),
+            id=data.get('id', constants.NOTHING),
             jsonrpc=data.get('jsonrpc', constants.VERSION_2_0),
             result=data.get('result'),
             **kwargs,
@@ -38,12 +41,14 @@ def from_dict(cls, data: dict, *, error_map: typing.Optional[dict] = None, **kwa
         return response
 
     def to_dict(self) -> typing.Optional[dict]:
-        if self.msg_id is constants.NOTHING:
-            return None
+        data = {'jsonrpc': self.jsonrpc}
 
-        data = {'id': self.msg_id, 'jsonrpc': self.jsonrpc}
+        if self.id in constants.EMPTY_VALUES:
+            data['id'] = None
+        else:
+            data['id'] = self.id
 
-        if self.error is constants.NOTHING:
+        if self.error in constants.EMPTY_VALUES:
             data['result'] = self.result
         else:
             data['error'] = {'code': self.error.code, 'message': self.error.message}
@@ -81,3 +86,48 @@ def _add_error(response: 'JsonRpcResponse', error: typing.Any, *, error_map: typ
             data=error.get('data'),
             code=error['code'],
         )
+
+
+@dataclass
+class JsonRpcBatchResponse:
+    responses: typing.List[JsonRpcResponse] = field(default_factory=list)
+
+    def to_list(self) -> typing.List[dict]:
+        return [response.to_dict() for response in self.responses]
+
+    @classmethod
+    def from_list(cls, data: list, *, error_map: typing.Optional[dict] = None, **kwargs) -> 'JsonRpcBatchResponse':
+        responses = [
+            JsonRpcResponse.from_dict(item, error_map=error_map, **kwargs)
+            for item in data
+        ]
+
+        return cls(responses=responses)
+
+
+@dataclass
+class UnlinkedResults:
+    data: list = field(default_factory=list)
+
+    def __bool__(self) -> bool:
+        return len(self.data) > 0
+
+    def get(self) -> list:
+        return self.data
+
+    def add(self, value: typing.Any) -> None:
+        self.data.append(value)
+
+
+@dataclass
+class DuplicatedResults:
+    data: list = field(default_factory=list)
+
+    def __bool__(self) -> bool:
+        return len(self.data) > 0
+
+    def get(self) -> list:
+        return self.data
+
+    def add(self, value: typing.Any) -> None:
+        self.data.append(value)
diff --git a/aiohttp_rpc/server/base.py b/aiohttp_rpc/server/base.py
index 76378b3..0435dc0 100644
--- a/aiohttp_rpc/server/base.py
+++ b/aiohttp_rpc/server/base.py
@@ -3,8 +3,6 @@
 import typing
 from functools import partial
 
-from aiohttp import web
-
 from .. import constants, errors, protocol, utils
 
 
@@ -36,7 +34,7 @@ def __init__(self, *,
 
         self.json_serialize = json_serialize
 
-    def load_middlewares(self):
+    def load_middlewares(self) -> None:
         self._middleware_chain = self._process_single_request
 
         for middleware in reversed(self.middlewares):
@@ -67,7 +65,7 @@ def add_methods(self,
         ]
 
     async def call(self,
-                   method: str, *,
+                   method_name: str, *,
                    args: typing.Optional[list] = None,
                    kwargs: typing.Optional[dict] = None,
                    extra_args: typing.Optional[dict] = None) -> typing.Any:
@@ -77,10 +75,10 @@ async def call(self,
         if kwargs is None:
             kwargs = {}
 
-        if method not in self.methods:
+        if method_name not in self.methods:
             raise errors.MethodNotFound
 
-        return await self.methods[method](args=args, kwargs=kwargs, extra_args=extra_args)
+        return await self.methods[method_name](args=args, kwargs=kwargs, extra_args=extra_args)
 
     def get_methods(self) -> dict:
         return {
@@ -106,20 +104,25 @@ def get_method(self, name: str) -> typing.Optional[dict]:
 
     async def _process_input_data(self,
                                   data: typing.Union[dict, list], *,
-                                  http_request: typing.Optional[web.Request] = None) -> typing.Any:
+                                  context: typing.Dict[str, typing.Any],
+                                  ) -> typing.Optional[typing.Union[dict, typing.List[dict]]]:
         if isinstance(data, list):
+            if not data:
+                return protocol.JsonRpcResponse(error=errors.InvalidRequest()).to_dict()
+
             coros = (
-                self._process_single_json_request(raw_rcp_request, http_request=http_request)
+                self._process_single_json_request(raw_rcp_request, context=context)
                 for raw_rcp_request in data
             )
             json_responses = await asyncio.gather(*coros, return_exceptions=True)
             self._prepare_exceptions(json_responses)
-            return json_responses
+            result = [json_response for json_response in json_responses if json_response is not None]
+            return result if result else None
 
         if isinstance(data, dict):
-            return await self._process_single_json_request(data, http_request=http_request)
+            return await self._process_single_json_request(data, context=context)
 
-        response = protocol.JsonRpcResponse(error=errors.ParseError('Data must be a dict or an list.'))
+        response = protocol.JsonRpcResponse(error=errors.InvalidRequest('Data must be a dict or an list.'))
         return response.to_dict()
 
     @staticmethod
@@ -132,19 +135,22 @@ def _prepare_exceptions(values: list) -> None:
 
     async def _process_single_json_request(self,
                                            json_request: dict, *,
-                                           http_request: typing.Optional[web.Request] = None) -> dict:
-        if not isinstance(json_request, dict):
-            raise errors.ParseError('Data must be a dict or an list.')
-
-        msg_id = json_request.get('id')
-
+                                           context: typing.Dict[str, typing.Any]) -> typing.Optional[dict]:
         try:
-            request = protocol.JsonRpcRequest.from_dict(json_request, context={'http_request': http_request})
+            if not isinstance(json_request, dict):
+                raise errors.InvalidRequest('Data must be a dict.')
+
+            request = protocol.JsonRpcRequest.from_dict(json_request, context=context)
         except errors.JsonRpcError as e:
-            response = protocol.JsonRpcResponse(msg_id=msg_id, error=e)
+            request_id = json_request.get('id', constants.NOTHING) if isinstance(json_request, dict) else constants.NOTHING
+            response = protocol.JsonRpcResponse(id=request_id, error=e)
             return response.to_dict()
 
         response = await self._middleware_chain(request)
+
+        if response.is_notification:
+            return None
+
         return response.to_dict()
 
     async def _process_single_request(self, request: protocol.JsonRpcRequest) -> protocol.JsonRpcResponse:
@@ -152,7 +158,7 @@ async def _process_single_request(self, request: protocol.JsonRpcRequest) -> pro
 
         try:
             result = await self.call(
-                request.method,
+                request.method_name,
                 args=request.args,
                 kwargs=request.kwargs,
                 extra_args=request.extra_args,
@@ -161,7 +167,7 @@ async def _process_single_request(self, request: protocol.JsonRpcRequest) -> pro
             error = e
 
         response = protocol.JsonRpcResponse(
-            msg_id=request.msg_id,
+            id=request.id,
             jsonrpc=request.jsonrpc,
             result=result,
             error=error,
diff --git a/aiohttp_rpc/server/http.py b/aiohttp_rpc/server/http.py
index 88436e7..1309388 100644
--- a/aiohttp_rpc/server/http.py
+++ b/aiohttp_rpc/server/http.py
@@ -23,7 +23,7 @@ async def handle_http_request(self, http_request: web.Request) -> web.Response:
             response = protocol.JsonRpcResponse(error=errors.ParseError(utils.get_exc_message(e)))
             return web.json_response(response.to_dict(), dumps=self.json_serialize)
 
-        output_data = await self._process_input_data(input_data, http_request=http_request)
+        output_data = await self._process_input_data(input_data, context={'http_request': http_request})
 
         return web.json_response(output_data, dumps=self.json_serialize)
 
diff --git a/aiohttp_rpc/server/websocket.py b/aiohttp_rpc/server/websocket.py
index 7116ab3..6203cbd 100644
--- a/aiohttp_rpc/server/websocket.py
+++ b/aiohttp_rpc/server/websocket.py
@@ -1,10 +1,12 @@
 import asyncio
 import json
+import typing
 import weakref
 
 from aiohttp import http_websocket, web, web_ws
 
 from .base import BaseJsonRpcServer
+from .. import errors, protocol, utils
 
 
 __all__ = (
@@ -14,36 +16,22 @@
 
 class WsJsonRpcServer(BaseJsonRpcServer):
     rcp_websockets: weakref.WeakSet
+    _json_response_handler: typing.Optional[typing.Callable] = None
 
-    def __init__(self, *args, **kwargs) -> None:
+    def __init__(self,
+                 *args,
+                 json_response_handler: typing.Optional[typing.Callable] = None,
+                 **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
         self.rcp_websockets = weakref.WeakSet()
+        self._json_response_handler = json_response_handler
 
     async def handle_http_request(self, http_request: web.Request) -> web.StreamResponse:
         if http_request.method != 'GET' or http_request.headers.get('upgrade', '').lower() != 'websocket':
             raise web.HTTPMethodNotAllowed(method=http_request.method, allowed_methods=('GET',))
 
-        return await self.handle_websocket_request(http_request)
-
-    async def handle_websocket_request(self, http_request: web.Request) -> web_ws.WebSocketResponse:
-        ws = web_ws.WebSocketResponse()
-        await ws.prepare(http_request)
-
-        self.rcp_websockets.add(ws)
-
-        async for ws_msg in ws:
-            if ws_msg.type == http_websocket.WSMsgType.TEXT:
-                coro = self._handle_ws_msg(
-                    ws=ws,
-                    http_request=http_request,
-                    ws_msg=ws_msg,
-                )
-                asyncio.ensure_future(coro)  # asyncio.create_task(coro) in Python 3.7+
-            elif ws_msg.type == http_websocket.WSMsgType.ERROR:
-                break
-
-        return ws
+        return await self._handle_ws_request(http_request)
 
     async def on_shutdown(self, app: web.Application) -> None:
         # https://docs.aiohttp.org/en/stable/web_advanced.html#graceful-shutdown
@@ -53,12 +41,49 @@ async def on_shutdown(self, app: web.Application) -> None:
 
         self.rcp_websockets.clear()
 
-    async def _handle_ws_msg(self, *,
-                             ws: web_ws.WebSocketResponse,
-                             http_request: web.Request,
-                             ws_msg: web_ws.WSMessage) -> None:
-        input_data = json.loads(ws_msg.data)
-        output_data = await self._process_input_data(input_data, http_request=http_request)
+    async def _handle_ws_request(self, http_request: web.Request) -> web_ws.WebSocketResponse:
+        from aiohttp_rpc import WsJsonRpcClient
+
+        ws_connect = web_ws.WebSocketResponse()
+        await ws_connect.prepare(http_request)
+
+        self.rcp_websockets.add(ws_connect)
+
+        ws_rpc_client = WsJsonRpcClient(ws_connect=ws_connect)
+
+        async for ws_msg in ws_connect:
+            if ws_msg.type != http_websocket.WSMsgType.TEXT:
+                continue
+
+            coro = self._handle_ws_message(
+                ws_msg=ws_msg,
+                ws_connect=ws_connect,
+                context={
+                    'http_request': http_request,
+                    'ws_connect': ws_connect,
+                    'ws_rpc_client': ws_rpc_client,
+                },
+            )
+            asyncio.ensure_future(coro)  # TODO: asyncio.create_task(coro) in Python 3.7+
+
+        return ws_connect
+
+    async def _handle_ws_message(self,
+                                 ws_msg: web_ws.WSMessage, *,
+                                 ws_connect: web_ws.WebSocketResponse,
+                                 context: dict) -> None:
+        try:
+            input_data = json.loads(ws_msg.data)
+        except json.JSONDecodeError as e:
+            response = protocol.JsonRpcResponse(error=errors.ParseError(utils.get_exc_message(e)))
+            json_response = response.to_dict()
+        else:
+            json_response = await self._process_input_data(input_data, context=context)
+
+        if json_response is None:
+            return
+
+        if ws_connect.closed:
+            raise errors.ServerError('WS is closed.')
 
-        if not ws.closed:
-            await ws.send_str(self.json_serialize(output_data))
+        await ws_connect.send_str(self.json_serialize(json_response))
diff --git a/aiohttp_rpc/utils.py b/aiohttp_rpc/utils.py
index addea79..b105c2e 100644
--- a/aiohttp_rpc/utils.py
+++ b/aiohttp_rpc/utils.py
@@ -1,5 +1,6 @@
 import json
 import typing
+import uuid
 from functools import partial
 from traceback import format_exception_only
 
@@ -52,6 +53,10 @@ def parse_args_and_kwargs(args: typing.Any, kwargs: typing.Any) -> typing.Tuple:
     return kwargs, [], kwargs
 
 
+def get_random_id() -> str:
+    return str(uuid.uuid4())
+
+
 def get_exc_message(exp: Exception) -> str:
     return ''.join(format_exception_only(exp.__class__, exp)).strip()
 
@@ -62,3 +67,124 @@ def validate_jsonrpc(jsonrpc: typing.Any) -> None:
 
 
 json_serialize = partial(json.dumps, default=lambda x: repr(x))
+
+
+# def is_json_rpc_request(data: typing.Any) -> bool:
+#     if isinstance(data, dict) and 'method' in data:
+#         return True
+#
+#     if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict) and 'method' in data[0]:
+#         return True
+#
+#     return False
+
+
+# def is_json_rpc_response(data: typing.Any) -> bool:
+#     if isinstance(data, dict) and ('result' in data or 'error' in data):
+#         return True
+#
+#     if isinstance(data, list) and len(data) > 0 and isinstance(data[0], dict) and ('result' in data[0] or 'error' in data[0]):
+#         return True
+#
+#     return False
+
+
+# def is_json_batch(data: typing.Any) -> bool:
+#     return isinstance(data, list)
+
+
+# class WebSocketConnection:
+#     _ws_connect: WSConnectType
+#     _message_worker: typing.Optional[asyncio.Future] = None
+#     _handlers: typing.List[typing.Callable]
+#
+#     def __init__(self, ws_connect: WSConnectType) -> None:
+#         self._ws_connect = ws_connect
+#         self._handlers = []
+#
+#     def add_handler(self, handler: typing.Callable) -> None:
+#         self._handlers.append(handler)
+#
+#     async def open(self) -> None:
+#         self._message_worker = asyncio.ensure_future(self._handle_ws_messages())
+#
+#     async def close(self) -> None:
+#         await self._ws_connect.close()
+#
+#         if self._message_worker:
+#             await self._message_worker
+#
+#     @property
+#     def closed(self) -> bool:
+#         return self._ws_connect.closed
+#
+#     async def send_str(self, message: typing.Union[str, bytes]) -> None:
+#         await self._ws_connect.send_str(message)
+#
+#     async def _handle_ws_messages(self):
+#         async for ws_msg in self._ws_connect:
+#             if ws_msg.type == http_websocket.WSMsgType.BINARY:
+#                 raw_data = ws_msg.data.decode()
+#             elif ws_msg.type == http_websocket.WSMsgType.TEXT:
+#                 raw_data = ws_msg.data
+#             else:
+#                 continue
+#
+#             parsed_data, exception = self._parse_raw_data(raw_data)
+#
+#             if exception is not None:
+#                 await self._call_handlers(
+#                     parsed_data=parsed_data,
+#                     prepared_data=None,
+#                     exception=exception,
+#                 )
+#                 return
+#
+#             try:
+#                 prepared_data = self._prepare_data(parsed_data)
+#             except Exception as e:
+#                 await self._call_handlers(
+#                     parsed_data=parsed_data,
+#                     prepared_data=None,
+#                     exception=e,
+#                 )
+#                 return
+#
+#             await self._call_handlers(
+#                 parsed_data=parsed_data,
+#                 prepared_data=prepared_data,
+#                 exception=None,
+#             )
+#
+#     async def _call_handlers(self, **kwargs) -> None:
+#         for handler in self._handlers:
+#             await handler(**kwargs)
+#
+#     @staticmethod
+#     def _parse_raw_data(raw_data: str) -> typing.Tuple:
+#         try:
+#             input_data = json.loads(raw_data)
+#         except json.JSONDecodeError as e:
+#             exception = e
+#             input_data = None
+#         else:
+#             exception = None
+#
+#         return input_data, exception
+#
+#     @staticmethod
+#     def _prepare_data(parsed_data):
+#         from . import protocol, errors
+#
+#         if is_json_rpc_response(parsed_data):
+#             if is_json_batch(parsed_data):
+#                 return protocol.JsonRpcBatchResponse.from_list(parsed_data)
+#             else:
+#                 return protocol.JsonRpcResponse.from_dict(parsed_data)
+#         elif is_json_rpc_request(parsed_data):
+#             if is_json_batch(parsed_data):
+#                 return protocol.JsonRpcBatchRequest.from_list(parsed_data)
+#             else:
+#                 return protocol.JsonRpcRequest.from_dict(parsed_data)
+#         else:
+#             raise errors.ParseError
diff --git a/poetry.lock b/poetry.lock
index ce6a23e..fb51374 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -3,24 +3,21 @@ category = "main"
 description = "Async http client/server framework (asyncio)"
 name = "aiohttp"
 optional = false
-python-versions = ">=3.5.3"
-version = "3.6.2"
+python-versions = "^3.6"
+version = "3.7.2"
 
 [package.dependencies]
 async-timeout = ">=3.0,<4.0"
 attrs = ">=17.3.0"
 chardet = ">=2.0,<4.0"
-multidict = ">=4.5,<5.0"
+multidict = ">=4.5,<7.0"
+typing-extensions = ">=3.6.5"
 yarl = ">=1.0,<2.0"
 
 [package.dependencies.idna-ssl]
 python = "<3.7"
 version = ">=1.0"
 
-[package.dependencies.typing-extensions]
-python = "<3.7"
-version = ">=3.6.5"
-
 [package.extras]
 speedups = ["aiodns", "brotlipy", "cchardet"]
 
@@ -55,13 +52,13 @@ description = "Classes Without Boilerplate"
 name = "attrs"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
-version = "19.3.0"
+version = "20.2.0"
 
 [package.extras]
-azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"]
-dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"]
-docs = ["sphinx", "zope.interface"]
-tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+dev = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "sphinx-rtd-theme", "pre-commit"]
+docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
+tests = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"]
+tests_no_zope = ["coverage (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"]
 
 [[package]]
 category = "main"
@@ -78,7 +75,7 @@ marker = "sys_platform == \"win32\" or platform_system == \"Windows\""
 name = "colorama"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
-version = "0.4.3"
+version = "0.4.4"
 
 [[package]]
 category = "dev"
@@ -123,7 +120,7 @@ marker = "python_version < \"3.8\""
 name = "importlib-metadata"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-version = "1.7.0"
+version = "2.0.0"
 
 [package.dependencies]
 zipp = ">=0.5"
@@ -138,8 +135,8 @@ description = "Read resources from Python packages"
 marker = "python_version < \"3.7\""
 name = "importlib-resources"
 optional = false
-python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-version = "3.0.0"
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
+version = "3.3.0"
 
 [package.dependencies]
 [package.dependencies.zipp]
@@ -155,15 +152,7 @@ description = "iniconfig: brain-dead simple config-ini parsing"
 name = "iniconfig"
 optional = false
 python-versions = "*"
-version = "1.0.1"
-
-[[package]]
-category = "dev"
-description = "More routines for operating on iterables, beyond itertools"
-name = "more-itertools"
-optional = false
-python-versions = ">=3.5"
-version = "8.4.0"
+version = "1.1.1"
 
 [[package]]
 category = "main"
@@ -171,7 +160,7 @@ description = "multidict implementation"
 name = "multidict"
 optional = false
 python-versions = ">=3.5"
-version = "4.7.6"
+version = "5.0.0"
 
 [[package]]
 category = "dev"
@@ -223,14 +212,13 @@ description = "pytest: simple powerful testing with Python"
 name = "pytest"
 optional = false
 python-versions = ">=3.5"
-version = "6.0.1"
+version = "6.1.2"
 
 [package.dependencies]
 atomicwrites = ">=1.0"
 attrs = ">=17.4.0"
 colorama = "*"
 iniconfig = "*"
-more-itertools = ">=4.0.0"
 packaging = "*"
 pluggy = ">=0.12,<1.0"
 py = ">=1.8.2"
@@ -276,10 +264,10 @@ description = "Thin-wrapper around the mock package for easier use with pytest"
 name = "pytest-mock"
 optional = false
 python-versions = ">=3.5"
-version = "3.2.0"
+version = "3.3.1"
 
 [package.dependencies]
-pytest = ">=2.7"
+pytest = ">=5.0"
 
 [package.extras]
 dev = ["pre-commit", "tox", "pytest-asyncio"]
@@ -297,8 +285,8 @@ category = "dev"
 description = "Python Library for Tom's Obvious, Minimal Language"
 name = "toml"
 optional = false
-python-versions = "*"
-version = "0.10.1"
+python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
+version = "0.10.2"
 
 [[package]]
 category = "dev"
@@ -306,7 +294,7 @@ description = "tox is a generic virtualenv management and test command line tool
 name = "tox"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
-version = "3.19.0"
+version = "3.20.1"
 
 [package.dependencies]
 colorama = ">=0.4.1"
@@ -320,7 +308,7 @@ virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,
 
 [package.dependencies.importlib-metadata]
 python = "<3.8"
-version = ">=0.12,<2"
+version = ">=0.12,<3"
 
 [package.extras]
 docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"]
@@ -329,11 +317,10 @@ testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psu
 [[package]]
 category = "main"
 description = "Backported and Experimental Type Hints for Python 3.5+"
-marker = "python_version < \"3.8\""
 name = "typing-extensions"
 optional = false
 python-versions = "*"
-version = "3.7.4.2"
+version = "3.7.4.3"
 
 [[package]]
 category = "dev"
@@ -341,7 +328,7 @@ description = "Virtual Python Environment builder"
 name = "virtualenv"
 optional = false
 python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7"
-version = "20.0.30"
+version = "20.1.0"
 
 [package.dependencies]
 appdirs = ">=1.4.3,<2"
@@ -351,7 +338,7 @@ six = ">=1.9.0,<2"
 
 [package.dependencies.importlib-metadata]
 python = "<3.8"
-version = ">=0.12,<2"
+version = ">=0.12,<3"
 
 [package.dependencies.importlib-resources]
 python = "<3.7"
@@ -359,15 +346,15 @@ version = ">=1.0"
 
 [package.extras]
 docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"]
-testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
+testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"]
 
 [[package]]
 category = "main"
 description = "Yet another URL library"
 name = "yarl"
 optional = false
-python-versions = ">=3.5"
-version = "1.5.1"
+python-versions = ">=3.6"
+version = "1.6.2"
 
 [package.dependencies]
 idna = ">=2.0"
@@ -384,30 +371,51 @@ marker = "python_version < \"3.8\""
 name = "zipp"
 optional = false
 python-versions = ">=3.6"
-version = "3.1.0"
+version = "3.4.0"
 
 [package.extras]
 docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"]
-testing = ["jaraco.itertools", "func-timeout"]
+testing = ["pytest (>=3.5,<3.7.3 || >3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "jaraco.test (>=3.2.0)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"]
 
 [metadata]
-content-hash = "2ba0df80a8fb83f1a0b0135b98180c22acd0aed5fcc8ef89acceff5c85509ac5"
+content-hash = "a5dfd0b38ed00d6a3af2113fbd1cc266a00e358c1f21b25e7155dcbb96fc4608"
 python-versions = "^3.6"
 
 [metadata.files]
 aiohttp = [
-    {file = "aiohttp-3.6.2-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:1e984191d1ec186881ffaed4581092ba04f7c61582a177b187d3a2f07ed9719e"},
-    {file = "aiohttp-3.6.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:50aaad128e6ac62e7bf7bd1f0c0a24bc968a0c0590a726d5a955af193544bcec"},
-    {file = "aiohttp-3.6.2-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:65f31b622af739a802ca6fd1a3076fd0ae523f8485c52924a89561ba10c49b48"},
-    {file = "aiohttp-3.6.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ae55bac364c405caa23a4f2d6cfecc6a0daada500274ffca4a9230e7129eac59"},
-    {file = "aiohttp-3.6.2-cp36-cp36m-win32.whl", hash = "sha256:344c780466b73095a72c616fac5ea9c4665add7fc129f285fbdbca3cccf4612a"},
-    {file = "aiohttp-3.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:4c6efd824d44ae697814a2a85604d8e992b875462c6655da161ff18fd4f29f17"},
-    {file = "aiohttp-3.6.2-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:2f4d1a4fdce595c947162333353d4a44952a724fba9ca3205a3df99a33d1307a"},
-    {file = "aiohttp-3.6.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6206a135d072f88da3e71cc501c59d5abffa9d0bb43269a6dcd28d66bfafdbdd"},
-    {file = "aiohttp-3.6.2-cp37-cp37m-win32.whl", hash = "sha256:b778ce0c909a2653741cb4b1ac7015b5c130ab9c897611df43ae6a58523cb965"},
-    {file = "aiohttp-3.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:32e5f3b7e511aa850829fbe5aa32eb455e5534eaa4b1ce93231d00e2f76e5654"},
-    {file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"},
-    {file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0989ff15834a4503056d103077ec3652f9ea5699835e1ceaee46b91cf59830bf"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:8fbeeb2296bb9fe16071a674eadade7391be785ae0049610e64b60ead6abcdd7"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:48104c883099c0e614c5c38f98c1d174a2c68f52f58b2a6e5a07b59df78262ab"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:c9a415f4f2764ab6c7d63ee6b86f02a46b4df9bc11b0de7ffef206908b7bf0b4"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:7e26712871ebaf55497a60f55483dc5e74326d1fb0bfceab86ebaeaa3a266733"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:8319a55de469d5af3517dfe1f6a77f248f6668c5a552396635ef900f058882ef"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:2aea79734ac5ceeac1ec22b4af4efb4efd6a5ca3d73d77ec74ed782cf318f238"},
+    {file = "aiohttp-3.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:be9fa3fe94fc95e9bf84e84117a577c892906dd3cb0a95a7ae21e12a84777567"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04dcbf6af1868048a9b4754b1684c669252aa2419aa67266efbcaaead42ced7"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2e886611b100c8c93b753b457e645c5e4b8008ec443434d2a480e5a2bb3e6514"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cdbb65c361ff790c424365a83a496fc8dd1983689a5fb7c6852a9a3ff1710c61"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:8a8addd41320637c1445fea0bae1fd9fe4888acc2cd79217ee33e5d1c83cfe01"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:b822bf7b764283b5015e3c49b7bb93f37fc03545f4abe26383771c6b1c813436"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:ad5c3559e3cd64f746df43fa498038c91aa14f5d7615941ea5b106e435f3b892"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:835bd35e14e4f36414e47c195e6645449a0a1c3fd5eeae4b7f22cb4c5e4f503a"},
+    {file = "aiohttp-3.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:11e087c316e933f1f52f3d4a09ce13f15ad966fc43df47f44ca4e8067b6a2e0d"},
+    {file = "aiohttp-3.7.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:f8c583c31c6e790dc003d9d574e3ed2c5b337947722965096c4d684e4f183570"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b84cef790cb93cec82a468b7d2447bf16e3056d2237b652e80f57d653b61da88"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:4afd8002d9238e5e93acf1a8baa38b3ddf1f7f0ebef174374131ff0c6c2d7973"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:a1f1cc11c9856bfa7f1ca55002c39070bde2a97ce48ef631468e99e2ac8e3fe6"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:7f1aeb72f14b9254296cdefa029c00d3c4550a26e1059084f2ee10d22086c2d0"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:67f8564c534d75c1d613186939cee45a124d7d37e7aece83b17d18af665b0d7a"},
+    {file = "aiohttp-3.7.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:184ead67248274f0e20b0cd6bb5f25209b2fad56e5373101cc0137c32c825c87"},
+    {file = "aiohttp-3.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:6e0d1231a626d07b23f6fe904caa44efb249da4222d8a16ab039fb2348722292"},
+    {file = "aiohttp-3.7.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:476b1f8216e59a3c2ffb71b8d7e1da60304da19f6000d422bacc371abb0fc43d"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:89c1aa729953b5ac6ca3c82dcbd83e7cdecfa5cf9792c78c154a642e6e29303d"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:c53f1d2bd48f5f407b534732f5b3c6b800a58e70b53808637848d8a9ee127fe7"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:06efdb01ab71ec20786b592d510d1d354fbe0b2e4449ee47067b9ca65d45a006"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:027be45c4b37e21be81d07ae5242361d73eebad1562c033f80032f955f34df82"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:1c36b7ef47cfbc150314c2204cd73613d96d6d0982d41c7679b7cdcf43c0e979"},
+    {file = "aiohttp-3.7.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c588a0f824dc7158be9eec1ff465d1c868ad69a4dc518cd098cc11e4f7da09d9"},
+    {file = "aiohttp-3.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:547b196a7177511da4f475fc81d0bb88a51a8d535c7444bbf2338b6dc82cb996"},
+    {file = "aiohttp-3.7.2.tar.gz", hash = "sha256:c6da1af59841e6d43255d386a2c4bfb59c0a3b262bdb24325cc969d211be6070"},
 ]
 appdirs = [
     {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"},
@@ -422,16 +430,16 @@ atomicwrites = [
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"},
-    {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"},
+    {file = "attrs-20.2.0-py2.py3-none-any.whl", hash = "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc"},
+    {file = "attrs-20.2.0.tar.gz", hash = "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594"},
 ]
 chardet = [
     {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"},
     {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"},
 ]
 colorama = [
-    {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"},
-    {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"},
+    {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
+    {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
 ]
 distlib = [
     {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
@@ -449,39 +457,51 @@ idna-ssl = [
     {file = "idna-ssl-1.1.0.tar.gz", hash = "sha256:a933e3bb13da54383f9e8f35dc4f9cb9eb9b3b78c6b36f311254d6d0d92c6c7c"},
 ]
 importlib-metadata = [
-    {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"},
-    {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"},
+    {file = "importlib_metadata-2.0.0-py2.py3-none-any.whl", hash = "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3"},
+    {file = "importlib_metadata-2.0.0.tar.gz", hash = "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da"},
 ]
 importlib-resources = [
-    {file = "importlib_resources-3.0.0-py2.py3-none-any.whl", hash = "sha256:d028f66b66c0d5732dae86ba4276999855e162a749c92620a38c1d779ed138a7"},
-    {file = "importlib_resources-3.0.0.tar.gz", hash = "sha256:19f745a6eca188b490b1428c8d1d4a0d2368759f32370ea8fb89cad2ab1106c3"},
+    {file = "importlib_resources-3.3.0-py2.py3-none-any.whl", hash = "sha256:a3d34a8464ce1d5d7c92b0ea4e921e696d86f2aa212e684451cb1482c8d84ed5"},
+    {file = "importlib_resources-3.3.0.tar.gz", hash = "sha256:7b51f0106c8ec564b1bef3d9c588bc694ce2b92125bbb6278f4f2f5b54ec3592"},
 ]
 iniconfig = [
-    {file = "iniconfig-1.0.1-py3-none-any.whl", hash = "sha256:80cf40c597eb564e86346103f609d74efce0f6b4d4f30ec8ce9e2c26411ba437"},
-    {file = "iniconfig-1.0.1.tar.gz", hash = "sha256:e5f92f89355a67de0595932a6c6c02ab4afddc6fcdc0bfc5becd0d60884d3f69"},
-]
-more-itertools = [
-    {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"},
-    {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"},
+    {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
+    {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
 ]
 multidict = [
-    {file = "multidict-4.7.6-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000"},
-    {file = "multidict-4.7.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a"},
-    {file = "multidict-4.7.6-cp35-cp35m-win32.whl", hash = "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5"},
-    {file = "multidict-4.7.6-cp35-cp35m-win_amd64.whl", hash = "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3"},
-    {file = "multidict-4.7.6-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87"},
-    {file = "multidict-4.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2"},
-    {file = "multidict-4.7.6-cp36-cp36m-win32.whl", hash = "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7"},
-    {file = "multidict-4.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463"},
-    {file = "multidict-4.7.6-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d"},
-    {file = "multidict-4.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255"},
-    {file = "multidict-4.7.6-cp37-cp37m-win32.whl", hash = "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507"},
-    {file = "multidict-4.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c"},
-    {file = "multidict-4.7.6-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b"},
-    {file = "multidict-4.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7"},
-    {file = "multidict-4.7.6-cp38-cp38-win32.whl", hash = "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d"},
-    {file = "multidict-4.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19"},
-    {file = "multidict-4.7.6.tar.gz", hash = "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430"},
+    {file = "multidict-5.0.0-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:11dcf2366da487d5b9de1d4b2055308c7ed9bde1a52973d07a89b42252af9ebe"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:167bd8e6351b57525bbf2d524ca5a133834699a2fcb090aad0c330c6017f3f3e"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:60af726c19a899ed49bbb276e062f08b80222cb6b9feda44b59a128b5ff52966"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:32f0a904859a6274d7edcbb01752c8ae9c633fb7d1c131771ff5afd32eceee42"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:7561a804093ea4c879e06b5d3d18a64a0bc21004bade3540a4b31342b528d326"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:786ad04ad954afe9927a1b3049aa58722e182160fe2fcac7ad7f35c93595d4f6"},
+    {file = "multidict-5.0.0-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:02b2ea2bb1277a970d238c5c783023790ca94d386c657aeeb165259950951cc6"},
+    {file = "multidict-5.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:932964cf57c0e59d1f3fb63ff342440cf8aaa75bf0dbcbad902c084024975380"},
+    {file = "multidict-5.0.0-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:c692087913e12b801a759e25a626c3d311f416252dfba2ecdfd254583427949f"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:cda06c99cd6f4a36571bb38e560a6fcfb1f136521e57f612e0bc31957b1cd4bd"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:84e4943d8725659942e7401bdf31780acde9cfdaf6fe977ff1449fffafcd93a9"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:bbec545b8f82536bc50afa9abce832176ed250aa22bfff3e20b3463fb90b0b35"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:c339b7d73c0ea5c551025617bb8aa1c00a0111187b6545f48836343e6cfbe6a0"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:0ce1d956ecbf112d49915ebc2f29c03e35fe451fb5e9f491edf9a2f4395ee0af"},
+    {file = "multidict-5.0.0-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:39713fa2c687e0d0e709ad751a8a709ac051fcdc7f2048f6fd09365dd03c83eb"},
+    {file = "multidict-5.0.0-cp37-cp37m-win_amd64.whl", hash = "sha256:0ffdb4b897b15df798c0a5939a0323ccf703f2bae551dfab4eb1af7fbab38ead"},
+    {file = "multidict-5.0.0-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:4ef76ce695da72e176f6a51867afb3bf300ce16ba2597824caaef625af5906a9"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:711289412b78cf41a21457f4c806890466013d62bf4296bd3d71fad73ff8a581"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:2b0cfc33f53e5c8226f7d7c4e126fa0780f970ef1e96f7c6353da7d01eafe490"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:28b5913e5b6fef273e5d4230b61f33c8a51c3ce5f44a88582dee6b5ca5c9977b"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:a5eca9ee72b372199c2b76672145e47d3c829889eefa2037b1f3018f54e5f67d"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:20eaf1c279c543e07c164e4ac02151488829177da06607efa7ccfecd71b21e79"},
+    {file = "multidict-5.0.0-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ec8bc0ab00c76c4260a201eaa58812ea8b1b7fde0ecf5f9c9365a182bd4691ed"},
+    {file = "multidict-5.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:aad240c1429e386af38a2d6761032f0bec5177fed7c5f582c835c99fff135b5c"},
+    {file = "multidict-5.0.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:52b5b51281d760197ce3db063c166fdb626e01c8e428a325aa37198ce31c9565"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:5263359a03368985b5296b7a73363d761a269848081879ba04a6e4bfd0cf4a78"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:620c39b1270b68e194023ad471b6a54bdb517bb48515939c9829b56c783504a3"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2739d1d9237835122b27d88990849ecf41ef670e0fcb876159edd236ca9ef40f"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:62f6e66931fb87e9016e7c1cc806ab4f3e39392fd502362df3cac888078b27cb"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:5dd303b545b62f9d2b14f99fbdb84c109a20e64a57f6a192fe6aebcb6263b59d"},
+    {file = "multidict-5.0.0-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:60b12d14bc122ba2dae1e4460a891b3a96e73d815b4365675f6ec0a1725416a5"},
+    {file = "multidict-5.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:79dc3e6e7ce853fb7ed17c134e01fcb0d0c826b33201aa2a910fb27ed75c2eb9"},
+    {file = "multidict-5.0.0.tar.gz", hash = "sha256:1b324444299c3a49b601b1bf621fc21704e29066f6ac2b7d7e4034a4a18662a1"},
 ]
 packaging = [
     {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"},
@@ -500,8 +520,8 @@ pyparsing = [
     {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"},
 ]
 pytest = [
-    {file = "pytest-6.0.1-py3-none-any.whl", hash = "sha256:8b6007800c53fdacd5a5c192203f4e531eb2a1540ad9c752e052ec0f7143dbad"},
-    {file = "pytest-6.0.1.tar.gz", hash = "sha256:85228d75db9f45e06e57ef9bf4429267f81ac7c0d742cc9ed63d09886a9fe6f4"},
+    {file = "pytest-6.1.2-py3-none-any.whl", hash = "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe"},
+    {file = "pytest-6.1.2.tar.gz", hash = "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e"},
 ]
 pytest-aiohttp = [
     {file = "pytest-aiohttp-0.3.0.tar.gz", hash = "sha256:c929854339637977375838703b62fef63528598bc0a9d451639eba95f4aaa44f"},
@@ -512,50 +532,66 @@ pytest-asyncio = [
     {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"},
 ]
 pytest-mock = [
-    {file = "pytest-mock-3.2.0.tar.gz", hash = "sha256:7122d55505d5ed5a6f3df940ad174b3f606ecae5e9bc379569cdcbd4cd9d2b83"},
-    {file = "pytest_mock-3.2.0-py3-none-any.whl", hash = "sha256:5564c7cd2569b603f8451ec77928083054d8896046830ca763ed68f4112d17c7"},
+    {file = "pytest-mock-3.3.1.tar.gz", hash = "sha256:a4d6d37329e4a893e77d9ffa89e838dd2b45d5dc099984cf03c703ac8411bb82"},
+    {file = "pytest_mock-3.3.1-py3-none-any.whl", hash = "sha256:024e405ad382646318c4281948aadf6fe1135632bea9cc67366ea0c4098ef5f2"},
 ]
 six = [
     {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"},
     {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"},
 ]
 toml = [
-    {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"},
-    {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"},
+    {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"},
+    {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"},
 ]
 tox = [
-    {file = "tox-3.19.0-py2.py3-none-any.whl", hash = "sha256:3d94b6921a0b6dc90fd8128df83741f30bb41ccd6cd52d131a6a6944ca8f16e6"},
-    {file = "tox-3.19.0.tar.gz", hash = "sha256:17e61a93afe5c49281fb969ab71f7a3f22d7586d1c56f9a74219910f356fe7d3"},
+    {file = "tox-3.20.1-py2.py3-none-any.whl", hash = "sha256:42ce19ce5dc2f6d6b1fdc5666c476e1f1e2897359b47e0aa3a5b774f335d57c2"},
+    {file = "tox-3.20.1.tar.gz", hash = "sha256:4321052bfe28f9d85082341ca8e233e3ea901fdd14dab8a5d3fbd810269fbaf6"},
 ]
 typing-extensions = [
-    {file = "typing_extensions-3.7.4.2-py2-none-any.whl", hash = "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392"},
-    {file = "typing_extensions-3.7.4.2-py3-none-any.whl", hash = "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5"},
-    {file = "typing_extensions-3.7.4.2.tar.gz", hash = "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae"},
+    {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"},
+    {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"},
+    {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"},
 ]
 virtualenv = [
-    {file = "virtualenv-20.0.30-py2.py3-none-any.whl", hash = "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e"},
-    {file = "virtualenv-20.0.30.tar.gz", hash = "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5"},
+    {file = "virtualenv-20.1.0-py2.py3-none-any.whl", hash = "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2"},
+    {file = "virtualenv-20.1.0.tar.gz", hash = "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380"},
 ]
 yarl = [
-    {file = "yarl-1.5.1-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb"},
-    {file = "yarl-1.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593"},
-    {file = "yarl-1.5.1-cp35-cp35m-win32.whl", hash = "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409"},
-    {file = "yarl-1.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317"},
-    {file = "yarl-1.5.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511"},
-    {file = "yarl-1.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e"},
-    {file = "yarl-1.5.1-cp36-cp36m-win32.whl", hash = "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f"},
-    {file = "yarl-1.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2"},
-    {file = "yarl-1.5.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a"},
-    {file = "yarl-1.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8"},
-    {file = "yarl-1.5.1-cp37-cp37m-win32.whl", hash = "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8"},
-    {file = "yarl-1.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d"},
-    {file = "yarl-1.5.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02"},
-    {file = "yarl-1.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a"},
-    {file = "yarl-1.5.1-cp38-cp38-win32.whl", hash = "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6"},
-    {file = "yarl-1.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692"},
-    {file = "yarl-1.5.1.tar.gz", hash = "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6"},
+    {file = "yarl-1.6.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:d77f6c9133d2aabb290a7846aaa74ec14d7b5ab35b01591fac5a70c4a8c959a2"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:311effab3b3828ab34f0e661bb57ff422f67d5c33056298bda4c12195251f8dd"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:f835015a825980b65356e9520979a1564c56efea7da7d4b68a14d4a07a3a7336"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:59f78b5da34ddcffb663b772f7619e296518712e022e57fc5d9f921818e2ab7c"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:3526cb5905907f0e42bee7ef57ae4a5f02bc27dcac27859269e2bba0caa4c2b6"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:e77bf79ad1ccae672eab22453838382fe9029fc27c8029e84913855512a587d8"},
+    {file = "yarl-1.6.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:b3dd1052afd436ba737e61f5d3bed1f43a7f9a33fc58fbe4226eb919a7006019"},
+    {file = "yarl-1.6.2-cp36-cp36m-win_amd64.whl", hash = "sha256:6f29115b0c330da25a04f48612d75333bca04521181a666ca0b8761005a99150"},
+    {file = "yarl-1.6.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f3031c78edf10315abe232254e6a36b65afe65fded41ee54ed7976d0b2cdf0da"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4bed5cd7c8e69551eb19df15295ba90e62b9a6a1149c76eb4a9bab194402a156"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:39b1e586f34b1d2512c9b39aa3cf24c870c972d525e36edc9ee19065db4737bb"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:03b7a44384ad60be1b7be93c2a24dc74895f8d767ea0bce15b2f6fc7695a3843"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:a1fd575dd058e10ad4c35065e7c3007cc74d142f622b14e168d8a273a2fa8713"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:9b48d31f8d881713fd461abfe7acbb4dcfeb47cec3056aa83f2fbcd2244577f7"},
+    {file = "yarl-1.6.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:1c05ae3d5ea4287470046a2c2754f0a4c171b84ea72c8a691f776eb1753dfb91"},
+    {file = "yarl-1.6.2-cp37-cp37m-win_amd64.whl", hash = "sha256:f4f27ff3dd80bc7c402def211a47291ea123d59a23f59fe18fc0e81e3e71f385"},
+    {file = "yarl-1.6.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:73d4e1e1ef5e52d526c92f07d16329e1678612c6a81dd8101fdcae11a72de15c"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:db643ce2b58a4bd11a82348225c53c76ecdd82bb37cf4c085e6df1b676f4038c"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:d25d3311794e6c71b608d7c47651c8f65eea5ab15358a27f29330b3475e8f8e5"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:51c6d3cf7a1f1fbe134bb92f33b7affd94d6de24cd64b466eb12de52120fb8c6"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:cd623170c729a865037828e3f99f8ebdb22a467177a539680dfc5670b74c84e2"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:c056e86bff5a0b566e0d9fab4f67e83b12ae9cbcd250d334cbe2005bbe8c96f2"},
+    {file = "yarl-1.6.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:f4c007156732866aa4507d619fe6f8f2748caabed4f66b276ccd97c82572620c"},
+    {file = "yarl-1.6.2-cp38-cp38-win_amd64.whl", hash = "sha256:2bb2e21cf062dfbe985c3cd4618bae9f25271efcad9e7be1277861247eee9839"},
+    {file = "yarl-1.6.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b99c25ed5c355b35d1e6dae87ac7297a4844a57dc5766b173b88b6163a36eb0d"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2467baf8233f7c64048df37e11879c553943ffe7f373e689711ec2807ea13805"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:e3a0c43a26dfed955b2a06fdc4d51d2c51bc2200aff8ce8faf14e676ea8c8862"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:f2f0174cb15435957d3b751093f89aede77df59a499ab7516bbb633b77ead13a"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:d695439c201ed340745250f9eb4dfe8d32bf1e680c16477107b8f3ce4bff4fdb"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:f57744fc61e118b5d114ae8077d8eb9df4d2d2c11e2af194e21f0c11ed9dcf6c"},
+    {file = "yarl-1.6.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:d894a2442d2cd20a3b0b0dce5a353d316c57d25a2b445e03f7eac90eee27b8af"},
+    {file = "yarl-1.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:076157404db9db4bb3fa9db22db319bbb36d075eeab19ba018ce20ae0cacf037"},
+    {file = "yarl-1.6.2.tar.gz", hash = "sha256:c45b49b59a5724869899798e1bbd447ac486215269511d3b76b4c235a1b766b6"},
 ]
 zipp = [
-    {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"},
-    {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"},
+    {file = "zipp-3.4.0-py3-none-any.whl", hash = "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108"},
+    {file = "zipp-3.4.0.tar.gz", hash = "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb"},
 ]
diff --git a/pyproject.toml b/pyproject.toml
index 1fc8952..1e80e0d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,11 +6,11 @@ authors = ["expert-m <michael@sulyak.info>"]
 
 [tool.poetry.dependencies]
 python = "^3.6"
-aiohttp = "^3.6.2"
+aiohttp = "^3.7.2"
 
 [tool.poetry.dev-dependencies]
-pytest = "^6.0.1"
-tox = "^3.19.0"
+pytest = "^6.1.2"
+tox = "^3.20.1"
 pytest-asyncio = "^0.14.0"
 pytest-aiohttp = "^0.3.0"
 pytest-mock = "^3.2.0"
diff --git a/setup.py b/setup.py
index 865757a..c38708f 100755
--- a/setup.py
+++ b/setup.py
@@ -10,7 +10,7 @@
 
     setup(
         name='aiohttp-rpc',
-        version='0.6.3',
+        version='0.7.0rc1',
         author='Michael Sulyak',
         url='https://github.com/expert-m/aiohttp-rpc/',
         author_email='michael@sulyak.info',


Test patch:
diff --git a/tests/test_batch.py b/tests/test_batch.py
index ab1add6..6643919 100644
--- a/tests/test_batch.py
+++ b/tests/test_batch.py
@@ -57,7 +57,8 @@ async def test_send_json_2(data, **kwargs):
 
     async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
         mocker.patch.object(rpc, 'send_json', new_callable=lambda: test_send_json_1)
-        assert await rpc.batch(('method_1', 'method_2',)) == [[1, 2, 1], [1]]
+        unlinked_results = aiohttp_rpc.UnlinkedResults(data=[[1]])
+        assert await rpc.batch(('method_1', 'method_2',)) == [[1, 2, 1], unlinked_results]
 
         mocker.patch.object(rpc, 'send_json', new_callable=lambda: test_send_json_2)
         unlinked_results = aiohttp_rpc.UnlinkedResults(data=[[1], [1]])
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 62c411e..5d87d48 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -7,7 +7,7 @@ def method():
         return 'ok'
 
     async def test_middleware(request, handler):
-        request.method = 'method'
+        request.method_name = 'method'
         response = await handler(request)
         response.result += '!'
         return response
diff --git a/tests/test_protocol_for_http.py b/tests/test_protocol_for_http.py
new file mode 100644
index 0000000..b7d656e
--- /dev/null
+++ b/tests/test_protocol_for_http.py
@@ -0,0 +1,270 @@
+# https://www.jsonrpc.org/specification#examples
+
+import pytest
+
+import aiohttp_rpc
+from aiohttp_rpc import errors
+from tests import utils
+
+
+async def test_rpc_call_with_positional_parameters(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
+
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}
+    <-- {"jsonrpc": "2.0", "result": -19, "id": 2}
+    """
+
+    def subtract(a, b):
+        return a - b
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+    rpc_server.add_method(subtract)
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.subtract(42, 23) == 19
+        assert await rpc.subtract(23, 42) == -19
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'subtract', 'params': [42, 23], 'id': 1})
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 1}
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'subtract', 'params': [23, 42], 'id': 2})
+        assert result[0] == {'jsonrpc': '2.0', 'result': -19, 'id': 2}
+
+
+async def test_rpc_call_with_named_parameters(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 3}
+
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 4}
+    """
+
+    def subtract(*, subtrahend, minuend):
+        return minuend - subtrahend
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+    rpc_server.add_method(subtract)
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.subtract(subtrahend=23, minuend=42) == 19
+        assert await rpc.subtract(minuend=42, subtrahend=23) == 19
+
+        result = await rpc.send_json({
+            'jsonrpc': '2.0', 'method': 'subtract', 'params': {"subtrahend": 23, "minuend": 42}, 'id': 3,
+        })
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 3}
+
+        result = await rpc.send_json({
+            'jsonrpc': '2.0', 'method': 'subtract', 'params': {"minuend": 42, "subtrahend": 23}, 'id': 4
+        })
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 4}
+
+
+async def test_notification(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}
+    --> {"jsonrpc": "2.0", "method": "foobar"}
+    """
+
+    def update(*args):
+        return args
+
+    def foobar(*args):
+        return 'ok'
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+    rpc_server.add_method(update)
+    rpc_server.add_method(foobar)
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.notify('update', subtrahend=23, minuend=42) is None
+        assert await rpc.notify('foobar', minuend=42, subtrahend=23) is None
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'update', 'params': [1, 2, 3, 4, 5]})
+        assert result[0] is None
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'foobar'})
+        assert result[0] is None
+
+
+async def test_rpc_call_of_non_existent_method(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
+    <-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}
+    """
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        with pytest.raises(errors.MethodNotFound):
+            assert await rpc.call('foobar', subtrahend=23, minuend=42)
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'foobar', 'id': '1'})
+        assert result[0] == {
+            'jsonrpc': '2.0', 'error': {'code': -32601, 'message': errors.MethodNotFound.message}, 'id': '1',
+        }
+
+
+async def test_rpc_call_with_invalid_json(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]
+    <-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        http_response = await rpc.session.post(
+            rpc.url,
+            data='{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]',
+        )
+        json_response = await http_response.json()
+        del json_response['error']['message']
+
+        assert json_response == {'jsonrpc': '2.0', 'error': {'code': -32700}, 'id': None}
+
+
+async def test_rpc_call_with_an_empty_array(aiohttp_client):
+    """
+    --> []
+    <-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        with pytest.raises(errors.InvalidRequest):
+            await rpc.batch([])
+
+        result = await rpc.send_json([])
+        assert result[0] == {
+            'jsonrpc': '2.0', 'error': {'code': -32600, 'message': errors.InvalidRequest.message}, 'id': None,
+        }
+
+
+async def test_rpc_call_with_an_invalid_batch(aiohttp_client):
+    """
+    --> [1]
+    <-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        result = await rpc.send_json([1])
+        assert result[0] == [{
+            'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'Data must be a dict.'}, 'id': None,
+        }]
+
+
+async def test_rpc_call_with_invalid_batch(aiohttp_client):
+    """
+    --> [1,2,3]
+    <-- [
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    ]
+    """
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    json_with_error = {
+        'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'Data must be a dict or an list.'}, 'id': None,
+    }
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        result = await rpc.send_json([1, 2, 3])
+        assert result[0] == [json_with_error, json_with_error, json_with_error]
+
+
+async def test_rpc_call_with_invalid_batch(aiohttp_client):
+    """
+    --> [
+            {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
+            {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
+            {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
+            {"foo": "boo"},
+            {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"},
+            {"jsonrpc": "2.0", "method": "get_data", "id": "9"}
+        ]
+    <-- [
+            {"jsonrpc": "2.0", "result": 7, "id": "1"},
+            {"jsonrpc": "2.0", "result": 19, "id": "2"},
+            {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+            {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"},
+            {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
+        ]
+    """
+
+    def subtract(a, b):
+        return a - b
+
+    def notify_hello(a):
+        return a
+
+    def get_data():
+        return ['hello', 5]
+
+    def my_sum(*args):
+        return sum(args)
+
+    rpc_server = aiohttp_rpc.JsonRpcServer()
+    rpc_server.add_method(subtract)
+    rpc_server.add_method((aiohttp_rpc.JsonRpcMethod(prefix='', func=my_sum, custom_name='sum')))
+    rpc_server.add_method(notify_hello)
+    rpc_server.add_method(get_data)
+
+    client = await utils.make_client(aiohttp_client, rpc_server)
+
+    called_methods = [
+        aiohttp_rpc.JsonRpcRequest(id=1, method_name='sum', params=[1, 2, 4]),
+        aiohttp_rpc.JsonRpcRequest(method_name='notify_hello', params=[1, 2, 4]),
+        aiohttp_rpc.JsonRpcRequest(id=2, method_name='subtract', params=[42, 23]),
+        aiohttp_rpc.JsonRpcRequest(id=5, method_name='foo.get', params={'name': 'myself'}),
+        aiohttp_rpc.JsonRpcRequest(id=9, method_name='get_data'),
+    ]
+
+    async with aiohttp_rpc.JsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.batch(called_methods) == [7, None, 19, errors.MethodNotFound(), ['hello', 5]]
+        assert await rpc.batch(called_methods, save_order=False) == [7, 19, errors.MethodNotFound(), ['hello', 5]]
+
+        result = await rpc.send_json([
+            {'jsonrpc': '2.0', 'method': 'sum', 'params': [1, 2, 4], 'id': '1'},
+            {'jsonrpc': '2.0', 'method': 'notify_hello', 'params': [7]},
+            {'jsonrpc': '2.0', 'method': 'subtract', 'params': [42, 23], 'id': '2'},
+            {'foo': 'boo'},
+            {'jsonrpc': '2.0', 'method': 'foo.get', 'params': {'name': 'myself'}, 'id': '5'},
+            {'jsonrpc': '2.0', 'method': 'get_data', 'id': '9'}
+        ])
+
+        assert result[0] == [
+            {'jsonrpc': '2.0', 'result': 7, 'id': '1'},
+            {'jsonrpc': '2.0', 'result': 19, 'id': '2'},
+            {'jsonrpc': '2.0', 'error': {
+                'code': -32600, 'message': 'A request must contain "method" and "jsonrpc".'
+            }, 'id': None},
+            {'jsonrpc': '2.0', 'error': {
+                'code': -32601, 'message': 'The method does not exist / is not available.'
+            }, 'id': '5'},
+            {'jsonrpc': '2.0', 'result': ['hello', 5], 'id': '9'},
+        ]
diff --git a/tests/test_protocol_for_ws.py b/tests/test_protocol_for_ws.py
new file mode 100644
index 0000000..8af5e13
--- /dev/null
+++ b/tests/test_protocol_for_ws.py
@@ -0,0 +1,329 @@
+# https://www.jsonrpc.org/specification#examples
+import asyncio
+
+import pytest
+
+import aiohttp_rpc
+from aiohttp_rpc import errors
+from tests import utils
+
+
+async def test_rpc_call_with_positional_parameters(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 1}
+
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": [23, 42], "id": 2}
+    <-- {"jsonrpc": "2.0", "result": -19, "id": 2}
+    """
+
+    def subtract(a, b):
+        return a - b
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    rpc_server.add_method(subtract)
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.WsJsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.subtract(42, 23) == 19
+        assert await rpc.subtract(23, 42) == -19
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'subtract', 'params': [42, 23], 'id': 1})
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 1}
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'subtract', 'params': [23, 42], 'id': 2})
+        assert result[0] == {'jsonrpc': '2.0', 'result': -19, 'id': 2}
+
+
+async def test_rpc_call_with_named_parameters(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": {"subtrahend": 23, "minuend": 42}, "id": 3}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 3}
+
+    --> {"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 4}
+    <-- {"jsonrpc": "2.0", "result": 19, "id": 4}
+    """
+
+    def subtract(*, subtrahend, minuend):
+        return minuend - subtrahend
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    rpc_server.add_method(subtract)
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.WsJsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.subtract(subtrahend=23, minuend=42) == 19
+        assert await rpc.subtract(minuend=42, subtrahend=23) == 19
+
+        result = await rpc.send_json({
+            'jsonrpc': '2.0', 'method': 'subtract', 'params': {"subtrahend": 23, "minuend": 42}, 'id': 3,
+        })
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 3}
+
+        result = await rpc.send_json({
+            'jsonrpc': '2.0', 'method': 'subtract', 'params': {"minuend": 42, "subtrahend": 23}, 'id': 4
+        })
+        assert result[0] == {'jsonrpc': '2.0', 'result': 19, 'id': 4}
+
+
+async def test_notification(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "update", "params": [1,2,3,4,5]}
+    --> {"jsonrpc": "2.0", "method": "foobar"}
+    """
+
+    def update(*args):
+        return args
+
+    def foobar(*args):
+        return 'ok'
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    rpc_server.add_method(update)
+    rpc_server.add_method(foobar)
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.WsJsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.notify('update', subtrahend=23, minuend=42) is None
+        assert await rpc.notify('foobar', minuend=42, subtrahend=23) is None
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'update', 'params': [1, 2, 3, 4, 5]})
+        assert result[0] is None
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'foobar'})
+        assert result[0] is None
+
+
+async def test_rpc_call_of_non_existent_method(aiohttp_client):
+    """
+    --> {"jsonrpc": "2.0", "method": "foobar", "id": "1"}
+    <-- {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "1"}
+    """
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    async with aiohttp_rpc.WsJsonRpcClient('/rpc', session=client) as rpc:
+        with pytest.raises(errors.MethodNotFound):
+            assert await rpc.call('foobar', subtrahend=23, minuend=42)
+
+        result = await rpc.send_json({'jsonrpc': '2.0', 'method': 'foobar', 'id': '1'})
+        assert result[0] == {
+            'jsonrpc': '2.0', 'error': {'code': -32601, 'message': errors.MethodNotFound.message}, 'id': '1',
+        }
+
+
+async def test_rpc_call_with_invalid_json(aiohttp_client, mocker):
+    """
+    --> {"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]
+    <-- {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    future = asyncio.Future()
+
+    def unprocessed_json_response_handler(*, ws_connect, ws_msg, json_response):
+        future.set_result(json_response)
+        del json_response['error']['message']
+        assert json_response == {'jsonrpc': '2.0', 'error': {'code': -32700}, 'id': None}
+
+    async with aiohttp_rpc.WsJsonRpcClient(
+            '/rpc',
+            session=client,
+            unprocessed_json_response_handler=unprocessed_json_response_handler,
+    ) as rpc:
+        handle_ws_message = mocker.patch.object(
+            rpc,
+            '_handle_single_ws_message',
+            side_effect=rpc._handle_single_ws_message,
+        )
+        rpc.json_serialize = lambda x: x
+        result = await rpc.send_json('{"jsonrpc": "2.0", "method": "foobar, "params": "bar", "baz]')
+        assert result == (None, None,)
+        await asyncio.wait_for(future, timeout=3)
+        handle_ws_message.assert_called_once()
+
+
+async def test_rpc_call_with_an_empty_array(aiohttp_client, mocker):
+    """
+    --> []
+    <-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    future = asyncio.Future()
+
+    def unprocessed_json_response_handler(*, ws_connect, ws_msg, json_response):
+        future.set_result(json_response)
+        assert json_response == {
+            'jsonrpc': '2.0', 'error': {'code': -32600, 'message': errors.InvalidRequest.message}, 'id': None,
+        }
+
+    async with aiohttp_rpc.WsJsonRpcClient(
+            '/rpc',
+            session=client,
+            unprocessed_json_response_handler=unprocessed_json_response_handler,
+    ) as rpc:
+        handle_ws_message = mocker.patch.object(
+            rpc,
+            '_handle_single_ws_message',
+            side_effect=rpc._handle_single_ws_message,
+        )
+
+        with pytest.raises(errors.InvalidRequest):
+            await rpc.batch([])
+
+        handle_ws_message.assert_not_called()
+        await rpc.send_json([])
+        await asyncio.wait_for(future, timeout=3)
+        handle_ws_message.assert_called_once()
+
+
+async def test_rpc_call_with_an_invalid_batch(aiohttp_client, mocker):
+    """
+    --> [1]
+    <-- {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    """
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+    future = asyncio.Future()
+
+    def unprocessed_json_response_handler(*, ws_connect, ws_msg, json_response):
+        future.set_result(json_response)
+        assert json_response == {
+            'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'Data must be a dict.'}, 'id': None,
+        }
+
+    async with aiohttp_rpc.WsJsonRpcClient(
+            '/rpc',
+            session=client,
+            unprocessed_json_response_handler=unprocessed_json_response_handler,
+    ) as rpc:
+        handle_ws_message = mocker.patch.object(
+            rpc,
+            '_handle_single_ws_message',
+            side_effect=rpc._handle_single_ws_message,
+        )
+        await rpc.send_json([1])
+        await asyncio.wait_for(future, timeout=3)
+        handle_ws_message.assert_called_once()
+
+
+async def test_rpc_call_with_invalid_batch(aiohttp_client, mocker):
+    """
+    --> [1,2,3]
+    <-- [
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+      {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null}
+    ]
+    """
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+    future = asyncio.Future()
+
+    json_with_error = {
+        'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'Data must be a dict or an list.'}, 'id': None,
+    }
+
+    def unprocessed_json_response_handler(*, ws_connect, ws_msg, json_response):
+        future.set_result(json_response)
+        assert json_response == [json_with_error, json_with_error, json_with_error]
+
+    async with aiohttp_rpc.WsJsonRpcClient(
+            '/rpc',
+            session=client,
+            unprocessed_json_response_handler=unprocessed_json_response_handler,
+    ) as rpc:
+        handle_ws_message = mocker.patch.object(
+            rpc,
+            '_handle_single_ws_message',
+            side_effect=rpc._handle_single_ws_message,
+        )
+        await rpc.send_json([1, 2, 3])
+        await asyncio.wait_for(future, timeout=3)
+        handle_ws_message.assert_called_once()
+
+
+async def test_rpc_call_with_invalid_batch(aiohttp_client):
+    """
+    --> [
+            {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
+            {"jsonrpc": "2.0", "method": "notify_hello", "params": [7]},
+            {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
+            {"foo": "boo"},
+            {"jsonrpc": "2.0", "method": "foo.get", "params": {"name": "myself"}, "id": "5"},
+            {"jsonrpc": "2.0", "method": "get_data", "id": "9"}
+        ]
+    <-- [
+            {"jsonrpc": "2.0", "result": 7, "id": "1"},
+            {"jsonrpc": "2.0", "result": 19, "id": "2"},
+            {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid Request"}, "id": null},
+            {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "5"},
+            {"jsonrpc": "2.0", "result": ["hello", 5], "id": "9"}
+        ]
+    """
+
+    def subtract(a, b):
+        return a - b
+
+    def notify_hello(a):
+        return a
+
+    def get_data():
+        return ['hello', 5]
+
+    def my_sum(*args):
+        return sum(args)
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer()
+    rpc_server.add_method(subtract)
+    rpc_server.add_method((aiohttp_rpc.JsonRpcMethod(prefix='', func=my_sum, custom_name='sum')))
+    rpc_server.add_method(notify_hello)
+    rpc_server.add_method(get_data)
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    called_methods = [
+        aiohttp_rpc.JsonRpcRequest(id=1, method_name='sum', params=[1, 2, 4]),
+        aiohttp_rpc.JsonRpcRequest(method_name='notify_hello', params=[1, 2, 4]),
+        aiohttp_rpc.JsonRpcRequest(id=2, method_name='subtract', params=[42, 23]),
+        aiohttp_rpc.JsonRpcRequest(id=5, method_name='foo.get', params={'name': 'myself'}),
+        aiohttp_rpc.JsonRpcRequest(id=9, method_name='get_data'),
+    ]
+
+    async with aiohttp_rpc.WsJsonRpcClient('/rpc', session=client) as rpc:
+        assert await rpc.batch(called_methods) == [7, None, 19, errors.MethodNotFound(), ['hello', 5]]
+        assert await rpc.batch(called_methods, save_order=False) == [7, 19, errors.MethodNotFound(), ['hello', 5]]
+
+        result = await rpc.send_json([
+            {'jsonrpc': '2.0', 'method': 'sum', 'params': [1, 2, 4], 'id': '1'},
+            {'jsonrpc': '2.0', 'method': 'notify_hello', 'params': [7]},
+            {'jsonrpc': '2.0', 'method': 'subtract', 'params': [42, 23], 'id': '2'},
+            {'foo': 'boo'},
+            {'jsonrpc': '2.0', 'method': 'foo.get', 'params': {'name': 'myself'}, 'id': '5'},
+            {'jsonrpc': '2.0', 'method': 'get_data', 'id': '9'}
+        ])
+
+        assert result[0] == [
+            {'jsonrpc': '2.0', 'result': 7, 'id': '1'},
+            {'jsonrpc': '2.0', 'result': 19, 'id': '2'},
+            {'jsonrpc': '2.0', 'error': {
+                'code': -32600, 'message': 'A request must contain "method" and "jsonrpc".'
+            }, 'id': None},
+            {'jsonrpc': '2.0', 'error': {
+                'code': -32601, 'message': 'The method does not exist / is not available.'
+            }, 'id': '5'},
+            {'jsonrpc': '2.0', 'result': ['hello', 5], 'id': '9'},
+        ]
diff --git a/tests/test_websocket.py b/tests/test_websocket.py
index 35c6405..7ba52bd 100644
--- a/tests/test_websocket.py
+++ b/tests/test_websocket.py
@@ -63,3 +63,44 @@ async def method(a):
 
         assert finished_at - started_at < datetime.timedelta(seconds=1)
         assert result == list(range(10))
+
+
+async def test_ws_client_for_server_response(aiohttp_client, mocker):
+    async def method(rpc_ws_client: aiohttp_rpc.WsJsonRpcClient):
+        await rpc_ws_client.notify('ping')
+        await rpc_ws_client.notify('ping')
+        await rpc_ws_client.notify('ping')
+
+    rpc_server = aiohttp_rpc.WsJsonRpcServer(
+        middlewares=[
+            *aiohttp_rpc.middlewares.DEFAULT_MIDDLEWARES,
+            aiohttp_rpc.middlewares.ws_client_for_server_response,
+        ],
+    )
+    rpc_server.add_method(method)
+
+    client = await utils.make_ws_client(aiohttp_client, rpc_server)
+
+    future = asyncio.Future()
+
+    results = []
+
+    def json_request_handler(*, ws_connect, ws_msg, json_request):
+        results.append(json_request)
+
+        if len(results) == 3:
+            future.set_result(results)
+
+    async with aiohttp_rpc.WsJsonRpcClient(
+            '/rpc',
+            session=client,
+            json_request_handler=json_request_handler,
+    ) as rpc:
+        json_request_handler = mocker.patch.object(rpc, '_json_request_handler', side_effect=rpc._json_request_handler)
+        await rpc.method()
+
+        await asyncio.wait_for(future, timeout=3)
+        assert json_request_handler.call_count == 3
+        assert results[0]['method'] == 'ping'
+        assert results[1]['method'] == 'ping'
+        assert results[2]['method'] == 'ping'