diff --git a/pages/blog/socketio-automatic-docs.md b/pages/blog/socketio-automatic-docs.md new file mode 100644 index 000000000000..0cdab23d6d35 --- /dev/null +++ b/pages/blog/socketio-automatic-docs.md @@ -0,0 +1,555 @@ +--- +title: Flask-SocketIO Automated Documentation and Validation +date: 2023-03-02T06:00:00+01:00 +featured: true +type: Engineering +tags: ['Python','SocketIO','Flask-SocketIO'] +cover: /img/posts/socketio-automatic-docs/cover.webp +authors: + - name: Daler Rahimov + photo: /img/avatars/dalerrahimov.webp + link: https://github.com/daler-rahimov/ + byline: Python Engineer at Keyme LLC +--- + +## What is Flask-SocketIO, SocketIO? +If you are reading this blog, you are probably already familiar with SocketIO [python-socketio](https://github.com/miguelgrinberg/python-socketio) and [Flask-SocketIO](https://flask-socketio.readthedocs.io/en/latest/). If you want to learn more about them, please check the following resources: + - https://socket.io/docs/v4/ + - https://www.asyncapi.com/blog/socketio-part1 + - https://www.asyncapi.com/blog/socketio-part2 + +## The problem +Imagine that you are working on a large project that uses a Flask-SocketIO server to handle real-time communication between the client and the server. The server was originally well-documented, but over time the documentation has become out of date as the server has evolved and new features have been added. + +I found myself in the same situation. I needed to maintain and constantly add more documentation to a Flask-SocketIO server. To make this process more efficient, I sought a solution to automate documentation generation from the existing codebase. This would eliminate the need for team members to manually edit the AsyncAPI specification file every time there was a change, a challenging task for those unfamiliar with AsyncAPI. By automating this process, we could save time and reduce the workload for the team. + +To address this issue, I decided to implement SIO-AsyncAPI. This tool allows you to generate an AsyncAPI specification from your SocketIO server and validate incoming and outgoing messages against it. This functionality is similar to how FastAPI, Flask-RESTful, and other frameworks have long provided it for RESTful servers. Now, with [SIO-AsyncAPI](https://github.com/daler-rahimov/sio-asyncapi), it is possible to apply this approach to SocketIO servers as well. + +## How to use SIO-AsyncAPI +Instead of giving you a detailed, step-by-step guide, we'll use a more exciting approach. We'll take the existing Flask-SocketIO server and add SIO-AsyncAPI. To make things even more interesting, we'll ask [ChatGPT](https://chat.openai.com/chat) to generate a server for us and use it as a real-world Flask-SocketIO server. + +### Generate a Flask-SocketIO server +I had to ask ChatGPT for multiple alterations to get the desired result. Here are my queries to ChatGPT: + +>- create python flask-socket server that provides tic tac toe game api +>- change previous code and add type hints +>- for previous example use pydantic models instead of dictionaries + +And here is the final code that ChatGPT generated for us: + +```python +from typing import List, Union + +from flask import Flask +from flask_socketio import SocketIO, emit +from pydantic import BaseModel + +app = Flask(__name__) +socketio = SocketIO(app) + +games: dict[int, 'Game'] = {} + +class Game(BaseModel): + board: List[List[str]] + turn: str + +class MakeMoveData(BaseModel): + game_id: int + x: int + y: int + +@socketio.on('create_game') +def create_game() -> None: + # Create a new game and add it to the list of games + game_id = len(games) + 1 + games[game_id] = Game(board=[['' for _ in range(3)] for _ in range(3)], turn='X') + + # Send the game id to the client + emit('game_created', {'game_id': game_id}) + +@socketio.on('make_move') +def make_move(data: MakeMoveData) -> None: + # Get the game and make the move + game = games[data.game_id] + board = game.board + turn = game.turn + board[data.x][data.y] = turn + + # Check for a win or draw + result = check_game_status(board) + + # Update the game state and send it to the client + if result == 'X': + emit('game_won', {'winner': 'X'}) + elif result == 'O': + emit('game_won', {'winner': 'O'}) + elif result == 'draw': + emit('game_drawn', {}) + else: + games[data.game_id] = Game(board=board, turn='O' if turn == 'X' else 'X') + emit('move_made', {'board': board, 'turn': game.turn}) + +def check_game_status(board: List[List[str]]) -> Union[str, None]: + # Check for a win or draw + for i in range(3): + if board[i] == ['X', 'X', 'X']: + return 'X' + if board[i] == ['O', 'O', 'O']: + return 'O' + if all(board[i][i] == 'X' for i in range(3)): + return 'X' + if all(board[i][i] == 'O' for i in range(3)): + return 'O' + if all(board[i][j] != '' for i in range(3) for j in range(3)): + return 'draw' + return None + + +if __name__ == '__main__': + socketio.run(app, debug=True) + +``` + +It's not quite correct (e.g. data in `make_move` will be passed as a dictionary, not a Pydantic model), but it's good enough for our purposes. + +### Generate Mermaid diagram +Now let's ask ChatGPT to generate a Mermaid diagram for us as well, so we can get a better illustration of our server: + +>- create mermaid diagram for previous example + +I had to alter the given diagram a bit, but here is the final result: + +```mermaid +sequenceDiagram + participant Client + participant SocketIOServer + + Client ->> SocketIOServer: create_game + SocketIOServer -->> Client: game_created(game_id) + + Client ->> SocketIOServer: make_move(game_id, x, y) + alt if_not_won_or_drawn + SocketIOServer -->> Client: move_made(board, turn) + end + + alt if_won + SocketIOServer -->> Client: game_won(winner) + end + + alt if_drawn + SocketIOServer -->> Client: game_drawn + end +``` + + +### Add SIO-AsyncAPI to the server + +Now let's imagine that this is our old server written in Flask-SocketIO and we want to add SIO-AsyncAPI. Here is how we would do it: + +1. Install SIO-AsyncAPI via `pip install sio-asyncapi` +2. Change import statements +```python +# instead of `from flask_socketio import SocketIO` +from sio_asyncapi import AsyncAPISocketIO as SocketIO +``` +3. Add an additional argument to the `AsyncAPISocketIO` constructor +```python +socketio = SocketIO( + app, + validate=True, + generate_docs=True, + version="1.0.0", + title="Tic-Tac-Toe API", + description="Tic-Tac-Toe Game API", + server_url="http://localhost:5000", + server_name="TIC_TAC_TOE_BACKEND", +) +``` +5. Tell the `@socketio.on` decorator to get models from the type hint. +> Note: you can also pass `request_model` and `response_model` arguments to the `@socketio.on` decorator instead of using type hints. +```python +@socketio.on('make_move', get_from_typehint=True) +``` +Now type annotations will be used to generate the AsyncAPI specification and validate incoming/outgoing messages. Note that the return value from a function is not data sent by the `emit` function but rather the `acknowledge` value that the client receives. + +6. Add an `on_emit` decorator to register/document a SocketIO emit event. Since we are not defining `emit` function ourselves but only calling it, we need to tell SIO-AsyncAPI what to expect when `emit` is called. E.g +```python +class GameCreatedData(BaseModel): + game_id: int + +@socketio.doc_emit('game_created', GameCreatedData) +@socketio.on('create_game') +def create_game(): + ... + emit('game_created', {'game_id': game_id}) +``` + +### Get AsyncAPI specification +Now we can get the AsyncAPI specification by calling the `socketio.asyncapi_doc.get_yaml()` function. Here is what the rendered specification looks like: + +![Figure 1:](/img/posts/socketio-automatic-docs/sio-asycnapi-pic1.webp) + +### Validation and Error handling +SIO-AsyncAPI will automatically validate incoming and outgoing messages. If a message is invalid, it will raise one of these 3 exceptions: `EmitValidationError`, `RequestValidationError`, or `ResponseValidationError`. + +Flask-SocketIO has the `@socketio.on_error_default` decorator for default error handling that we can use. E.g.: +```python +@socketio.on_error_default +def default_error_handler(e: Exception): + """ + Default error handler. It is called if no other error handler is defined. + Handles RequestValidationError, EmitValidationError, and ResponseValidationError errors. + """ + if isinstance(e, RequestValidationError): + logger.error(f"Request validation error: {e}") + return {'error': str(e)} + elif isinstance(e, ResponseValidationError): + logger.critical(f"Response validation error: {e}") + raise e + if isinstance(e, EmitValidationError): + logger.critical(f"Emit validation error: {e}") + raise e + else: + logger.critical(f"Unknown error: {e}") + raise e +``` +Instead of re-raising exceptions, we can return some error interpreted as an `acknowledge` value sent to the client. That's what we do in the example above when there is a `RequestValidationError`. + +This is how it looks like in FireCamp if we do not provide `game_id` in the `make_move` request: + +![Figure 2:](/img/posts/socketio-automatic-docs/sio-asycnapi-pic2.webp) + +Because the `make_move` request may return an error in the acknowledged value now, we should add a new `MakeMoveAckData` model and annotate the `make_move` function accordingly. This will automatically update the documentation in our AsyncAPI specification. +```python +class MakeMoveAckData(BaseModel): + error: Optional[str] = Field(None, description='The error message', example='Invalid move') + +... +@socketio.on('make_move', get_from_typehint=True) +def make_move(data: MakeMoveData) -> MakeMoveAckData: + ... +``` + +### Final Code and Specification +Here is the final code of the server. I also added examples to Pydantic models to make the specification more readable. +```python +import pathlib +from typing import List, Union, Optional + +from flask import Flask +from flask_socketio import emit +from pydantic import BaseModel, Field +from loguru import logger + +from sio_asyncapi import AsyncAPISocketIO as SocketIO +from sio_asyncapi import (EmitValidationError, RequestValidationError, + ResponseValidationError) + +app = Flask(__name__) + +socketio = SocketIO( + app, + validate=True, + generate_docs=True, + version="1.0.0", + title="Tic-Tac-Toe API", + description="Tic-Tac-Toe Game API", + server_url="http://localhost:5000", + server_name="TIC_TAC_TOE_BACKEND", +) + +games: dict[int, 'Game'] = {} + + +class Game(BaseModel): + board: List[List[str]] = Field(..., description='The game board', example=[ + ['X', 'O', ''], ['', 'X', ''], ['', '', 'O']]) + turn: str = Field(..., description='The current turn', example='X') + + +class MakeMoveData(BaseModel): + game_id: int = Field(..., description='The game id', example=1) + x: int = Field(..., description='The x coordinate', example=0) + y: int = Field(..., description='The y coordinate', example=0) + + +class GameCreatedData(BaseModel): + game_id: int = Field(..., description='The game id', example=1) + + +class GameWonData(BaseModel): + winner: str = Field(..., description='The winner', example='X') + + +class GameDrawnData(BaseModel): + pass + + +class MoveMadeData(BaseModel): + board: List[List[str]] = Field(..., description='The game board', example=[ + ['X', 'O', ''], ['', 'X', ''], ['', '', 'O']]) + turn: str = Field(..., description='The current turn', example='X') + +class MakeMoveAckData(BaseModel): + error: Optional[str] = Field(None, description='The error message', example='Invalid move') + +@socketio.doc_emit('game_created', GameCreatedData) +@socketio.on('create_game') +def create_game(): + # Create a new game and add it to the list of games + game_id = len(games) + 1 + games[game_id] = Game(board=[['' for _ in range(3)] for _ in range(3)], turn='X') + + # Send the game id to the client + emit('game_created', {'game_id': game_id}) + +@socketio.doc_emit('game_won', GameWonData) +@socketio.doc_emit('game_drawn', GameDrawnData) +@socketio.doc_emit('move_made', MoveMadeData) +@socketio.on('make_move', get_from_typehint=True) +def make_move(data: MakeMoveData) -> MakeMoveAckData: + # Get the game and make the move + logger.info(f'Making move {data}') + game = games[data.game_id] + board = game.board + turn = game.turn + board[data.x][data.y] = turn + + # Check for a win or draw + result = check_game_status(board) + + logger.info(f'Game result: {result}') + # Update the game state and send it to the client + if result == 'X': + emit('game_won', {'winner': 'X'}) + elif result == 'O': + emit('game_won', {'winner': 'O'}) + elif result == 'draw': + emit('game_drawn', {}) + else: + games[data.game_id] = Game(board=board, turn='O' if turn == 'X' else 'X') + emit('move_made', {'board': board, 'turn': game.turn}) + +def check_game_status(board: List[List[str]]) -> Union[str, None]: + # Check for a win or draw + for i in range(3): + if board[i] == ['X', 'X', 'X']: + return 'X' + if board[i] == ['O', 'O', 'O']: + return 'O' + if all(board[i][i] == 'X' for i in range(3)): + return 'X' + if all(board[i][i] == 'O' for i in range(3)): + return 'O' + if all(board[i][j] != '' for i in range(3) for j in range(3)): + return 'draw' + return None + +@socketio.on_error_default +def default_error_handler(e: Exception): + """ + default error handler. it called if no other error handler defined. + handles requestvalidationerror, emitvalidationerror and responsevalidationerror errors. + """ + if isinstance(e, RequestValidationError): + logger.error(f"request validation error: {e}") + return {'error': str(e)} + elif isinstance(e, ResponseValidationError): + logger.critical(f"response validation error: {e}") + raise e + if isinstance(e, EmitValidationError): + logger.critical(f"emit validation error: {e}") + raise e + else: + logger.critical(f"unknown error: {e}") + raise e + +if __name__ == '__main__': + # generate the asyncapi doc + path = pathlib.Path(__file__).parent / "chat_gpt_asyncapi.yaml" + doc_str = socketio.asyncapi_doc.get_yaml() + with open(path, "w") as f: + # doc_str = spec.get_json_str_doc() + f.write(doc_str) + # run the app + socketio.run(app, debug=True) + +``` + +And here is the auto generated specification: +```yaml +asyncapi: 2.5.0 +channels: + /: + publish: + message: + oneOf: + - $ref: '#/components/messages/Create_Game' + - $ref: '#/components/messages/Make_Move' + subscribe: + message: + oneOf: + - $ref: '#/components/messages/game_created' + - $ref: '#/components/messages/move_made' + - $ref: '#/components/messages/game_drawn' + - $ref: '#/components/messages/game_won' + x-handlers: + disconnect: disconnect +components: + messages: + Create_Game: + description: '' + name: create_game + Make_Move: + description: '' + name: make_move + payload: + $ref: '#/components/schemas/MakeMoveData' + deprecated: false + x-ack: + properties: + error: + description: The error message + example: Invalid move + title: Error + type: string + title: MakeMoveAckData + type: object + game_created: + description: '' + name: game_created + payload: + $ref: '#/components/schemas/GameCreatedData' + deprecated: false + game_drawn: + description: '' + name: game_drawn + payload: + $ref: '#/components/schemas/GameDrawnData' + deprecated: false + game_won: + description: '' + name: game_won + payload: + $ref: '#/components/schemas/GameWonData' + deprecated: false + move_made: + description: '' + name: move_made + payload: + $ref: '#/components/schemas/MoveMadeData' + deprecated: false + schemas: + GameCreatedData: + properties: + game_id: + description: The game id + example: 1 + title: Game Id + type: integer + required: + - game_id + title: GameCreatedData + type: object + GameDrawnData: + properties: {} + title: GameDrawnData + type: object + GameWonData: + properties: + winner: + description: The winner + example: X + title: Winner + type: string + required: + - winner + title: GameWonData + type: object + MakeMoveAckData: + properties: + error: + description: The error message + example: Invalid move + title: Error + type: string + title: MakeMoveAckData + type: object + MakeMoveData: + properties: + game_id: + description: The game id + example: 1 + title: Game Id + type: integer + x: + description: The x coordinate + example: 0 + title: X + type: integer + y: + description: The y coordinate + example: 0 + title: Y + type: integer + required: + - game_id + - x + - y + title: MakeMoveData + type: object + MoveMadeData: + properties: + board: + description: The game board + example: + - - X + - O + - '' + - - '' + - X + - '' + - - '' + - '' + - O + items: + items: + type: string + type: array + title: Board + type: array + turn: + description: The current turn + example: X + title: Turn + type: string + required: + - board + - turn + title: MoveMadeData + type: object + NoSpec: + deprecated: false + description: Specification is not provided +info: + description: 'Tic-Tac-Toe Game API + +
AsyncAPI currently does not support Socket.IO binding and Web Socket like + syntax used for now. + + In order to add support for Socket.IO ACK value, AsyncAPI is extended with with + x-ack keyword. + + This documentation should **NOT** be used for generating code due to these limitations. + + ' + title: Tic-Tac-Toe API + version: 1.0.0 +servers: + TIC_TAC_TOE_BACKEND: + protocol: socketio + url: http://localhost:5000 +``` + + +> Cover image by Windmills During Dawn from Unsplash diff --git a/public/img/avatars/dalerrahimov.webp b/public/img/avatars/dalerrahimov.webp new file mode 100644 index 000000000000..71d78a29bd47 Binary files /dev/null and b/public/img/avatars/dalerrahimov.webp differ diff --git a/public/img/posts/socketio-automatic-docs/cover.webp b/public/img/posts/socketio-automatic-docs/cover.webp new file mode 100755 index 000000000000..5edd8fe645af Binary files /dev/null and b/public/img/posts/socketio-automatic-docs/cover.webp differ diff --git a/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic1.webp b/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic1.webp new file mode 100644 index 000000000000..c57d2bfe1afc Binary files /dev/null and b/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic1.webp differ diff --git a/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic2.webp b/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic2.webp new file mode 100644 index 000000000000..0408d24f7e63 Binary files /dev/null and b/public/img/posts/socketio-automatic-docs/sio-asycnapi-pic2.webp differ