What is Flask-SocketIO, SocketIO?
If you are reading this blog, you are probably already familiar with SocketIO python-socketio and Flask-SocketIO. 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, 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 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:
1from typing import List, Union
2
3from flask import Flask
4from flask_socketio import SocketIO, emit
5from pydantic import BaseModel
6
7app = Flask(__name__)
8socketio = SocketIO(app)
9
10games: dict[int, 'Game'] = {}
11
12class Game(BaseModel):
13 board: List[List[str]]
14 turn: str
15
16class MakeMoveData(BaseModel):
17 game_id: int
18 x: int
19 y: int
20
21@socketio.on('create_game')
22def create_game() -> None:
23 # Create a new game and add it to the list of games
24 game_id = len(games) + 1
25 games[game_id] = Game(board=[['' for _ in range(3)] for _ in range(3)], turn='X')
26
27 # Send the game id to the client
28 emit('game_created', {'game_id': game_id})
29
30@socketio.on('make_move')
31def make_move(data: MakeMoveData) -> None:
32 # Get the game and make the move
33 game = games[data.game_id]
34 board = game.board
35 turn = game.turn
36 board[data.x][data.y] = turn
37
38 # Check for a win or draw
39 result = check_game_status(board)
40
41 # Update the game state and send it to the client
42 if result == 'X':
43 emit('game_won', {'winner': 'X'})
44 elif result == 'O':
45 emit('game_won', {'winner': 'O'})
46 elif result == 'draw':
47 emit('game_drawn', {})
48 else:
49 games[data.game_id] = Game(board=board, turn='O' if turn == 'X' else 'X')
50 emit('move_made', {'board': board, 'turn': game.turn})
51
52def check_game_status(board: List[List[str]]) -> Union[str, None]:
53 # Check for a win or draw
54 for i in range(3):
55 if board[i] == ['X', 'X', 'X']:
56 return 'X'
57 if board[i] == ['O', 'O', 'O']:
58 return 'O'
59 if all(board[i][i] == 'X' for i in range(3)):
60 return 'X'
61 if all(board[i][i] == 'O' for i in range(3)):
62 return 'O'
63 if all(board[i][j] != '' for i in range(3) for j in range(3)):
64 return 'draw'
65 return None
66
67
68if __name__ == '__main__':
69 socketio.run(app, debug=True)
70
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:
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:
- Install SIO-AsyncAPI via
pip install sio-asyncapi
- Change import statements
1# instead of `from flask_socketio import SocketIO`
2from sio_asyncapi import AsyncAPISocketIO as SocketIO
- Add an additional argument to the
AsyncAPISocketIO
constructor
1socketio = SocketIO(
2 app,
3 validate=True,
4 generate_docs=True,
5 version="1.0.0",
6 title="Tic-Tac-Toe API",
7 description="Tic-Tac-Toe Game API",
8 server_url="http://localhost:5000",
9 server_name="TIC_TAC_TOE_BACKEND",
10)
- Tell the
@socketio.on
decorator to get models from the type hint.
Note: you can also pass
request_model
andresponse_model
arguments to the@socketio.on
decorator instead of using type hints.
@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.
- Add an
on_emit
decorator to register/document a SocketIO emit event. Since we are not definingemit
function ourselves but only calling it, we need to tell SIO-AsyncAPI what to expect whenemit
is called. E.g
1class GameCreatedData(BaseModel):
2 game_id: int
3
4@socketio.doc_emit('game_created', GameCreatedData)
5@socketio.on('create_game')
6def create_game():
7 ...
8 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:
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.:
1@socketio.on_error_default
2def default_error_handler(e: Exception):
3 """
4 Default error handler. It is called if no other error handler is defined.
5 Handles RequestValidationError, EmitValidationError, and ResponseValidationError errors.
6 """
7 if isinstance(e, RequestValidationError):
8 logger.error(f"Request validation error: {e}")
9 return {'error': str(e)}
10 elif isinstance(e, ResponseValidationError):
11 logger.critical(f"Response validation error: {e}")
12 raise e
13 if isinstance(e, EmitValidationError):
14 logger.critical(f"Emit validation error: {e}")
15 raise e
16 else:
17 logger.critical(f"Unknown error: {e}")
18 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:
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.
1class MakeMoveAckData(BaseModel):
2 error: Optional[str] = Field(None, description='The error message', example='Invalid move')
3
4...
5@socketio.on('make_move', get_from_typehint=True)
6def make_move(data: MakeMoveData) -> MakeMoveAckData:
7 ...
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.
1import pathlib
2from typing import List, Union, Optional
3
4from flask import Flask
5from flask_socketio import emit
6from pydantic import BaseModel, Field
7from loguru import logger
8
9from sio_asyncapi import AsyncAPISocketIO as SocketIO
10from sio_asyncapi import (EmitValidationError, RequestValidationError,
11 ResponseValidationError)
12
13app = Flask(__name__)
14
15socketio = SocketIO(
16 app,
17 validate=True,
18 generate_docs=True,
19 version="1.0.0",
20 title="Tic-Tac-Toe API",
21 description="Tic-Tac-Toe Game API",
22 server_url="http://localhost:5000",
23 server_name="TIC_TAC_TOE_BACKEND",
24)
25
26games: dict[int, 'Game'] = {}
27
28
29class Game(BaseModel):
30 board: List[List[str]] = Field(..., description='The game board', example=[
31 ['X', 'O', ''], ['', 'X', ''], ['', '', 'O']])
32 turn: str = Field(..., description='The current turn', example='X')
33
34
35class MakeMoveData(BaseModel):
36 game_id: int = Field(..., description='The game id', example=1)
37 x: int = Field(..., description='The x coordinate', example=0)
38 y: int = Field(..., description='The y coordinate', example=0)
39
40
41class GameCreatedData(BaseModel):
42 game_id: int = Field(..., description='The game id', example=1)
43
44
45class GameWonData(BaseModel):
46 winner: str = Field(..., description='The winner', example='X')
47
48
49class GameDrawnData(BaseModel):
50 pass
51
52
53class MoveMadeData(BaseModel):
54 board: List[List[str]] = Field(..., description='The game board', example=[
55 ['X', 'O', ''], ['', 'X', ''], ['', '', 'O']])
56 turn: str = Field(..., description='The current turn', example='X')
57
58class MakeMoveAckData(BaseModel):
59 error: Optional[str] = Field(None, description='The error message', example='Invalid move')
60
61@socketio.doc_emit('game_created', GameCreatedData)
62@socketio.on('create_game')
63def create_game():
64 # Create a new game and add it to the list of games
65 game_id = len(games) + 1
66 games[game_id] = Game(board=[['' for _ in range(3)] for _ in range(3)], turn='X')
67
68 # Send the game id to the client
69 emit('game_created', {'game_id': game_id})
70
71@socketio.doc_emit('game_won', GameWonData)
72@socketio.doc_emit('game_drawn', GameDrawnData)
73@socketio.doc_emit('move_made', MoveMadeData)
74@socketio.on('make_move', get_from_typehint=True)
75def make_move(data: MakeMoveData) -> MakeMoveAckData:
76 # Get the game and make the move
77 logger.info(f'Making move {data}')
78 game = games[data.game_id]
79 board = game.board
80 turn = game.turn
81 board[data.x][data.y] = turn
82
83 # Check for a win or draw
84 result = check_game_status(board)
85
86 logger.info(f'Game result: {result}')
87 # Update the game state and send it to the client
88 if result == 'X':
89 emit('game_won', {'winner': 'X'})
90 elif result == 'O':
91 emit('game_won', {'winner': 'O'})
92 elif result == 'draw':
93 emit('game_drawn', {})
94 else:
95 games[data.game_id] = Game(board=board, turn='O' if turn == 'X' else 'X')
96 emit('move_made', {'board': board, 'turn': game.turn})
97
98def check_game_status(board: List[List[str]]) -> Union[str, None]:
99 # Check for a win or draw
100 for i in range(3):
101 if board[i] == ['X', 'X', 'X']:
102 return 'X'
103 if board[i] == ['O', 'O', 'O']:
104 return 'O'
105 if all(board[i][i] == 'X' for i in range(3)):
106 return 'X'
107 if all(board[i][i] == 'O' for i in range(3)):
108 return 'O'
109 if all(board[i][j] != '' for i in range(3) for j in range(3)):
110 return 'draw'
111 return None
112
113@socketio.on_error_default
114def default_error_handler(e: Exception):
115 """
116 default error handler. it called if no other error handler defined.
117 handles requestvalidationerror, emitvalidationerror and responsevalidationerror errors.
118 """
119 if isinstance(e, RequestValidationError):
120 logger.error(f"request validation error: {e}")
121 return {'error': str(e)}
122 elif isinstance(e, ResponseValidationError):
123 logger.critical(f"response validation error: {e}")
124 raise e
125 if isinstance(e, EmitValidationError):
126 logger.critical(f"emit validation error: {e}")
127 raise e
128 else:
129 logger.critical(f"unknown error: {e}")
130 raise e
131
132if __name__ == '__main__':
133 # generate the asyncapi doc
134 path = pathlib.Path(__file__).parent / "chat_gpt_asyncapi.yaml"
135 doc_str = socketio.asyncapi_doc.get_yaml()
136 with open(path, "w") as f:
137 # doc_str = spec.get_json_str_doc()
138 f.write(doc_str)
139 # run the app
140 socketio.run(app, debug=True)
141
And here is the auto generated specification:
1asyncapi: 2.5.0
2channels:
3 /:
4 publish:
5 message:
6 oneOf:
7 - $ref: '#/components/messages/Create_Game'
8 - $ref: '#/components/messages/Make_Move'
9 subscribe:
10 message:
11 oneOf:
12 - $ref: '#/components/messages/game_created'
13 - $ref: '#/components/messages/move_made'
14 - $ref: '#/components/messages/game_drawn'
15 - $ref: '#/components/messages/game_won'
16 x-handlers:
17 disconnect: disconnect
18components:
19 messages:
20 Create_Game:
21 description: ''
22 name: create_game
23 Make_Move:
24 description: ''
25 name: make_move
26 payload:
27 $ref: '#/components/schemas/MakeMoveData'
28 deprecated: false
29 x-ack:
30 properties:
31 error:
32 description: The error message
33 example: Invalid move
34 title: Error
35 type: string
36 title: MakeMoveAckData
37 type: object
38 game_created:
39 description: ''
40 name: game_created
41 payload:
42 $ref: '#/components/schemas/GameCreatedData'
43 deprecated: false
44 game_drawn:
45 description: ''
46 name: game_drawn
47 payload:
48 $ref: '#/components/schemas/GameDrawnData'
49 deprecated: false
50 game_won:
51 description: ''
52 name: game_won
53 payload:
54 $ref: '#/components/schemas/GameWonData'
55 deprecated: false
56 move_made:
57 description: ''
58 name: move_made
59 payload:
60 $ref: '#/components/schemas/MoveMadeData'
61 deprecated: false
62 schemas:
63 GameCreatedData:
64 properties:
65 game_id:
66 description: The game id
67 example: 1
68 title: Game Id
69 type: integer
70 required:
71 - game_id
72 title: GameCreatedData
73 type: object
74 GameDrawnData:
75 properties: {}
76 title: GameDrawnData
77 type: object
78 GameWonData:
79 properties:
80 winner:
81 description: The winner
82 example: X
83 title: Winner
84 type: string
85 required:
86 - winner
87 title: GameWonData
88 type: object
89 MakeMoveAckData:
90 properties:
91 error:
92 description: The error message
93 example: Invalid move
94 title: Error
95 type: string
96 title: MakeMoveAckData
97 type: object
98 MakeMoveData:
99 properties:
100 game_id:
101 description: The game id
102 example: 1
103 title: Game Id
104 type: integer
105 x:
106 description: The x coordinate
107 example: 0
108 title: X
109 type: integer
110 y:
111 description: The y coordinate
112 example: 0
113 title: Y
114 type: integer
115 required:
116 - game_id
117 - x
118 - y
119 title: MakeMoveData
120 type: object
121 MoveMadeData:
122 properties:
123 board:
124 description: The game board
125 example:
126 - - X
127 - O
128 - ''
129 - - ''
130 - X
131 - ''
132 - - ''
133 - ''
134 - O
135 items:
136 items:
137 type: string
138 type: array
139 title: Board
140 type: array
141 turn:
142 description: The current turn
143 example: X
144 title: Turn
145 type: string
146 required:
147 - board
148 - turn
149 title: MoveMadeData
150 type: object
151 NoSpec:
152 deprecated: false
153 description: Specification is not provided
154info:
155 description: 'Tic-Tac-Toe Game API
156
157 <br/> AsyncAPI currently does not support Socket.IO binding and Web Socket like
158 syntax used for now.
159
160 In order to add support for Socket.IO ACK value, AsyncAPI is extended with with
161 x-ack keyword.
162
163 This documentation should **NOT** be used for generating code due to these limitations.
164
165 '
166 title: Tic-Tac-Toe API
167 version: 1.0.0
168servers:
169 TIC_TAC_TOE_BACKEND:
170 protocol: socketio
171 url: http://localhost:5000
Cover image by Windmills During Dawn from Unsplash