Se você desenvolve ou consome APIs REST provavelmente já se deparou com alguma documentação escrita segundo a especificação OpenAPI. Ela é o padrão do mercado, apesar de eu preferir documentar usando o API Blueprint :)

Mas o assunto deste post é outra especificação, a AsyncAPI. Inspirada na OpenAPI, a AsyncAPI tem como propósito documentar aplicações que usam a arquitetura baseada em eventos (Event-Driven Architectures ou EDA). Na imagem a seguir podemos ver uma comparação entre os dois padrões:

apidoc1

Assim como sua irmã mais velha, a AsyncAPI permite que geremos documentação em diferentes formatos, bem como a geração de código, graças a uma série de ferramentas criadas pela comunidade.

Neste post vou demonstrar o uso da AsyncAPI com um exemplo bem simples. Vamos modelar um sistema baseado em microsserviços e EDA para uma empresa que vende um Software com Serviço (SaaS). O sistema é composto de três microsserviços:

  • accounts: serviço responsável pelo cadastro de novos usuários. Ele gera o evento user-registered.

  • subscription: serviço responsável pelo controle da assinatura dos planos da empresa pelos usuários. Ele “ouve” o evento user-registered e pode gerar os eventos user-subscribed e user-unsubscribed.

  • finance: serviço responsável pelo controle financeiro dos planos. Ele “ouve” os eventos user-subscribed e user-unsubscribed e com base nestas informações ele pode gerar os eventos payment-succeded e payment-failed.

Neste contexto, quando eu uso o termo ouve significa que o serviço assina (subscribe) o evento, ou ele é um consumidor (consumer) deste evento. E quando uso o termo gera significa que o serviço publica (publish) um evento, ou ele é um produtor (producer) deste tipo de ocorrência. Estes conceitos são bem comuns em aplicações baseadas em eventos, como nosso exemplo. O Reactive Manifesto contém uma descrição bem interessante da diferença entre arquiteturas baseadas em mensagens e as baseadas em eventos:

Em uma arquitetura orientada a mensagens, o produtor conhece o consumidor. Em arquiteturas orientadas a eventos, por outro lado, o consumidor decide quais fontes deseja assinar.

Vamos agora começar a documentar nosso projeto. Para isso, criei um diretório chamado docs e dentro dele o arquivo saas-service.yaml. As primeiras linhas do arquivo contém:

asyncapi: 2.2.0
info:
  title: Awesome SaaS
  version: 1.0.0
  description: The Awesome Saas Company
  contact:
    name: Elton Minetto
    email: elton@minetto.dev

Elas definem a versão da especificação que estamos usando, bem como algumas informações básicas.

Vamos agora incluir a informação relacionada a forma como vamos processar os eventos do projeto. A especificação é bem ampla em relação a isso, não limitando o usuário a uma solução em específico. Neste exemplo vamos considerar o uso do RabbitMQ, mas poderíamos usar Kafka, Mosquito, entre outros. Vamos incluir no nosso documento a configuração dos nossos servidores RabbitMQ:

servers:
  rabbitmq-dev:
    url: localhost:5672
    description: Local RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"
  rabbitmq-staging:
    url: staging-rabbitmq.server.saas.com:5672
    description: Staging RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"
  rabbitmq-prod:
    url: rabbitmq.server.saas.com:5672
    description: Production RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"

O próximo passo é incluirmos as informações relacionadas aos eventos que nosso sistema vai processar. Vamos começar com a publicação e assinatura do evento user-registered:

channels:
  user-registered:
    publish:
      operationId: userRegisteredPub
      description: The payload of user registration
      message:
        $ref: "#/components/messages/user"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: userRegisteredSub
      description: The payload of user registration
      message:
        $ref: "#/components/messages/user"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: userExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0

Dentro de channels vamos especificar os canais com as informações referentes ao que é necessário para os serviços que desejam publicar e consumir este evento. Como estamos usando o RabbitMQ como nosso message broker as informações dentro de bindings fazem referência a configurações específicas desta solução. Cada solução possui configurações especiais, como consta na documentação. Outro ponto importante é a chave message que faz referência ($ref) a uma mensagem que vamos definir agora:

components:
  messages:
    user:
      payload:
        type: object
        properties:
          id:
            type: integer
            format: int64
            description: ID of user
          name:
            type: string
            description: Name of user
          email:
            type: string
            description: E-mail of user
          password:
            type: string
            format: password
            description: Password of user
          registered_at:
            type: string
            format: date-time
            description: Timestamp of registration

O uso destas referências é útil para podermos reaproveitar a informação em vários eventos, caso necessário. Na documentação é possível ver os tipos de dados que a especificação suporta.

Após esta introdução, seguimos incluindo os outros eventos e o arquivo final ficou desta forma:

asyncapi: 2.2.0
info:
  title: Awesome SaaS
  version: 1.0.0
  description: The Awesome Saas Company
  contact:
    name: Elton Minetto
    email: elton@minetto.dev
servers:
  rabbitmq-dev:
    url: localhost:5672
    description: Local RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"
  rabbitmq-staging:
    url: staging-rabbitmq.server.saas.com:5672
    description: Staging RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"
  rabbitmq-prod:
    url: rabbitmq.server.saas.com:5672
    description: Production RabbitMQ
    protocol: amqp
    protocolVersion: "0.9.1"
