From c3a0f9c2ac7f5a80d6b40ba816d27bab6d041ec4 Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sat, 13 Jul 2024 15:18:03 +0200 Subject: [PATCH 01/14] docs(ws-tutorial): add tutorial on how to use websockets Add a tutorial on how to work with websockets in a Falcon app. This is to be read as a quickstart, linking to the reference page for more info. #2240 --- docs/user/index.rst | 1 + docs/user/tutorial-websockets.rst | 608 ++++++++++++++++++++++++++++++ 2 files changed, 609 insertions(+) create mode 100644 docs/user/tutorial-websockets.rst diff --git a/docs/user/index.rst b/docs/user/index.rst index 7a6c5e385..0574978a8 100644 --- a/docs/user/index.rst +++ b/docs/user/index.rst @@ -9,5 +9,6 @@ User Guide quickstart tutorial tutorial-asgi + tutorial-websockets recipes/index faq diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst new file mode 100644 index 000000000..ebec0e281 --- /dev/null +++ b/docs/user/tutorial-websockets.rst @@ -0,0 +1,608 @@ +.. _tutorial-websockets: + +Tutorial (WebSockets) +===================== + +In this tutorial, we're going to build a WebSocket server using Falcon. We'll start with a simple server that echoes back any message it receives. +We'll then add more functionality to the server, such as sending JSON data and logging messages. + +.. note:: + This tutorial covers the asynchronous flavor of Falcon using + the `ASGI `__ protocol. + + A Falcon WebSocket server is build upon the `ASGI WebSocket specification `__. Therefore it's not supported in a Falcon WSGI application. + + +First Steps +___________ + +We'll start with a clean working directory and create a new virtual environment using the `venv` module.: + +.. code-block:: bash + + $ mkdir asyncws + $ cd asyncws + $ python3 -m venv .venv + $ source .venv/bin/activate + +Create the following directory structure:: + + asyncws + ├── .venv + └── asyncws + ├── __init__.py + └── app.py + + +And next we'll install Falcon and Uvicorn in our freshly created virtual environment: + +.. code-block:: bash + + $ pip install falcon uvicorn + +Now, let's create a simple Falcon application to ensure our project is working as expected. + +.. code-block:: python + + import falcon.asgi + import uvicorn + + app = falcon.asgi.App() + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + app.add_route('/hello', HelloWorldResource()) + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +Now we can test the application by running the following command:: + + $ http localhost:8000/hello + + HTTP/1.1 200 OK + content-length: 18 + content-type: application/json + date: Sat, 13 Jul 2024 09:13:24 GMT + server: uvicorn + + { + "hello": "world" + } + +Awesome, it works! Now let's move on to building our WebSocket server. + +WebSockets Server +_________________ + +We will update our server to include a websocket route that will echo back any message it receives. +Later we'll update the server with more logic, but for now, let's keep it simple. + +.. code-block:: python + + import falcon.asgi + from falcon import WebSocketDisconnected + from falcon.asgi import Request, WebSocket + import uvicorn + + app = falcon.asgi.App() + + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + + class EchoWebSocketResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + message = await ws.receive_text() + await ws.send_text(f"Received the following text: {message}") + except WebSocketDisconnected: + return + + + app.add_route('/hello', HelloWorldResource()) + app.add_route('/echo', EchoWebSocketResource()) + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +We'll also need to install a websockets library. There are multiple ways to do this:: + + $ pip install websockets + or + $ pip install uvicorn[standard] + or + $ wsproto + +To test the new WebSocket route, we can use the `websocat `__ tool:: + + $ websocat ws://localhost:8000/echo + $ hello + Received the following text: hello + +Cool! We have a working WebSocket server. Now let's add some more functionality to our server. + +To make this easier, we'll create a simple client that will send messages to our server. + +Simple Client +_____________ + +Create a new file called `client.py` in the same directory as `app.py`. The client will ask for your input and send it to the server.: + +.. code-block:: python + + import asyncio + import websockets + + + async def send_message(): + uri = "ws://localhost:8000/echo/hello" + + async with websockets.connect(uri) as websocket: + while True: + message = input("Enter a message: ") + await websocket.send(message) + response = await websocket.recv() + print(response) + + + if __name__ == "__main__": + asyncio.run(send_message()) + +Run this client in a separate terminal: + +.. code-block:: bash + + $ python client.py + Enter a message: Hi + Received the following text: Hi + +This will simplify testing our server. + +Now let's add some more functionality to our server. + +We've been working with text input/output - let's try sending sending some JSON data. + +.. code-block:: python + + from datetime import datetime + + import falcon.asgi + from falcon import WebSocketDisconnected + from falcon.asgi import Request, WebSocket + import uvicorn + + app = falcon.asgi.App() + + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + + class EchoWebSocketResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + message = await ws.receive_text() + await ws.send_media({'message': message, 'date': datetime.now().isoformat()}) + except WebSocketDisconnected: + return + + + app.add_route('/hello', HelloWorldResource()) + app.add_route('/echo', EchoWebSocketResource()) + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +.. code-block:: bash + + $ python client.py + $ Enter a message: Hi + {"message": "Hi", "date": "2024-07-13T12:11:51.758923"} + + +.. note:: + By default, `send_media() `__ and `receive_media() `__ will serialize to (and deserialize from) JSON for a TEXT payload, and to/from MessagePack for a BINARY payload (see also: `Built-in Media Handlers `__). + +Lets try to query for data from the server. We'll create a new resource that will return a report based on the query. + +Server side: + +.. code-block:: python + + from datetime import datetime + + import falcon.asgi + from falcon import WebSocketDisconnected + from falcon.asgi import Request, WebSocket + import uvicorn + + REPORTS = { + 'report1': { + 'title': 'Report 1', + 'content': 'This is the content of report 1', + }, + 'report2': { + 'title': 'Report 2', + 'content': 'This is the content of report 2', + }, + 'report3': { + 'title': 'Report 3', + 'content': 'This is the content of report 3', + }, + 'report4': { + 'title': 'Report 4', + 'content': 'This is the content of report 4', + }, + } + + app = falcon.asgi.App() + + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + + class EchoWebSocketResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + message = await ws.receive_text() + await ws.send_media({'message': message, 'date': datetime.now().isoformat()}) + except WebSocketDisconnected: + return + + class ReportsResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + query = await ws.receive_text() + report = REPORTS.get(query, None) + print(report) + + if report is None: + await ws.send_media({'error': 'report not found'}) + continue + + await ws.send_media({'report': report["title"]}) + except WebSocketDisconnected: + return + + + app.add_route('/hello', HelloWorldResource()) + app.add_route('/echo', EchoWebSocketResource()) + app.add_route('/reports', ReportsResource()) + + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +We'll also create new client app (`reports_client.py`), that will connect to the reports endpoint. : + +.. code-block:: python + + import asyncio + import websockets + + + async def send_message(): + uri = "ws://localhost:8000/reports" + async with websockets.connect(uri) as websocket: + while True: + message = input("Name of the log: ") + await websocket.send(message) + response = await websocket.recv() + print(response) + + + if __name__ == "__main__": + asyncio.run(send_message()) + +We've added a new resource that will return a report based on the query. The client will send a query to the server, and the server will respond with the report. +If it can't find the report, it will respond with an error message. + +This is a simple example, but you can easily extend it to include more complex logic like fetching data from a database. + +Middleware +__________ + +Falcon supports middleware, which can be used to add functionality to the application. For example, we can add a middleware that prints when a connection is established. + +.. code-block:: python + + from datetime import datetime + + import falcon.asgi + from falcon import WebSocketDisconnected + from falcon.asgi import Request, WebSocket + import uvicorn + + REPORTS = { + 'report1': { + 'title': 'Report 1', + 'content': 'This is the content of report 1', + }, + 'report2': { + 'title': 'Report 2', + 'content': 'This is the content of report 2', + }, + 'report3': { + 'title': 'Report 3', + 'content': 'This is the content of report 3', + }, + 'report4': { + 'title': 'Report 4', + 'content': 'This is the content of report 4', + }, + } + + app = falcon.asgi.App() + + class LoggerMiddleware: + async def process_request_ws(self, req: Request, ws: WebSocket): + # This will be called for the HTTP request that initiates the + # WebSocket handshake before routing. + pass + + async def process_resource_ws(self, req: Request, ws: WebSocket, resource, params): + # This will be called for the HTTP request that initiates the + # WebSocket handshake after routing (if a route matches the + # request). + print(f'WebSocket connection established on {req.path}') + + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + + class EchoWebSocketResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + message = await ws.receive_text() + await ws.send_media({'message': message, 'date': datetime.now().isoformat()}) + except WebSocketDisconnected: + return + + class ReportsResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + query = await ws.receive_text() + report = REPORTS.get(query, None) + print(report) + + if report is None: + await ws.send_media({'error': 'report not found'}) + continue + + await ws.send_media({'report': report["title"]}) + except WebSocketDisconnected: + return + + + app.add_route('/hello', HelloWorldResource()) + app.add_route('/echo', EchoWebSocketResource()) + app.add_route('/reports', ReportsResource()) + + app.add_middleware(LoggerMiddleware()) + + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +Now, when you run the server, you should see a message in the console when a WebSocket connection is established. + + +Authentication +______________ + +Adding authentication can be done with the help of middleware as well. It checks the request headers for a token. +If the token is valid, the request is allowed to continue. If the token is invalid, the request is rejected. + +There are some `considerations `__ to take into account when implementing authentication in a WebSocket server. + +Updated server code: + +.. code-block:: python + + from datetime import datetime + + import falcon.asgi + import uvicorn + from falcon import WebSocketDisconnected + from falcon.asgi import Request, WebSocket + + REPORTS = { + 'report1': { + 'title': 'Report 1', + 'content': 'This is the content of report 1', + }, + 'report2': { + 'title': 'Report 2', + 'content': 'This is the content of report 2', + }, + 'report3': { + 'title': 'Report 3', + 'content': 'This is the content of report 3', + }, + 'report4': { + 'title': 'Report 4', + 'content': 'This is the content of report 4', + }, + } + + app = falcon.asgi.App() + + + class LoggerMiddleware: + async def process_request_ws(self, req: Request, ws: WebSocket): + # This will be called for the HTTP request that initiates the + # WebSocket handshake before routing. + pass + + async def process_resource_ws(self, req: Request, ws: WebSocket, resource, params): + # This will be called for the HTTP request that initiates the + # WebSocket handshake after routing (if a route matches the + # request). + print(f'WebSocket connection established on {req.path}') + + + # Added an authentication middleware. This middleware will check if the request is on a protected route. + class AuthMiddleware: + protected_routes = [] + + def __init__(self, protected_routes: list[str] | None = None): + if protected_routes is None: + protected_routes = [] + + self.protected_routes = protected_routes + + async def process_request_ws(self, req: Request, ws: WebSocket): + if req.path not in self.protected_routes: + return + + token = req.get_header('Authorization') + if token != 'very secure token': + await ws.close(1008) + return + + print(f'Client with token {token} Authenticated') + + + class HelloWorldResource: + async def on_get(self, req, resp): + resp.media = {'hello': 'world'} + + + class EchoWebSocketResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + message = await ws.receive_text() + await ws.send_media({'message': message, 'date': datetime.now().isoformat()}) + except WebSocketDisconnected: + return + + + class ReportsResource: + async def on_websocket(self, req: Request, ws: WebSocket): + try: + await ws.accept() + except WebSocketDisconnected: + return + + while True: + try: + query = await ws.receive_text() + report = REPORTS.get(query, None) + print(report) + + if report is None: + await ws.send_media({'error': 'report not found'}) + continue + + await ws.send_media({'report': report["title"]}) + except WebSocketDisconnected: + return + + + app.add_route('/hello', HelloWorldResource()) + app.add_route('/echo', EchoWebSocketResource()) + app.add_route('/reports', ReportsResource()) + + app.add_middleware(LoggerMiddleware()) + + # Add the AuthMiddleware to the app, and specify the protected routes + app.add_middleware(AuthMiddleware(['/reports'])) + + if __name__ == '__main__': + uvicorn.run(app, host='localhost', port=8000) + +Updated client code for the reports client: + +.. code-block:: python + + import asyncio + import websockets + + + async def send_message(): + uri = "ws://localhost:8000/reports" + headers = { + 'Authorization': 'very secure token' + } + + async with websockets.connect(uri, extra_headers=headers) as websocket: + while True: + message = input("Name of the log: ") + await websocket.send(message) + response = await websocket.recv() + print(response) + + + if __name__ == "__main__": + asyncio.run(send_message()) + +If you try to query the reports endpoint now, everything works as expected. But as soon as you remove/modify the token, the connection will be closed. + +.. code-block:: bash + + $ python reports_client.py + [...] + websockets.exceptions.InvalidStatusCode: server rejected WebSocket connection: HTTP 403 + +.. note:: + + This is a simple example of how to add authentication to a WebSocket server. In a real-world application, you would want to use a more secure method of authentication, such as JWT tokens. + +What Now +________ + +This tutorial is just the beginning. You can extend the server with more complex logic. For example, you could add a database to store/retrieve the reports, or add more routes to the server. + +For more information on websockets in Falcon, check out the `WebSocket API `__. From 40cb884b998ea338760cf1cc368bca63ed94b4bc Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sat, 13 Jul 2024 15:48:18 +0200 Subject: [PATCH 02/14] docs(ws-tutorial): move example code to includable files Include the final code instead of hard-coding it in the docs. #2240 --- docs/user/tutorial-websockets.rst | 163 +---------------------- examples/wslook/requirements.txt | 3 + examples/wslook/wslook/__init__.py | 0 examples/wslook/wslook/app.py | 119 +++++++++++++++++ examples/wslook/wslook/client.py | 17 +++ examples/wslook/wslook/reports_client.py | 18 +++ 6 files changed, 160 insertions(+), 160 deletions(-) create mode 100644 examples/wslook/requirements.txt create mode 100644 examples/wslook/wslook/__init__.py create mode 100644 examples/wslook/wslook/app.py create mode 100644 examples/wslook/wslook/client.py create mode 100644 examples/wslook/wslook/reports_client.py diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index ebec0e281..5696b1a2e 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -139,25 +139,7 @@ _____________ Create a new file called `client.py` in the same directory as `app.py`. The client will ask for your input and send it to the server.: -.. code-block:: python - - import asyncio - import websockets - - - async def send_message(): - uri = "ws://localhost:8000/echo/hello" - - async with websockets.connect(uri) as websocket: - while True: - message = input("Enter a message: ") - await websocket.send(message) - response = await websocket.recv() - print(response) - - - if __name__ == "__main__": - asyncio.run(send_message()) +.. literalinclude:: ../../examples/wslook/wslook/client.py Run this client in a separate terminal: @@ -443,150 +425,11 @@ There are some `considerations Date: Sat, 13 Jul 2024 16:04:03 +0200 Subject: [PATCH 03/14] docs(ws-tutorial): set max width to around 80 The max width is now set to 80. Not all lines fit, but most do. #2240 --- docs/user/tutorial-websockets.rst | 74 +++++++++++++++++++++---------- 1 file changed, 50 insertions(+), 24 deletions(-) diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index 5696b1a2e..4eaa50123 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -3,8 +3,11 @@ Tutorial (WebSockets) ===================== -In this tutorial, we're going to build a WebSocket server using Falcon. We'll start with a simple server that echoes back any message it receives. -We'll then add more functionality to the server, such as sending JSON data and logging messages. +In this tutorial, we're going to build a WebSocket server using Falcon. +We'll start with a simple server that echoes back any message it receives. + +We'll then add more functionality to the server, such as sending JSON data and +logging messages. .. note:: This tutorial covers the asynchronous flavor of Falcon using @@ -16,7 +19,8 @@ We'll then add more functionality to the server, such as sending JSON data and l First Steps ___________ -We'll start with a clean working directory and create a new virtual environment using the `venv` module.: +We'll start with a clean working directory and create a new virtual environment +using the `venv` module.: .. code-block:: bash @@ -34,13 +38,15 @@ Create the following directory structure:: └── app.py -And next we'll install Falcon and Uvicorn in our freshly created virtual environment: +And next we'll install Falcon and Uvicorn in our freshly created virtual +environment: .. code-block:: bash $ pip install falcon uvicorn -Now, let's create a simple Falcon application to ensure our project is working as expected. +Now, let's create a simple Falcon application to ensure our project is working +as expected. .. code-block:: python @@ -77,8 +83,9 @@ Awesome, it works! Now let's move on to building our WebSocket server. WebSockets Server _________________ -We will update our server to include a websocket route that will echo back any message it receives. -Later we'll update the server with more logic, but for now, let's keep it simple. +We will update our server to include a websocket route that will echo back any +message it receives. Later we'll update the server with more logic, but for now, +let's keep it simple. .. code-block:: python @@ -116,7 +123,8 @@ Later we'll update the server with more logic, but for now, let's keep it simple if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8000) -We'll also need to install a websockets library. There are multiple ways to do this:: +We'll also need to install a websockets library. There are multiple ways to do +this:: $ pip install websockets or @@ -124,20 +132,24 @@ We'll also need to install a websockets library. There are multiple ways to do t or $ wsproto -To test the new WebSocket route, we can use the `websocat `__ tool:: +To test the new WebSocket route, we can use the `websocat `__ +tool:: $ websocat ws://localhost:8000/echo $ hello Received the following text: hello -Cool! We have a working WebSocket server. Now let's add some more functionality to our server. +Cool! We have a working WebSocket server. Now let's add some more functionality +to our server. -To make this easier, we'll create a simple client that will send messages to our server. +To make this easier, we'll create a simple client that will send messages to our +server. Simple Client _____________ -Create a new file called `client.py` in the same directory as `app.py`. The client will ask for your input and send it to the server.: +Create a new file called `client.py` in the same directory as `app.py`. +The client will ask for your input and send it to the server.: .. literalinclude:: ../../examples/wslook/wslook/client.py @@ -153,7 +165,8 @@ This will simplify testing our server. Now let's add some more functionality to our server. -We've been working with text input/output - let's try sending sending some JSON data. +We've been working with text input/output - let's try sending sending some JSON +data. .. code-block:: python @@ -203,7 +216,8 @@ We've been working with text input/output - let's try sending sending some JSON .. note:: By default, `send_media() `__ and `receive_media() `__ will serialize to (and deserialize from) JSON for a TEXT payload, and to/from MessagePack for a BINARY payload (see also: `Built-in Media Handlers `__). -Lets try to query for data from the server. We'll create a new resource that will return a report based on the query. +Lets try to query for data from the server. We'll create a new resource that +will return a report based on the query. Server side: @@ -287,7 +301,8 @@ Server side: if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8000) -We'll also create new client app (`reports_client.py`), that will connect to the reports endpoint. : +We'll also create new client app (`reports_client.py`), that will connect to +the reports endpoint. : .. code-block:: python @@ -308,15 +323,19 @@ We'll also create new client app (`reports_client.py`), that will connect to the if __name__ == "__main__": asyncio.run(send_message()) -We've added a new resource that will return a report based on the query. The client will send a query to the server, and the server will respond with the report. +We've added a new resource that will return a report based on the query. +The client will send a query to the server, and the server will respond with the +report. If it can't find the report, it will respond with an error message. -This is a simple example, but you can easily extend it to include more complex logic like fetching data from a database. +This is a simple example, but you can easily extend it to include more complex +logic like fetching data from a database. Middleware __________ -Falcon supports middleware, which can be used to add functionality to the application. For example, we can add a middleware that prints when a connection is established. +Falcon supports middleware, which can be used to add functionality to the application. +For example, we can add a middleware that prints when a connection is established. .. code-block:: python @@ -412,14 +431,17 @@ Falcon supports middleware, which can be used to add functionality to the applic if __name__ == '__main__': uvicorn.run(app, host='localhost', port=8000) -Now, when you run the server, you should see a message in the console when a WebSocket connection is established. +Now, when you run the server, you should see a message in the console when a +WebSocket connection is established. Authentication ______________ -Adding authentication can be done with the help of middleware as well. It checks the request headers for a token. -If the token is valid, the request is allowed to continue. If the token is invalid, the request is rejected. +Adding authentication can be done with the help of middleware as well. +It checks the request headers for a token. +If the token is valid, the request is allowed to continue. +If the token is invalid, the request is rejected. There are some `considerations `__ to take into account when implementing authentication in a WebSocket server. @@ -431,7 +453,8 @@ Updated client code for the reports client: .. literalinclude:: ../../examples/wslook/wslook/reports_client.py -If you try to query the reports endpoint now, everything works as expected. But as soon as you remove/modify the token, the connection will be closed. +If you try to query the reports endpoint now, everything works as expected. +But as soon as you remove/modify the token, the connection will be closed. .. code-block:: bash @@ -441,11 +464,14 @@ If you try to query the reports endpoint now, everything works as expected. But .. note:: - This is a simple example of how to add authentication to a WebSocket server. In a real-world application, you would want to use a more secure method of authentication, such as JWT tokens. + This is a simple example of how to add authentication to a WebSocket server. + In a real-world application, you would want to use a more secure method of + authentication, such as JWT tokens. What Now ________ -This tutorial is just the beginning. You can extend the server with more complex logic. For example, you could add a database to store/retrieve the reports, or add more routes to the server. +This tutorial is just the beginning. You can extend the server with more complex logic. +For example, you could add a database to store/retrieve the reports, or add more routes to the server. For more information on websockets in Falcon, check out the `WebSocket API `__. From be8c3e75522f5a1e7b7cc8b1735d0fc56131cdec Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sun, 14 Jul 2024 07:46:55 +0200 Subject: [PATCH 04/14] docs(ws-tutorial): change authentication method for websockets Authentication is now done via the first-message method instead of headers. #2240 --- docs/user/tutorial-websockets.rst | 23 +++++++++++++------ examples/wslook/wslook/app.py | 28 +++++++++--------------- examples/wslook/wslook/reports_client.py | 6 +++-- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index 4eaa50123..d20e96c30 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -439,11 +439,11 @@ Authentication ______________ Adding authentication can be done with the help of middleware as well. -It checks the request headers for a token. -If the token is valid, the request is allowed to continue. -If the token is invalid, the request is rejected. +Authentication can be done a few ways. In this example we'll use the +**First message** method, as described on the `websockets documentation `__. -There are some `considerations `__ to take into account when implementing authentication in a WebSocket server. +There are some `considerations `__ +to take into account when implementing authentication in a WebSocket server. Updated server code: @@ -453,14 +453,23 @@ Updated client code for the reports client: .. literalinclude:: ../../examples/wslook/wslook/reports_client.py -If you try to query the reports endpoint now, everything works as expected. -But as soon as you remove/modify the token, the connection will be closed. +Things we've changed: + +- Added a new middleware class `AuthMiddleware` that will check the token on the first message. +- Opening a websocket connection is now handled by the middleware. +- The client now sends a token as the first message, if required for that route. + +If you try to query the reports endpoint now, everything works as expected on an +authenticated route. +But as soon as you remove/modify the token, the connection will be closed +(after sending the first query - a `downside `__ +of first-message authentication). .. code-block:: bash $ python reports_client.py [...] - websockets.exceptions.InvalidStatusCode: server rejected WebSocket connection: HTTP 403 + websockets.exceptions.ConnectionClosedError: received 1008 (policy violation); then sent 1008 (policy violation) .. note:: diff --git a/examples/wslook/wslook/app.py b/examples/wslook/wslook/app.py index c78b84a9f..56c75a18e 100644 --- a/examples/wslook/wslook/app.py +++ b/examples/wslook/wslook/app.py @@ -1,7 +1,8 @@ from datetime import datetime -import falcon.asgi import uvicorn + +import falcon.asgi from falcon import WebSocketDisconnected from falcon.asgi import Request, WebSocket @@ -40,10 +41,7 @@ async def process_resource_ws(self, req: Request, ws: WebSocket, resource, param print(f'WebSocket connection established on {req.path}') -# Added an authentication middleware. This middleware will check if the request is on a protected route. class AuthMiddleware: - protected_routes = [] - def __init__(self, protected_routes: list[str] | None = None): if protected_routes is None: protected_routes = [] @@ -51,15 +49,21 @@ def __init__(self, protected_routes: list[str] | None = None): self.protected_routes = protected_routes async def process_request_ws(self, req: Request, ws: WebSocket): + # Opening a connection so we can receive the token + await ws.accept() + + # Check if the route is protected if req.path not in self.protected_routes: return - token = req.get_header('Authorization') + token = await ws.receive_text() + if token != 'very secure token': await ws.close(1008) return - print(f'Client with token {token} Authenticated') + # Never log tokens in production + print(f'Client with token "{token}" Authenticated') class HelloWorldResource: @@ -69,11 +73,6 @@ async def on_get(self, req, resp): class EchoWebSocketResource: async def on_websocket(self, req: Request, ws: WebSocket): - try: - await ws.accept() - except WebSocketDisconnected: - return - while True: try: message = await ws.receive_text() @@ -86,11 +85,6 @@ async def on_websocket(self, req: Request, ws: WebSocket): class ReportsResource: async def on_websocket(self, req: Request, ws: WebSocket): - try: - await ws.accept() - except WebSocketDisconnected: - return - while True: try: query = await ws.receive_text() @@ -111,8 +105,6 @@ async def on_websocket(self, req: Request, ws: WebSocket): app.add_route('/reports', ReportsResource()) app.add_middleware(LoggerMiddleware()) - -# Add the AuthMiddleware to the app, and specify the protected routes app.add_middleware(AuthMiddleware(['/reports'])) if __name__ == '__main__': diff --git a/examples/wslook/wslook/reports_client.py b/examples/wslook/wslook/reports_client.py index b98b30b5d..666483675 100644 --- a/examples/wslook/wslook/reports_client.py +++ b/examples/wslook/wslook/reports_client.py @@ -4,9 +4,11 @@ async def send_message(): uri = 'ws://localhost:8000/reports' - headers = {'Authorization': 'very secure token'} - async with websockets.connect(uri, extra_headers=headers) as websocket: + async with websockets.connect(uri) as websocket: + # Send the autentication token + await websocket.send('very secure token!') + while True: message = input('Name of the log: ') await websocket.send(message) From dc7f3d3021941944502720e0e30a5e956627850b Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sun, 14 Jul 2024 10:37:36 +0200 Subject: [PATCH 05/14] docs(ws-tutorial): typo fix Typo fix. #2240 --- examples/wslook/wslook/reports_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/wslook/wslook/reports_client.py b/examples/wslook/wslook/reports_client.py index 666483675..92b8ddd52 100644 --- a/examples/wslook/wslook/reports_client.py +++ b/examples/wslook/wslook/reports_client.py @@ -6,7 +6,7 @@ async def send_message(): uri = 'ws://localhost:8000/reports' async with websockets.connect(uri) as websocket: - # Send the autentication token + # Send the authentication token await websocket.send('very secure token!') while True: From 337b065850a617f5b68b121346e756776d17f39b Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sun, 14 Jul 2024 10:58:38 +0200 Subject: [PATCH 06/14] docs(ws-tutorial): Do not generate coverage for the run command Do not generate coverage for the run command as that's only used when locally running the code. #2240 --- examples/wslook/wslook/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/wslook/wslook/app.py b/examples/wslook/wslook/app.py index 56c75a18e..6a95f4caf 100644 --- a/examples/wslook/wslook/app.py +++ b/examples/wslook/wslook/app.py @@ -108,4 +108,4 @@ async def on_websocket(self, req: Request, ws: WebSocket): app.add_middleware(AuthMiddleware(['/reports'])) if __name__ == '__main__': - uvicorn.run(app, host='localhost', port=8000) + uvicorn.run(app, host='localhost', port=8000) # pragma: no cover From 1ec1e563bee3d473551d55e53de855fb5dfbd37c Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sun, 14 Jul 2024 10:59:13 +0200 Subject: [PATCH 07/14] docs(ws-tutorial): add explanation Add an explanation why the code isn't tested. #2240 --- examples/wslook/wslook/client.py | 5 +++++ examples/wslook/wslook/reports_client.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/examples/wslook/wslook/client.py b/examples/wslook/wslook/client.py index e49b71391..a2edd2e66 100644 --- a/examples/wslook/wslook/client.py +++ b/examples/wslook/wslook/client.py @@ -1,4 +1,9 @@ +# This is a simple example of a WebSocket client that sends a message to the server +# Since it's an example using the `websockets` library and it isn't using anything specific to Falcon, +# there are no tests. Coverage is skipped for this module. + import asyncio + import websockets diff --git a/examples/wslook/wslook/reports_client.py b/examples/wslook/wslook/reports_client.py index 92b8ddd52..a0a56b911 100644 --- a/examples/wslook/wslook/reports_client.py +++ b/examples/wslook/wslook/reports_client.py @@ -1,4 +1,9 @@ +# This is a simple example of a WebSocket client that sends a message to the server +# Since it's an example using the `websockets` library and it isn't using anything specific to Falcon, +# there are no tests. Coverage is skipped for this module. + import asyncio + import websockets From 6863c1275f211e80e0c37a653f76d0f2de988bde Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Sun, 14 Jul 2024 10:59:34 +0200 Subject: [PATCH 08/14] docs(ws-tutorial): add tests for the example code Add tests for the example code. #2240 --- .../{requirements.txt => requirements/app} | 1 - examples/wslook/requirements/test | 3 + examples/wslook/tests/__init__.py | 0 examples/wslook/tests/conftest.py | 9 +++ examples/wslook/tests/test_asgi_http.py | 10 ++++ examples/wslook/tests/test_asgi_ws.py | 60 +++++++++++++++++++ tox.ini | 17 ++++++ 7 files changed, 99 insertions(+), 1 deletion(-) rename examples/wslook/{requirements.txt => requirements/app} (57%) create mode 100644 examples/wslook/requirements/test create mode 100644 examples/wslook/tests/__init__.py create mode 100644 examples/wslook/tests/conftest.py create mode 100644 examples/wslook/tests/test_asgi_http.py create mode 100644 examples/wslook/tests/test_asgi_ws.py diff --git a/examples/wslook/requirements.txt b/examples/wslook/requirements/app similarity index 57% rename from examples/wslook/requirements.txt rename to examples/wslook/requirements/app index c3d7aa1ef..50ad128a5 100644 --- a/examples/wslook/requirements.txt +++ b/examples/wslook/requirements/app @@ -1,3 +1,2 @@ falcon uvicorn -websockets diff --git a/examples/wslook/requirements/test b/examples/wslook/requirements/test new file mode 100644 index 000000000..e01a82068 --- /dev/null +++ b/examples/wslook/requirements/test @@ -0,0 +1,3 @@ +pytest +pytest-cov +pytest-asyncio diff --git a/examples/wslook/tests/__init__.py b/examples/wslook/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/wslook/tests/conftest.py b/examples/wslook/tests/conftest.py new file mode 100644 index 000000000..9195dd208 --- /dev/null +++ b/examples/wslook/tests/conftest.py @@ -0,0 +1,9 @@ +import pytest +from wslook.app import app + +import falcon.testing + + +@pytest.fixture() +def client(): + return falcon.testing.TestClient(app) diff --git a/examples/wslook/tests/test_asgi_http.py b/examples/wslook/tests/test_asgi_http.py new file mode 100644 index 000000000..e45b87768 --- /dev/null +++ b/examples/wslook/tests/test_asgi_http.py @@ -0,0 +1,10 @@ +def test_hello_http_call(client): + response = client.simulate_get('/hello') + assert response.status_code == 200 + data = response.json + assert data == {'hello': 'world'} + + +def test_missing_endpoint(client): + response = client.simulate_get('/missing') + assert response.status_code == 404 diff --git a/examples/wslook/tests/test_asgi_ws.py b/examples/wslook/tests/test_asgi_ws.py new file mode 100644 index 000000000..f73d43211 --- /dev/null +++ b/examples/wslook/tests/test_asgi_ws.py @@ -0,0 +1,60 @@ +from copy import copy + +import pytest +from falcon import testing +from falcon import errors +from wslook.app import app, AuthMiddleware + + +@pytest.mark.asyncio +async def test_websocket_echo(): + async with testing.ASGIConductor(app) as conn: + async with conn.simulate_ws('/echo') as ws: + await ws.send_text('Hello, World!') + response = await ws.receive_json() + + assert response['message'] == 'Hello, World!' + + +@pytest.mark.asyncio +async def test_resetting_auth_middleware(): + local_app = copy(app) + local_app._middleware = None + local_app.add_middleware(AuthMiddleware()) + + async with testing.ASGIConductor(local_app) as conn: + async with conn.simulate_ws('/reports') as ws: + with pytest.raises(errors.WebSocketDisconnected): + await ws.send_text('report1') + await ws.receive_json() + + +@pytest.mark.asyncio +async def test_websocket_reports(): + async with testing.ASGIConductor(app) as conn: + async with conn.simulate_ws('/reports') as ws: + await ws.send_text('very secure token') + await ws.send_text('report1') + response = await ws.receive_json() + + assert response['report'] == 'Report 1' + + +@pytest.mark.asyncio +async def test_websocket_report_not_found(): + async with testing.ASGIConductor(app) as conn: + async with conn.simulate_ws('/reports') as ws: + await ws.send_text('very secure token') + await ws.send_text('report10') + response = await ws.receive_json() + + assert response['error'] == 'report not found' + + +@pytest.mark.asyncio +async def test_websocket_not_authenticated(): + async with testing.ASGIConductor(app) as conn: + async with conn.simulate_ws('/reports') as ws: + with pytest.raises(errors.WebSocketDisconnected): + await ws.send_text('report1') + await ws.receive_json() diff --git a/tox.ini b/tox.ini index e6ca31342..dcb0df1ed 100644 --- a/tox.ini +++ b/tox.ini @@ -457,6 +457,23 @@ commands = --cov-report term-missing \ {toxinidir}/examples/asgilook/tests/ +# -------------------------------------------------------------------- +# WebSockets tutorial ("wslook") tests +# -------------------------------------------------------------------- + +[testenv:wslook] +basepython = python3.12 +deps = + -r{toxinidir}/examples/wslook/requirements/app + -r{toxinidir}/examples/wslook/requirements/test +commands = + pytest \ + --cov wslook \ + --cov-config {toxinidir}/examples/wslook/.coveragerc \ + --cov-fail-under 100 \ + --cov-report term-missing \ + {toxinidir}/examples/wslook/tests/ + # -------------------------------------------------------------------- # Ecosystem # -------------------------------------------------------------------- From 1c25f3d880ddfd1407c9fae4416c0a61dcaa9966 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 24 Jul 2024 10:21:25 +0200 Subject: [PATCH 09/14] chore(wslook): shorten comment lines in `client.py` --- examples/wslook/wslook/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/wslook/wslook/client.py b/examples/wslook/wslook/client.py index a2edd2e66..2bdf621e1 100644 --- a/examples/wslook/wslook/client.py +++ b/examples/wslook/wslook/client.py @@ -1,6 +1,6 @@ -# This is a simple example of a WebSocket client that sends a message to the server -# Since it's an example using the `websockets` library and it isn't using anything specific to Falcon, -# there are no tests. Coverage is skipped for this module. +# This is a simple example of a WebSocket client that sends a message to the server. +# Since it's an example using the `websockets` library, and it isn't using anything specific +# to Falcon, there are no tests. Coverage is skipped for this module. import asyncio From 3e712c960dc180e4d5ff36c9acb110fae9b9ca25 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Wed, 24 Jul 2024 10:36:01 +0200 Subject: [PATCH 10/14] style(wslook): shorten lines in `reports_client.py` --- examples/wslook/wslook/reports_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/wslook/wslook/reports_client.py b/examples/wslook/wslook/reports_client.py index a0a56b911..6c265ec79 100644 --- a/examples/wslook/wslook/reports_client.py +++ b/examples/wslook/wslook/reports_client.py @@ -1,6 +1,6 @@ -# This is a simple example of a WebSocket client that sends a message to the server -# Since it's an example using the `websockets` library and it isn't using anything specific to Falcon, -# there are no tests. Coverage is skipped for this module. +# This is a simple example of a WebSocket client that sends a message to the server. +# Since it's an example using the `websockets` library and it isn't using anything specific +# to Falcon, there are no tests. Coverage is skipped for this module. import asyncio From b8709313a48831ed875ae70d73b75f8d2f5ff101 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 25 Jul 2024 10:42:26 +0200 Subject: [PATCH 11/14] docs(tutorial-ws): sand some rough edges, fix CI --- docs/user/tutorial-websockets.rst | 29 ++++++++++++++---------- examples/wslook/tests/test_asgi_ws.py | 6 +++-- examples/wslook/wslook/app.py | 5 ++-- examples/wslook/wslook/client.py | 4 ++-- examples/wslook/wslook/reports_client.py | 4 ++-- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index d20e96c30..cc6c3886e 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -13,8 +13,9 @@ logging messages. This tutorial covers the asynchronous flavor of Falcon using the `ASGI `__ protocol. - A Falcon WebSocket server is build upon the `ASGI WebSocket specification `__. Therefore it's not supported in a Falcon WSGI application. - + A Falcon WebSocket server builds upon the + `ASGI WebSocket specification `__. + Therefore it's not supported in a Falcon WSGI application. First Steps ___________ @@ -132,8 +133,8 @@ this:: or $ wsproto -To test the new WebSocket route, we can use the `websocat `__ -tool:: +To test the new WebSocket route, we can use the +`websocat `__ tool:: $ websocat ws://localhost:8000/echo $ hello @@ -148,7 +149,7 @@ server. Simple Client _____________ -Create a new file called `client.py` in the same directory as `app.py`. +Create a new file called ``client.py`` in the same directory as ``app.py``. The client will ask for your input and send it to the server.: .. literalinclude:: ../../examples/wslook/wslook/client.py @@ -212,7 +213,6 @@ data. $ Enter a message: Hi {"message": "Hi", "date": "2024-07-13T12:11:51.758923"} - .. note:: By default, `send_media() `__ and `receive_media() `__ will serialize to (and deserialize from) JSON for a TEXT payload, and to/from MessagePack for a BINARY payload (see also: `Built-in Media Handlers `__). @@ -440,9 +440,11 @@ ______________ Adding authentication can be done with the help of middleware as well. Authentication can be done a few ways. In this example we'll use the -**First message** method, as described on the `websockets documentation `__. +**First message** method, as described on the +`websockets documentation `__. -There are some `considerations `__ +There are some +`considerations `__ to take into account when implementing authentication in a WebSocket server. Updated server code: @@ -462,7 +464,8 @@ Things we've changed: If you try to query the reports endpoint now, everything works as expected on an authenticated route. But as soon as you remove/modify the token, the connection will be closed -(after sending the first query - a `downside `__ +(after sending the first query - a +`downside `__ of first-message authentication). .. code-block:: bash @@ -480,7 +483,9 @@ of first-message authentication). What Now ________ -This tutorial is just the beginning. You can extend the server with more complex logic. -For example, you could add a database to store/retrieve the reports, or add more routes to the server. +This tutorial is just the beginning. You can extend the server with more +complex logic. For example, you could add a database to store/retrieve the +reports, or add more routes to the server. -For more information on websockets in Falcon, check out the `WebSocket API `__. +For more information on websockets in Falcon, check out the +`WebSocket API `__. diff --git a/examples/wslook/tests/test_asgi_ws.py b/examples/wslook/tests/test_asgi_ws.py index f73d43211..e1827642c 100644 --- a/examples/wslook/tests/test_asgi_ws.py +++ b/examples/wslook/tests/test_asgi_ws.py @@ -1,9 +1,11 @@ from copy import copy import pytest -from falcon import testing +from wslook.app import app +from wslook.app import AuthMiddleware + from falcon import errors -from wslook.app import app, AuthMiddleware +from falcon import testing @pytest.mark.asyncio diff --git a/examples/wslook/wslook/app.py b/examples/wslook/wslook/app.py index 6a95f4caf..7e4d4248c 100644 --- a/examples/wslook/wslook/app.py +++ b/examples/wslook/wslook/app.py @@ -2,9 +2,10 @@ import uvicorn -import falcon.asgi from falcon import WebSocketDisconnected -from falcon.asgi import Request, WebSocket +import falcon.asgi +from falcon.asgi import Request +from falcon.asgi import WebSocket REPORTS = { 'report1': { diff --git a/examples/wslook/wslook/client.py b/examples/wslook/wslook/client.py index 2bdf621e1..877a8e9e7 100644 --- a/examples/wslook/wslook/client.py +++ b/examples/wslook/wslook/client.py @@ -1,6 +1,6 @@ # This is a simple example of a WebSocket client that sends a message to the server. -# Since it's an example using the `websockets` library, and it isn't using anything specific -# to Falcon, there are no tests. Coverage is skipped for this module. +# Since it's an example using the `websockets` library, and it isn't using anything +# specific to Falcon, there are no tests. Coverage is skipped for this module. import asyncio diff --git a/examples/wslook/wslook/reports_client.py b/examples/wslook/wslook/reports_client.py index 6c265ec79..d9b851066 100644 --- a/examples/wslook/wslook/reports_client.py +++ b/examples/wslook/wslook/reports_client.py @@ -1,6 +1,6 @@ # This is a simple example of a WebSocket client that sends a message to the server. -# Since it's an example using the `websockets` library and it isn't using anything specific -# to Falcon, there are no tests. Coverage is skipped for this module. +# Since it's an example using the `websockets` library and it isn't using anything +# specific to Falcon, there are no tests. Coverage is skipped for this module. import asyncio From f88a803de006c5cca37b265227e464119bbb511f Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 25 Jul 2024 10:54:16 +0200 Subject: [PATCH 12/14] docs(WS): add more interlinks to the tutorial --- docs/api/websocket.rst | 4 ++++ docs/user/tutorial-asgi.rst | 4 ++++ docs/user/tutorial-websockets.rst | 4 ++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/api/websocket.rst b/docs/api/websocket.rst index 226c35123..0f35addfb 100644 --- a/docs/api/websocket.rst +++ b/docs/api/websocket.rst @@ -424,6 +424,10 @@ by the framework. app = falcon.asgi.App(middleware=SomeMiddleware()) app.add_route('/{account_id}/messages', SomeResource()) +.. tip:: + If you prefer to learn by doing, feel free to continue experimenting along + the lines of our :ref:`WebSocket tutorial `! + Testing ------- diff --git a/docs/user/tutorial-asgi.rst b/docs/user/tutorial-asgi.rst index cce31c72f..bbfd1ab2c 100644 --- a/docs/user/tutorial-asgi.rst +++ b/docs/user/tutorial-asgi.rst @@ -1033,6 +1033,10 @@ numerous ways: :ref:`WebSockets `. * ...And much more (patches welcome, as they say)! +.. tip:: + If you want to add :ref:`WebSocket ` support, please check out our + :ref:`WebSocket tutorial ` too! + Compared to the sync version, asynchronous code can at times be harder to design and reason about. Should you run into any issues, our friendly community is available to answer your questions and help you work through any sticky diff --git a/docs/user/tutorial-websockets.rst b/docs/user/tutorial-websockets.rst index cc6c3886e..ec36f8c6d 100644 --- a/docs/user/tutorial-websockets.rst +++ b/docs/user/tutorial-websockets.rst @@ -1,4 +1,4 @@ -.. _tutorial-websockets: +.. _tutorial-ws: Tutorial (WebSockets) ===================== @@ -21,7 +21,7 @@ First Steps ___________ We'll start with a clean working directory and create a new virtual environment -using the `venv` module.: +using the :mod:`venv` module: .. code-block:: bash From 284636ce833f3ec178782271769af29eafd02de6 Mon Sep 17 00:00:00 2001 From: Vytautas Liuolia Date: Thu, 25 Jul 2024 10:55:50 +0200 Subject: [PATCH 13/14] chore: run tox -e wslook in CI --- .github/workflows/tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 30556b9d2..f2ce9cf3d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -35,6 +35,7 @@ jobs: - "towncrier" - "look" - "asgilook" + - "wslook" - "check_vendored" - "twine_check" - "daphne" From 4dcb8a6f00c4d14c4963ccf703ec42fb3ae51dca Mon Sep 17 00:00:00 2001 From: Derk Weijers Date: Thu, 25 Jul 2024 12:11:48 +0200 Subject: [PATCH 14/14] docs(ws-tutorial): add coveragerc to the repo --- examples/wslook/.coveragerc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 examples/wslook/.coveragerc diff --git a/examples/wslook/.coveragerc b/examples/wslook/.coveragerc new file mode 100644 index 000000000..60592de6e --- /dev/null +++ b/examples/wslook/.coveragerc @@ -0,0 +1,4 @@ +[run] +omit = + examples/wslook/wslook/client.py + examples/wslook/wslook/reports_client.py