AsyncAPI: documentando o WebSocket¶
O OpenAPI descreve uma requisição e a resposta dela. Um WebSocket não tem esse formato: a conexão fica aberta, as mensagens vão nos dois sentidos, e o servidor fala sem ninguém ter pedido. Não existe onde encaixar isso num documento OpenAPI — então rota de socket costuma virar um parágrafo de prosa, e prosa não gera cliente nenhum.
O createAsyncApiRegistry resolve isso do mesmo jeito que o registry de
OpenAPI resolve o lado HTTP: você registra o que a conexão carrega, e o SDK
emite um documento AsyncAPI 3.0 servido ao lado do /openapi.json.
O exemplo completo¶
import {
createApp,
createAsyncApiRegistry,
runServer,
z,
} from "tempest-express-sdk";
const subscribeFrame = z.object({
action: z.literal("subscribe"),
room: z.string().min(1).max(128),
});
const messageFrame = z.object({
type: z.literal("message"),
payload: z.object({
remoteJid: z.string(),
text: z.string().nullable(),
}),
});
const handshakeHeaders = z.object({
"x-api-key": z.string().describe("Chave do consumidor, exigida no upgrade"),
});
const asyncapi = createAsyncApiRegistry()
.registerChannel({
name: "socket",
address: "/ws",
title: "Conexão de tempo real",
handshakeHeaders,
})
.registerMessage({
name: "SubscribeFrame",
schema: subscribeFrame,
summary: "Entrar numa sala",
})
.registerMessage({
name: "MessageFrame",
schema: messageFrame,
summary: "Uma mensagem chegou",
})
.registerOperation({
name: "subscribe",
channel: "socket",
direction: "clientToServer",
messages: ["SubscribeFrame"],
})
.registerOperation({
name: "onMessage",
channel: "socket",
direction: "serverToClient",
messages: ["MessageFrame"],
});
const app = await createApp({
asyncapi: { registry: asyncapi, info: { title: "Gateway", version: "1.0.0" } },
});
await runServer(app, { port: 3000 });
Batendo em http://127.0.0.1:3000/asyncapi.json:
{
"asyncapi": "3.0.0",
"x-tempest-perspective": "server",
"info": { "title": "Gateway", "version": "1.0.0" },
"defaultContentType": "application/json",
"channels": {
"socket": {
"address": "/ws",
"title": "Conexão de tempo real",
"messages": {
"SubscribeFrame": { "$ref": "#/components/messages/SubscribeFrame" },
"MessageFrame": { "$ref": "#/components/messages/MessageFrame" }
},
"bindings": {
"ws": {
"bindingVersion": "0.1.0",
"method": "GET",
"headers": {
"type": "object",
"properties": { "x-api-key": { "type": "string" } },
"required": ["x-api-key"]
}
}
}
}
},
"operations": {
"subscribe": {
"action": "receive",
"channel": { "$ref": "#/channels/socket" },
"messages": [{ "$ref": "#/channels/socket/messages/SubscribeFrame" }]
},
"onMessage": {
"action": "send",
"channel": { "$ref": "#/channels/socket" },
"messages": [{ "$ref": "#/channels/socket/messages/MessageFrame" }]
}
}
}
Pedaço por pedaço¶
O canal é a conexão¶
Em WebSocket não existe canal virtual. A própria especificação diz: "the channel represents the connection [...] there's only one channel". Diferente de Kafka ou MQTT, onde canal é tópico, aqui você registra um canal — o caminho onde o socket é servido — e as salas são detalhe do seu protocolo, não do documento.
O handshakeHeaders vira o binding ws do canal, que é onde a especificação
guarda o que o upgrade HTTP exige. É ali que a chave de API fica documentada.
O schema do handshake é embutido, não referenciado
A especificação tipa bindings.ws.headers como
oneOf: [Schema, Reference], e um {"$ref": ...} puro satisfaz os
dois ramos — então o oneOf vê duas correspondências e o documento
falha no próprio JSON Schema do AsyncAPI. Medido das duas formas contra o
meta-schema oficial.
direction é do ponto de vista do cliente¶
Esta é a parte que mais dá errado, e é por isso que o registry não aceita
action.
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.
| Você escreve | Sai no documento | Quer dizer |
|---|---|---|
direction: "clientToServer" |
action: "receive" |
o servidor recebe |
direction: "serverToClient" |
action: "send" |
o servidor envia |
Ler action como se fosse seu produz um cliente ao contrário
Um gerador de cliente precisa inverter todo action do documento. Errar
o sinal não quebra nada visível: o cliente compila, passa no type-check, e
faz exatamente o oposto do que devia.
Por isso direction existe com esses dois nomes: clientToServer não tem
como ser lido de trás para frente. E por isso o documento carrega
x-tempest-perspective: "server" — um consumidor confere isso e recusa
o documento se faltar, em vez de assumir.
Os payloads são os mesmos schemas que validam¶
O payload de cada mensagem sai do seu objeto Zod, pelo mesmo caminho que
alimenta o OpenAPI. Não é uma cópia do schema: é o schema. O documento não tem
como descrever uma forma que o servidor recusaria, porque são o mesmo objeto.
Use união discriminada, não um envelope solto
Um envelope genérico ({ type: string, data: unknown }) passa no
type-check e documenta nada — unknown não gera tipo, e o cliente
gerado recebe um campo opaco.
Registre um frame por variante, cada um com o discriminante literal
(z.literal("subscribe")). O consumidor monta uma união tagueada e o
switch fica exaustivo.
z.literal() vira um enum de um valor só
No JSON Schema gerado, z.literal("subscribe") sai como
{ "type": "string", "enum": ["subscribe"] }, e não como
{ "const": "subscribe" }. Quem lê o documento para montar a união
procura o discriminante nessa forma.
Referência pendurada falha na hora¶
Operação que aponta para canal ou mensagem que ninguém registrou levanta erro na geração, nomeando o que não resolveu:
createAsyncApiRegistry()
.registerChannel({ name: "socket", address: "/ws" })
.registerOperation({
name: "subscribe",
channel: "socket",
direction: "clientToServer",
messages: ["NaoRegistrado"],
})
.generate({ info: { title: "T", version: "1" } });
// Error: AsyncAPI operation "subscribe" refers to message "NaoRegistrado",
// which is not registered.
Sem essa checagem o documento sairia estruturalmente válido, com um $ref
apontando para o vazio — e o cliente gerado a partir dele simplesmente não teria
aquele frame.
A ordem de registro, essa sim, não importa: as referências são resolvidas na geração, então dá para registrar a operação antes do canal.
Montar fora do createApp¶
O mountAsyncApiJson existe para quem monta o app na mão:
import { generateAsyncApiDocument, mountAsyncApiJson } from "tempest-express-sdk";
const document = generateAsyncApiDocument(asyncapi, {
info: { title: "Gateway", version: "1.0.0" },
});
mountAsyncApiJson(app, "/asyncapi.json", document);
Montar depois do createApp responde 404
O createApp instala o handler de 404 por último. Rota adicionada no app
que ele devolveu fica atrás desse handler e nunca é alcançada.
Use a opção asyncapi do createApp, ou monte dentro do configure.
Recapitulando¶
- OpenAPI não descreve socket; AsyncAPI descreve, e os dois documentos convivem.
- Um canal por conexão — em WebSocket não há canal virtual.
directioné do ponto de vista do cliente; oactiondo documento é do servidor, e o consumidor inverte.x-tempest-perspectivediz de quem é a perspectiva, para ninguém supor.- Payload sai do Zod que já valida, então documento e runtime não divergem.
- União discriminada por frame, nunca envelope com
data: unknown. - Referência pendurada falha na geração, não no consumidor.