channels:
  user-registered:
    publish:
      operationId: userRegisteredPub
      description: The payload of user registration
      message:
        $ref: "#/components/messages/user"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: userRegisteredSub
      description: The payload of user registration
      message:
        $ref: "#/components/messages/user"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: userExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0
  user-subscribed:
    publish:
      operationId: userSubscribedPub
      description: The payload of user subscription
      message:
        $ref: "#/components/messages/subscription"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: userSubscribedSub
      description: The payload of user subscription
      message:
        $ref: "#/components/messages/subscription"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: subscriptionExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0
  user-unsubscribed:
    publish:
      operationId: userUnsubscribedPub
      description: The payload of user unsubscription
      message:
        $ref: "#/components/messages/unsubscription"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: userUnsubscribedSub
      description: The payload of user unsubscription
      message:
        $ref: "#/components/messages/unsubscription"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: unsubscriptionExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0
  payment-succeeded:
    publish:
      operationId: paymentSucceededPub
      description: The payload of successful payment
      message:
        $ref: "#/components/messages/payment"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: paymentSucceededSub
      description: The payload of successful payment
      message:
        $ref: "#/components/messages/payment"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: paymentSucceededExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0
  payment-failed:
    publish:
      operationId: paymentFailedPub
      description: The payload of failed payment
      message:
        $ref: "#/components/messages/payment"
      bindings:
        amqp:
          timestamp: true
          ack: false
          bindingVersion: 0.2.0
    subscribe:
      operationId: paymentFailedSub
      description: The payload of failed payment
      message:
        $ref: "#/components/messages/payment"
    bindings:
      amqp:
        is: routingKey
        exchange:
          name: paymentFailedExchange
          type: direct
          durable: true
          vhost: /
        bindingVersion: 0.2.0
components:
  messages:
    user:
      payload:
        type: object
        properties:
          id:
            type: integer
            format: int64
            description: ID of user
          name:
            type: string
            description: Name of user
          email:
            type: string
            description: E-mail of user
          password:
            type: string
            format: password
            description: Password of user
          registered_at:
            type: string
            format: date-time
            description: Timestamp of registration
    subscription:
      payload:
        type: object
        properties:
          id:
            type: integer
            format: int64
            description: ID of subscription
          user_id:
            type: integer
            format: int64
            description: ID of user
          plan_id:
            type: integer
            format: int64
            description: ID of plan
          plan_name:
            type: string
            description: Name of plan
          plan_value:
            type: number
            format: float
            description: Value of plan
          subscribed_at:
            type: string
            format: date-time
            description: Timestamp of subscription
    unsubscription:
      payload:
        type: object
        properties:
          id:
            type: integer
            format: int64
            description: ID of subscription
          unsubscribed_at:
            type: string
            format: date-time
            description: Timestamp of unsubscription
    payment:
      payload:
        type: object
        properties:
          id:
            type: integer
            format: int64
            description: ID of payment
          user_id:
            type: integer
            format: int64
            description: ID of user
          plan_id:
            type: integer
            format: int64
            description: ID of plan
          value:
            type: number
            format: float
            description: Value of payment
          created_at:
            type: string
            format: date-time
            description: Timestamp of payment

Gerando a documentação

Com o arquivo criado podemos agora criar uma documentação mais amigável. Para isso precisamos instalar o gerador, ou usar ele em sua versão Docker.

Para instalar localmente é necessário o npm e executar o comando:

npm install -g @asyncapi/generator

Com o gerador instalado basta executar:

mkdir docs/html
ag docs/saas-service.yaml -o docs/html/ @asyncapi/html-template --force-write

Ou, usando o comando via Docker:

docker run --rm -it \
-v ${PWD}/docs/saas-service.yaml:/app/saas-service.yaml \
-v ${PWD}/docs/html:/app/output \
asyncapi/generator -o /app/output /app/saas-service.yaml @asyncapi/html-template --force-write

A documentação gerada é bem útil e de fácil entendimento:

apidoc1

Também é possível gerar a documentação no formato Markdown.

Gerando código

O gerador também permite a geração de código. Segundo a documentação é possível gerar códigos em Node.js, Java e Python. Executei o comando abaixo:

 ag docs/saas-service.yaml @asyncapi/python-paho-template -o src

E o diretório foi criado, com códigos na linguagem:

asyncapi_post ⟩ ls  src/
README.md           entity.py           messaging.py
config-template.ini main.py             payload.py

Como eu não sou fluente em nenhuma das três linguagens suportadas não vou tecer comentários sobre a qualidade do código gerado, deixo isso para o leitor testar e dar suas opiniões nos comentários do post.

Ferramentas

Além do gerador existe uma ferramenta online para testar a especificação, bem como Github Actions, parsers e plugins para VSCode e IDEs baseadas no IntelliJ.

Conclusões

É inegável que uma boa documentação é crucial para qualquer projeto, e ainda mais em casos de comunicação assíncrona e baseada em eventos. E ter um padrão de mercado, adotado por grandes empresas como Slack, SAP e Salesforce, aumenta muito a importância da AsyncAPI.

Minhas únicas reclamações são em relação ao fato de precisar escrever arquivos YAML (sou um velho ranzinza que não gosta do formato, mas isso é gosto pessoal) e a performance do gerador, que poderia ser mais rápido (talvez existam outras implementações em Go ou Rust, mas eu não fiz uma pesquisa extensa para este post).

De qualquer forma, me parece que aprender esta especificação é um bom investimento.