Skip to content

Commit 0e701f6

Browse files
committed
Added socketio documentation
1 parent 50365f8 commit 0e701f6

33 files changed

+787
-6
lines changed

docs/img/live_support_websocket_3.gif

119 KB
Loading

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# **[ellar](#introduction)**
12
<p align="center">
23
<a href="#" target="blank"><img src="img/EllarLogoB.png" width="200" alt="Ellar Logo" /></a>
34
</p>

docs/websockets/socketio.md

Lines changed: 362 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,362 @@
1-
# Socket IO - [python-socketio](https://python-socketio.readthedocs.io/en/latest/){target="_blank"}
1+
# **Socket IO - [python-socketio](https://python-socketio.readthedocs.io/en/latest/){target="_blank"}**
2+
3+
Ellar integration with [python-socketio](https://python-socketio.readthedocs.io/en/latest/){target="_blank"}, a library that enables real-time, bidirectional and event-based communication between the browser and the server.
4+
5+
## **Gateways**
6+
7+
A class annotated with `WebSocketGateway` decorator is like a controller that creates a compatibles with python-socketio, ellar and websocket.
8+
A gateway class also supports dependency injection and guards.
9+
10+
```python
11+
from ellar.socket_io import WebSocketGateway
12+
13+
14+
@WebSocketGateway(path='/events-ws', name='event-gateway')
15+
class EventGateway:
16+
pass
17+
```
18+
## **Installation**
19+
To start building Socket.IO webSockets-based applications, first install the required package:
20+
```shell
21+
$(venv) pip install python-socketio
22+
```
23+
## **Overview**
24+
In general, each gateway is listening on the same port as the HTTP server and has a path `/socket.io` unless changed manually.
25+
This default behavior can be modified by passing an argument to the `@WebSocketGateway(path='/event-ws')`.
26+
You can also set a [namespace](https://socket.io/docs/v4/namespaces/) used by the gateway as shown below:
27+
28+
```python
29+
# project_name/events/gateway.py
30+
from ellar.socket_io import WebSocketGateway
31+
32+
33+
@WebSocketGateway(path='/socket.io', namespace='events')
34+
class EventGateway:
35+
pass
36+
```
37+
!!! warning
38+
Gateways are not instantiated until they are referenced in the `controllers` array of an existing module.
39+
40+
You can pass any supported [option](https://socket.io/docs/v4/server-options/) to the socket constructor with the second argument to the `@WebSocketGateway()` decorator, as shown below:
41+
42+
```python
43+
# project_name/events/gateway.py
44+
from ellar.socket_io import WebSocketGateway, GatewayBase
45+
46+
47+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
48+
class EventGateway(GatewayBase):
49+
pass
50+
```
51+
52+
The gateway is now listening, but we have not yet subscribed to any incoming messages.
53+
Let's create a handler that will subscribe to the `events` messages and respond to the user with the exact same data.
54+
```python
55+
# project_name/events/gateway.py
56+
from ellar.socket_io import WebSocketGateway, subscribe_message, GatewayBase
57+
from ellar.common import WsBody
58+
59+
60+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
61+
class EventGateway(GatewayBase):
62+
@subscribe_message('events')
63+
async def handle_event(self, data: str = WsBody()):
64+
return data
65+
```
66+
67+
You can also define schema for the data receive, for example:
68+
```python
69+
# project_name/events/gateway.py
70+
from ellar.socket_io import WebSocketGateway, subscribe_message, GatewayBase
71+
from ellar.common import WsBody
72+
from pydantic import BaseModel
73+
74+
75+
class MessageBody(BaseModel):
76+
data: str
77+
78+
79+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
80+
class EventGateway(GatewayBase):
81+
@subscribe_message('events')
82+
async def handle_event(self, data: MessageBody = WsBody()):
83+
return data.dict()
84+
```
85+
86+
Once the gateway is created, we can register it in our module.
87+
```python
88+
# project_name/events/module.py
89+
90+
from ellar.common import Module
91+
from .gateway import EventGateway
92+
93+
@Module(controllers=[EventGateway])
94+
class EventsModule:
95+
pass
96+
97+
```
98+
99+
`WebSocketGateway` decorated class comes with a different **context** that providers extra information/access to `server`, `sid` and current message `environment`.
100+
```python
101+
from ellar.socket_io import GatewayBase
102+
from socketio import AsyncServer
103+
104+
105+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
106+
class EventGateway(GatewayBase):
107+
@subscribe_message('events')
108+
async def handle_event(self, data: MessageBody = WsBody()):
109+
assert isinstance(self.context.server, AsyncServer)
110+
assert isinstance(self.context.sid, str)
111+
assert isinstance(self.context.environment, dict)
112+
113+
await self.context.server.emit('my_custom_event', data.dict(), room=None)
114+
```
115+
116+
## **WsResponse**
117+
You may return a `WsResponse` object and supply two properties. The `event` which is a name of the emitted event and the `data` that has to be forwarded to the client.
118+
```python
119+
from ellar.socket_io import GatewayBase
120+
from ellar.socket_io import WsResponse
121+
122+
123+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
124+
class EventGateway(GatewayBase):
125+
@subscribe_message('events')
126+
async def handle_event(self, data: MessageBody = WsBody()):
127+
return WsResponse('events', data.dict())
128+
```
129+
!!! hint
130+
The `WsResponse` class is imported from `ellar.socketio` package. And its has similar interface as `AsyncServer().emit`
131+
132+
!!! warning
133+
If you return a response that is not a `WsResponse` object, ellar will assume handler as the `event` to emit the response. Or you can use `self.context.server.emit` to send the message back to the client.
134+
135+
In order to listen for the incoming response(s), the client has to apply another event listener.
136+
137+
```javascript
138+
socket.on('events', (data) => console.log(data));
139+
```
140+
141+
## **Gateway Connection and Disconnection Handling**
142+
`on_connected` and `on_disconnected` can be used to define `on_connect` and `on_disconnect` handler in your gateway controller.
143+
144+
For example,
145+
```python
146+
from ellar.socket_io import GatewayBase, WebSocketGateway, subscribe_message, on_connected, on_disconnected
147+
from ellar.socket_io import WsResponse
148+
149+
150+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
151+
class EventGateway(GatewayBase):
152+
@on_connected()
153+
async def connect(self):
154+
await self.context.server.emit(
155+
"my_response", {"data": "Connected", "count": 0}, room=self.context.sid
156+
)
157+
158+
@on_disconnected()
159+
async def disconnect(self):
160+
print("Client disconnected")
161+
162+
@subscribe_message('events')
163+
async def handle_event(self, data: MessageBody = WsBody()):
164+
return WsResponse('events', data.dict())
165+
```
166+
167+
!!! info
168+
`@on_connected` and `@on_disconnected()` handlers doesn't take any argument because all its arguments are already available in the `self.context`
169+
170+
171+
## **Exceptions**
172+
All exceptions that happens on the server in a gateway controller after successful handshake between the server and client are sent to the client through `error` event.
173+
This is a standard practice when working socketio client. The client is required to subscribe to `error` event inorder to receive error message from the server.
174+
175+
176+
for example:
177+
```python
178+
from ellar.socket_io import GatewayBase, WebSocketGateway, subscribe_message
179+
from ellar.common.exceptions import WebSocketException
180+
from starlette import status
181+
182+
183+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
184+
class EventGateway(GatewayBase):
185+
@subscribe_message('events')
186+
async def handle_event(self, data: MessageBody = WsBody()):
187+
raise WebSocketException(status.WS_1009_MESSAGE_TOO_BIG, reason='Message is too big')
188+
```
189+
When client sends message to `events`, an exception will be raised. And the client will receive the error message if it subscribed to `error` events.
190+
191+
For example:
192+
193+
```javascript
194+
const socket = io.connect()
195+
196+
socket.on('error', (error) => {
197+
console.error(error)
198+
})
199+
```
200+
201+
## **Guards**
202+
There is no fundamental difference between web sockets guards and regular HTTP application guards.
203+
The only difference is that instead of throwing `HttpException`, you should use `WebSocketException`
204+
205+
!!! hint
206+
`WebSocketException` is an exception class located in `ellar.common.exceptions`
207+
208+
209+
```python
210+
from ellar.common import Guards
211+
212+
...
213+
@Guards(MyCustomGuards)
214+
@subscribe_message('events')
215+
async def handle_event(self, data: MessageBody = WsBody()):
216+
return WsResponse('events', data.dict())
217+
...
218+
219+
```
220+
221+
`@Guards` can be applied at handler level as shown in the last construct or at class level as shown below:
222+
223+
```python
224+
...
225+
226+
@Guards(MyGuard)
227+
@WebSocketGateway(path='/socket.io', transports=['websocket'])
228+
class EventGateway(GatewayBase):
229+
@on_connected()
230+
async def connect(self):
231+
await self.context.server.emit(
232+
"my_response", {"data": "Connected", "count": 0}, room=self.context.sid
233+
)
234+
...
235+
```
236+
237+
## **Testing**
238+
Gateway can be unit tested just like regular ellar controllers. But for integration testing, a separate testing module, `TestGateway`, is needed
239+
to set up a socketio client to simulation activity between server and client.
240+
241+
!!! hint
242+
`TestGateway` class is located at `ellar.socket_io.testing`
243+
244+
For example:
245+
246+
```python
247+
@WebSocketGateway(path="/ws", async_mode="asgi", cors_allowed_origins="*")
248+
class EventGateway:
249+
@subscribe_message("my_event")
250+
async def my_event(self, message: MessageData = WsBody()):
251+
return WsResponse("my_response", {"data": message.data}, room=self.context.sid)
252+
253+
@subscribe_message
254+
async def my_broadcast_event(self, message: MessageData = WsBody()):
255+
await self.context.server.emit("my_response", {"data": message.data})
256+
257+
@on_connected()
258+
async def connect(self):
259+
await self.context.server.emit(
260+
"my_response", {"data": "Connected", "count": 0}, room=self.context.sid
261+
)
262+
263+
@on_disconnected()
264+
async def disconnect(self):
265+
print("Client disconnected")
266+
```
267+
The above gateway construct integration testing can be done as shown below:
268+
269+
```python
270+
import pytest
271+
from ellar.socket_io.testing import TestGateway
272+
273+
@pytest.mark.asyncio
274+
class TestEventGateway:
275+
test_client = TestGateway.create_test_module(controllers=[EventGateway])
276+
277+
async def test_socket_connection_work(self):
278+
my_response_message = []
279+
connected_called = False
280+
disconnected_called = False
281+
282+
async with self.test_client.run_with_server() as ctx:
283+
284+
@ctx.sio.event
285+
async def my_response(message):
286+
my_response_message.append(message)
287+
288+
@ctx.sio.event
289+
async def disconnect():
290+
nonlocal disconnected_called
291+
disconnected_called = True
292+
293+
@ctx.sio.event
294+
async def connect(*args):
295+
nonlocal connected_called
296+
await ctx.sio.emit("my_event", {"data": "I'm connected!"})
297+
connected_called = True
298+
299+
await ctx.connect(socketio_path="/ws/")
300+
await ctx.wait()
301+
302+
assert len(my_response_message) == 2
303+
assert my_response_message == [
304+
{"data": "Connected", "count": 0},
305+
{"data": "I'm connected!"},
306+
]
307+
assert disconnected_called and connected_called
308+
309+
async def test_broadcast_work(self):
310+
sio_1_response_message = []
311+
sio_2_response_message = []
312+
313+
async with self.test_client.run_with_server() as ctx:
314+
ctx_2 = ctx.new_socket_client_context()
315+
316+
@ctx.sio.event
317+
async def my_response(message):
318+
sio_1_response_message.append(message)
319+
320+
@ctx_2.sio.event
321+
async def my_response(message):
322+
sio_2_response_message.append(message)
323+
324+
await ctx.connect(socketio_path="/ws/")
325+
await ctx_2.connect(socketio_path="/ws/")
326+
327+
await ctx.sio.emit(
328+
"my_broadcast_event", {"data": "Testing Broadcast"}
329+
) # both sio_1 and sio_2 would receive this message
330+
331+
await ctx.wait()
332+
await ctx_2.wait()
333+
334+
assert len(sio_1_response_message) == 2
335+
assert sio_1_response_message == [
336+
{"data": "Connected", "count": 0},
337+
{"data": "Testing Broadcast"},
338+
]
339+
340+
assert len(sio_2_response_message) == 2
341+
assert sio_2_response_message == [
342+
{"data": "Connected", "count": 0},
343+
{"data": "Testing Broadcast"},
344+
]
345+
```
346+
`self.test_client.run_with_server()` setup a server and returns `RunWithServerContext` object.
347+
The `RunWithServerContext` contains a socket io client and created server url.
348+
And with the client(`sio`) returned, you can subscribe to events and send messages as shown in the above construct.
349+
350+
!!! warning
351+
It is important to have all the event subscription written before calling `ctx.connect`
352+
353+
Also, it is possible to test with more than one client as you can see in `test_broadcast_work` in construct above.
354+
We created another instance of **RunWithServerContext** as `ctx_2` from the already existing `ctx` with `ctx.new_socket_client_context()`.
355+
And both were used to test for message broadcast.
356+
357+
358+
## **SocketIO Ellar Example**
359+
[python-socketio](https://python-socketio.readthedocs.io/en/latest/){target="_blank"} provided a sample project on how to integrate [python-socketio with django](https://github.com/miguelgrinberg/python-socketio/blob/main/examples/server/wsgi).
360+
The sample project was converted to ellar gateway and it can find it [here]()
361+
362+
![gateway_example_image](../img/live_support_websocket_3.gif)

ellar/common/exceptions/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from starlette.exceptions import HTTPException
1+
from starlette.exceptions import HTTPException, WebSocketException
22

33
from .api import (
44
APIException,
@@ -18,6 +18,7 @@
1818
"HostContextException",
1919
"ExecutionContextException",
2020
"HTTPException",
21+
"WebSocketException",
2122
"ImproperConfiguration",
2223
"APIException",
2324
"WebSocketRequestValidationError",

ellar/openapi/builder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,14 @@
1818
from ellar.common.routing.controller import ControllerRouteOperation
1919
from ellar.core.main import App
2020
from ellar.core.services.reflector import Reflector
21-
from ellar.core.validation_schema import HTTPValidationError, ValidationError
2221

2322
from .openapi_v3 import OpenAPI
2423
from .route_doc_models import (
2524
OpenAPIMountDocumentation,
2625
OpenAPIRoute,
2726
OpenAPIRouteDocumentation,
2827
)
28+
from .schemas import HTTPValidationError, ValidationError
2929

3030
default_openapi_version = "3.0.2"
3131

0 commit comments

Comments
 (0)