Cliente de WebSocket (AsyncAPI)¶
A receita de cliente de integração (OpenAPI) gera um cliente HTTP de uma especificação. Esta faz o mesmo para o que o OpenAPI não descreve: uma conexão que fica aberta, carrega mensagem nos dois sentidos, e onde o servidor fala sem ninguém pedir.
Não é uma limitação de ferramenta — é de formato. O OpenAPI modela uma requisição e a resposta dela; não existe onde encaixar um socket. Por isso rota de WebSocket costuma virar um parágrafo de prosa na documentação, e prosa não gera cliente nenhum.
O formato que descreve isso é o AsyncAPI 3.0, e o SDK lê.
Gerando¶
uv run tempest asyncapi-client http://127.0.0.1:3000/asyncapi.json \
--name zap \
--out src/integrations/zap_ws
Saem três arquivos, como no gerador de OpenAPI:
| Arquivo | O que tem |
|---|---|
schemas.py |
Uma classe Pydantic por payload de frame |
stream.py |
O cliente, mais as duas uniões e o erro de frame desconhecido |
__init__.py |
Re-export dos dois, na forma dupla (as + __all__) |
Requer o extra [websocket], que o SDK já usa no lado servidor — nenhuma
dependência nova entra por causa disto.
Usando¶
import asyncio
from src.integrations.zap_ws import (
AckFrame,
ErrorFrame,
ServerMessageFrame,
SubscribeFrame,
ZapStream,
)
async def main() -> None:
"""Assinar tudo e imprimir o que chegar."""
async with ZapStream(x_api_key="<sua chave>") as stream:
await stream.send(SubscribeFrame(action="subscribe", room="*"))
async for frame in stream:
match frame:
case ServerMessageFrame():
print(frame.payload.remote_jid, frame.payload.text)
case AckFrame():
print("ack:", frame.event)
case ErrorFrame():
print("erro:", frame.message)
asyncio.run(main())
O url tem default quando o documento declara um servers, e o header do
handshake vira argumento obrigatório quando o documento o marca como
requerido — x-api-key virou x_api_key.
Duas uniões, não uma¶
O que você envia e o que você recebe são tipos diferentes:
from src.integrations.zap_ws import (
AckFrame,
ErrorFrame,
SendFrame,
ServerMessageFrame,
SubscribeFrame,
UnsubscribeFrame,
)
ZapStreamClientFrame = SubscribeFrame | UnsubscribeFrame | SendFrame
ZapStreamServerFrame = AckFrame | ErrorFrame | ServerMessageFrame
O send aceita só a primeira; o receive e a iteração devolvem só a
segunda. Um match sobre a união de entrada é exaustivo para o
type-checker, então frame novo no documento vira erro de tipo no seu
serviço em vez de um else silencioso.
Cada variante é reconhecida pelo discriminante
O gerador procura, em cada payload, uma propriedade cujo enum tem
exatamente um valor — que é como um literal é renderizado em JSON
Schema. Esse par vira a chave de uma tabela de despacho.
Um frame que chega com um discriminante que o documento não declara
levanta ZapStreamFrameError, carregando a tag e o corpo. A causa comum
é servidor à frente do documento commitado, e o conserto é regenerar.
A parte que mais dá errado: a direção¶
O action do AsyncAPI é relativo a quem publicou o documento. Como quem
publica é o servidor, um frame que o cliente envia aparece no documento como
action: "receive" — o servidor é quem recebe.
Um gerador de cliente precisa inverter todos.
Inversão errada não quebra nada visível
O cliente compila, passa no type-check, e manda o que deveria escutar. Nenhum sinal aparece até a primeira mensagem sumir.
Por isso o gerador recusa um documento que não declare
x-tempest-perspective: "server" na raiz:
$ uv run tempest asyncapi-client ./doc.json --name x --out /tmp/x
error: ./doc.json does not declare `x-tempest-perspective`. AsyncAPI's
`action` is relative to whoever published the document, and a client has
to invert it — so a document that does not say which end wrote it cannot
be generated from. Documents produced by `tempest-express-sdk` carry it;
add `"x-tempest-perspective": "server"` at the root of a hand-written one
served by the application it describes.
O comando sai com código 2.
Documento gerado pelo tempest-express-sdk já carrega o campo. Num
escrito à mão, é uma linha.
A inversão acontece uma vez, no parser. O IR fala só a perspectiva do
cliente (outbound/inbound), então nem o emissor nem quem lê o IR precisa
lembrar de quem é o action que está segurando.
Um canal, sempre¶
A especificação é explícita: em WebSocket "the channel represents the connection [...] there's only one channel". Diferente de Kafka ou MQTT, onde canal é tópico. Documento com mais de um canal é recusado — está descrevendo outro transporte, e este gerador emite cliente de WebSocket.
Salas, quando existem, são conceito dos frames, não do canal.
Validação vem junto¶
Constraint do documento vira validação Pydantic no modelo gerado:
from src.integrations.zap_ws import SubscribeFrame
# docs-guard: skip — a chamada recusada abaixo é o assunto da seção
SubscribeFrame(action="subscribe", room="nao@vale@nada")
# pydantic_core.ValidationError: 1 validation error for SubscribeFrame
# room
# String should match pattern '^(\*|\d{10,15}|[A-Za-z0-9._-]+@[A-Za-z0-9.-]+)$'
# [type=string_pattern_mismatch, input_value='nao@vale@nada', input_type=str]
O frame inválido nem chega à rede. É o mesmo regex que o servidor aplica, porque os dois saem do mesmo schema.
Mantendo em dia¶
Como no gerador de OpenAPI, o documento é vendorizado e os arquivos gerados são commitados, para que o consumidor não rode codegen:
make zap-ws-fetch # relê o documento do gateway (precisa dele no ar)
make zap-ws-regen # regenera a partir do vendorizado, offline
O SPEC_SHA256 registra de quais bytes este checkout gerou, e
tests/integrations/messaging/zap_ws/test_generated_drift.py falha se
alguém editar o gerado à mão ou esquecer de atualizar o digest.
Duas superfícies, dois documentos
Um serviço que fala HTTP e WebSocket publica os dois: o
/openapi.json para as rotas e o /asyncapi.json para o socket. Eles
convivem porque nenhum formato sozinho descreve os dois.
Recapitulando¶
- O OpenAPI não descreve socket; o AsyncAPI descreve, e o SDK lê 3.x.
tempest asyncapi-clientescreveschemas.py,stream.pye o barrel.- Uma união para o que você envia, outra para o que recebe;
matchexaustivo. - Direção é invertida uma vez, no parser — e o documento tem que dizer de quem é a perspectiva, ou é recusado.
- Um canal por conexão, porque WebSocket não tem canal virtual.
- Constraint do documento vira validação, então frame inválido não sai.
- Documento vendorizado, gerado commitado, drift test guardando os dois.