Package: fastws
FastWSSource Code:https://github.com/endrekrohn/fastwsFastWS is a wrapper around FastAPI to create better WebSocket applications with auto-documentation usingAsyncAPI, in a similar fashion as FastAPIs existing use of OpenAPI.The current supported AsyncAPI verison is2.4.0. Once version3.0.0is released the plan is to upgrade to this standard.Example projectIf you are familiar with FastAPI and want to look at an example project using FastWSlook here👨‍💻RequirementsPython 3.11+FastWSuses Pydantic v2 and FastAPI.Installation$pipinstallfastwsYou will also need an ASGI server, for production such asUvicornorHypercorn.$pipinstall"uvicorn[standard]"ExampleCreate itCreate a filemain.pywith:fromcontextlibimportasynccontextmanagerfromtypingimportAnnotatedfromfastapiimportDepends,FastAPIfromfastwsimportClient,FastWSservice=FastWS()@service.send("ping",reply="ping")asyncdefsend_event_a():return@asynccontextmanagerasyncdeflifespan(app:FastAPI):service.setup(app)yieldapp=FastAPI(lifespan=lifespan)@app.websocket("/")asyncdeffastws_stream(client:Annotated[Client,Depends(service.manage)]):awaitservice.serve(client)We can look at the generated documentation athttp://localhost:<port>/asyncapi.Example breakdownFirst we import and initialize the service.fromfastwsimportClient,FastWSservice=FastWS()Define eventNext up we connect an operation (a WebSocket message) to the service, using the decorator@service.send(...). We need to define the operation using a string similar to how we define an HTTP-endpoint using a path.The operation-identificator is in this case"ping", meaning we will use this string to identify what type of message we are receiving.@service.send("ping",reply="ping")asyncdefsend_event_a():returnIf we want to define anpayloadfor the operation we can extend the example:frompydanticimportBaseModelclassPingPayload(BaseModel):foo:str@service.send("ping",reply="ping")asyncdefsend_event_a(payload:PingPayload):returnAn incoming message should now have the following format. (We will later view this in the generated AsyncAPI-documentation).{"type":"ping","payload":{"foo":"bar"}}Connect serviceNext up we connect the service to our running FastAPI application.@asynccontextmanagerasyncdeflifespan(app:FastAPI):service.setup(app)yieldapp=FastAPI(lifespan=lifespan)@app.websocket("/")asyncdeffastws_stream(client:Annotated[Client,Depends(service.manage)]):awaitservice.serve(client)The functionservice.setup(app)inside FastAPIs lifespan registers two endpoints/asyncapi.json, to retrieve our API definition/asyncapi, to view the AsyncAPI documentation UI.You can override both of these URLs when initializing the service, or set them toNoneto avoid registering the endpoints at all.RoutingTo spread out our service we can use theOperationRouter-class.# feature_1.pyfromfastwsimportClient,OperationRouterfrompydanticimportBaseModelrouter=OperationRouter(prefix="user.")classSubscribePayload(BaseModel):topic:strclassSubscribeResponse(BaseModel):detail:strtopics:set[str]@router.send("subscribe",reply="subscribe.response")asyncdefsubscribe_to_topic(payload:SubscribePayload,client:Client,)->SubscribeResponse:client.subscribe(payload.topic)returnSubscribeResponse(detail=f"Subscribed to{payload.topic}",topics=client.topics,)We can then include the router in our main service.# main.pyfromfastwsimportClient,FastWSfromfeature_1importrouterservice=FastWS()service.include_router(router)Operations,sendandrecvThe service enables two types of operations. Let us define these operations clearly:send: An operation where API user sends a message to the API server.Note: Up to AsyncAPI version2.6.0this refers to apublish-operation, but is changing tosendin version3.0.0.recv: An operation where API server sends a message to the API user.Note: Up to AsyncAPI version2.6.0this refers to asubscribe-operation, but is changing toreceivein version3.0.0.Thesend-operationThe above examples have only displayed the use ofsend-operations.When using the functionsFastWS.client_send(message, client)orFastWS.serve(client), we implicitly send some arguments. These keyword-arguments have the following keywords and types:clientwith typefastws.application.Clientappwith typefastws.application.FastWSpayload, optional with type defined in the function processing the message.Asend-operation can therefore access the following arguments:fromfastwsimportClient,FastWSfrompydanticimportBaseModelclassSomething(BaseModel):foo:strclassThing(BaseModel):bar:str@router.send("foo",reply="bar")asyncdefsome_function(payload:Something,client:Client,app:FastWS,)->Thing:print(f"{app.connections=}")print(f"{client.uid=}")returnThing(bar=client.uid)Therecv-operationWhen using the functionFastWS.server_send(message, topic), we implicitly send some arguments. These keyword-arguments have the keywords and types:appwith typefastws.application.FastWSOptionalpayloadwith type defined in the function processing the message.Arecv-operation can therefore access the following arguments:fromfastwsimportFastWSfrompydanticimportBaseModelclassAlertPayload(BaseModel):message:str@router.recv("alert")asyncdefrecv_client(payload:AlertPayload,app:FastWS)->str:return"hey there!"If we want create a message on the server side we can do the following:fromfastapiimportFastAPIfromfastwsimportFastWSservice=FastWS()app=FastAPI()@app.post("/")asyncdefalert_on_topic_foobar(message:str):awaitservice.server_send(Message(type="alert",payload={"message":message}),topic="foobar",)return"ok"In the example above all connections subscribed to the topicfoobarwill recieve a message the the payload"hey there!".In this way you can on the server-side choose to publish messages from anywhere to any topic. This is especially useful if you have a persistent connection to Redis or similar that reads messages from some channel and want to propagate these to your users.AuthenticationThere are to ways to tackle authentication usingFastWS.By definingauth_handlerOne way is to provide a customauth_handlerwhen initializing the service. Below is an example where the API user must provide a secret message within a timeout to authenticate.importasyncioimportloggingfromfastapiimportWebSocketfromfastwsimportFastWSdefcustom_auth(to_wait:float=5):asyncdefhandler(ws:WebSocket)->bool:awaitws.accept()try:initial_msg=awaitasyncio.wait_for(ws.receive_text(),timeout=to_wait,)returninitial_msg=="SECRET_HUSH_HUSH"exceptasyncio.exceptions.TimeoutError:logging.info("Took to long to provide authentication")returnFalsereturnhandlerservice=FastWS(auth_handler=custom_auth())By using FastAPI dependencyIf you want to use your own FastAPI dependency to handle authentication before it enters the FastWS service you will have to setauto_ws_accepttoFalse.importasynciofromtypingimportAnnotatedfromfastapiimportDepends,FastAPI,WebSocket,WebSocketException,statusfromfastwsimportClient,FastWSservice=FastWS(auto_ws_accept=False)app=FastAPI()asyncdefcustom_dep(ws:WebSocket):awaitws.accept()initial_msg=awaitasyncio.wait_for(ws.receive_text(),timeout=5,)ifinitial_msg=="SECRET_HUSH_HUSH":returnraiseWebSocketException(code=status.WS_1008_POLICY_VIOLATION,reason="Not authenticated",)@app.websocket("/")asyncdeffastws_stream(client:Annotated[Client,Depends(service.manage)],_=Depends(custom_dep),):awaitservice.serve(client)Heartbeats and connection lifespanTo handle a WebSocket's lifespan at an application level, FastWS tries to help you by usingasyncio.timeout()-context managers in itsserve(client)-function.You can set the both:heartbeat_interval: Meaning a client needs to send a message within this time.max_connection_lifespan: Meaning all connections will disconnect when exceeding this time.These must set during initialization:fromfastwsimportFastWSservice=FastWS(heartbeat_interval=10,max_connection_lifespan=300,)Bothheartbeat_intervalandmax_connection_lifespancan be set to None to disable any restrictions. Note this is the default.Please note that you can also set restrictions in your ASGI-server. These restrictions apply at a protocol/server-level and are different from the restrictions set by your application. Applicable settings foruvicorn:--ws-ping-intervalINTEGER--ws-ping-timeoutINTEGER--ws-max-sizeINTEGER