AsyncApiSpex
Declaratively describe the messaging channels an Elixir application publishes to
or consumes from, as an AsyncAPI 3.0
document, and serve or serialize it. The API mirrors
open_api_spex: plain structs, light use
macros, a Plug, and a mix task.
The library has no dependency on any application's domain code — only plug
(optional, for AsyncApiSpex.Plug.RenderSpec and AsyncApiSpex.Plug.Viewer)
— and nothing from any application in this repository;
scripts/standalone.sh proves it against a fresh mix new app in CI.
Installation
def deps do
[{:async_api_spex, "~> 0.2"}]
end
Usage
Describe what you already have
Say your shop app already has event structs and a module that publishes them to
a Kafka topic with :brod. Decorate those: the
structs become schemas, the producer declares its topic and its broker, and one
use AsyncApiSpex.Spec module turns them into an AsyncAPI document.
defmodule Shop.Events.LineItem do
defstruct [:sku, :quantity]
use AsyncApiSpex.Schema,
name: "LineItem",
fields: [sku: [type: :string, required: true], quantity: [type: :integer, required: true]]
end
defmodule Shop.Events.OrderCreated do
defstruct [:id, :customer_id, :items, :placed_at]
use AsyncApiSpex.Schema,
name: "OrderCreated",
title: "Order created",
description: "Emitted once per checkout.",
fields: [
id: [type: :string, required: true],
customer_id: :string,
items: {:array, Shop.Events.LineItem},
placed_at: :datetime
]
end
defmodule Shop.Events.OrderShipped do
defstruct [:id, :carrier, :shipped_at]
use AsyncApiSpex.Schema,
name: "OrderShipped",
fields: [
id: [type: :string, required: true],
carrier: {:enum, ["ups", "dhl"]},
shipped_at: :datetime
]
end
defmodule Shop.Kafka.OrderProducer do
use AsyncApiSpex.Channel,
address: "shop.orders",
server: [id: "kafka", host: System.get_env("KAFKA_BOOTSTRAP", "kafka:9092"), protocol: "kafka"],
messages: [Shop.Events.OrderCreated, Shop.Events.OrderShipped],
bindings: %{"kafka" => %{"partitions" => 12}},
description: "One record per order event, keyed by order id."
def publish(%{id: id} = event) do
:brod.produce_sync(:shop, "shop.orders", :hash, id, JSON.encode!(Map.from_struct(event)))
end
end
defmodule Shop.AsyncApi do
use AsyncApiSpex.Spec,
otp_app: :shop,
info: [title: "Shop", version: "1.0.0"]
end
Every key of a struct becomes a property of its schema, and fields: refines
the keys it names: a type, required: true, a description. A key that
fields: does not name is still a property, with no constraints; naming a key
the struct does not have is a compile error. Types are :string, :integer,
:number, :boolean, :map, :any, :datetime, :date, {:array, type},
{:enum, values}, another schema module (Shop.Events.LineItem above), or a
raw schema map. See AsyncApiSpex.Schema for the table.
The producer declares where its events go: the topic (address:), the broker
(server:, evaluated when the document is built, so environment variables and
Application.get_env/2 work), and the events it sends (messages:). The
operation is send unless you pass action: :receive, which is what a consumer
module declares. publish/1 is yours; the library only reads the declaration.
otp_app: :shop finds every module in that application that uses
AsyncApiSpex.Channel. To list them instead, write
channels: [Shop.Kafka.OrderProducer]. Each channel module gets its own
channel, and the document shares a server between channels that declare the
same one.
Serve or write it
Serve it from a router, next to a page that renders it:
# Plug.Router
forward "/asyncapi.json", to: AsyncApiSpex.Plug.RenderSpec, init_opts: [spec: Shop.AsyncApi]
forward "/asyncapi", to: AsyncApiSpex.Plug.Viewer, init_opts: [path: "/asyncapi.json"]
# Phoenix router
forward "/asyncapi.json", AsyncApiSpex.Plug.RenderSpec, spec: Shop.AsyncApi
forward "/asyncapi", AsyncApiSpex.Plug.Viewer, path: "/asyncapi.json"
AsyncApiSpex.Plug.Viewer serves an HTML page that fetches the document from
its path: (the RenderSpec mount, as an absolute path); open /asyncapi to
browse the channels, operations, and schemas. The page is the
AsyncAPI React component, loaded
from unpkg; :bundle_url and :stylesheet_url point it at a self-hosted copy.
Under a Content Security Policy, script-src must also allow the page's one
inline <script> ('unsafe-inline' or its hash).
Or write it to a file:
mix async_api_spex.gen --spec Shop.AsyncApi --output asyncapi.json
AsyncApiSpex.encode!/1 emits JSON. YAML 1.2 is a superset of JSON, so any YAML
tool — including AsyncAPI Studio and asyncapi validate — reads the output.
Let others subscribe
The document for the modules above, abridged:
{
"asyncapi": "3.0.0",
"info": {"title": "Shop", "version": "1.0.0"},
"servers": {
"kafka": {"host": "kafka:9092", "protocol": "kafka"}
},
"channels": {
"shop_orders": {
"address": "shop.orders",
"description": "One record per order event, keyed by order id.",
"bindings": {"kafka": {"partitions": 12}},
"servers": [{"$ref": "#/servers/kafka"}],
"messages": {
"OrderCreated": {"$ref": "#/components/messages/OrderCreated"},
"OrderShipped": {"$ref": "#/components/messages/OrderShipped"}
}
}
},
"operations": {
"send-shop_orders": {
"action": "send",
"channel": {"$ref": "#/channels/shop_orders"},
"messages": [
{"$ref": "#/channels/shop_orders/messages/OrderCreated"},
{"$ref": "#/channels/shop_orders/messages/OrderShipped"}
]
}
},
"components": {
"messages": {
"OrderCreated": {
"name": "OrderCreated",
"title": "Order created",
"contentType": "application/json",
"payload": {"$ref": "#/components/schemas/OrderCreated"}
}
},
"schemas": {
"OrderCreated": {
"type": "object",
"title": "Order created",
"description": "Emitted once per checkout.",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"customer_id": {"type": "string"},
"items": {"type": "array", "items": {"$ref": "#/components/schemas/LineItem"}},
"placed_at": {"type": "string", "format": "date-time"}
}
}
}
}
}
Another team subscribes with what is in the document and nothing else: connect
to servers.kafka.host, subscribe to channels.shop_orders.address, and decode
each record with the schemas under components.schemas. The AsyncAPI
generator templates read exactly this.
Writing the document by hand
A channel that cannot be decorated (it is not published from one module of
yours, or you want full control) is declared with the structs directly. A
decorated channel whose address is computed at runtime takes address: nil and
an explicit id:. use AsyncApiSpex.Schema with
schema: takes a JSON Schema map, and use AsyncApiSpex.Message declares a
message with headers or a correlation id:
defmodule MyApp.AsyncApi.Schemas do
use AsyncApiSpex.Schema,
name: "OrderCreatedV1",
schema: %{
type: "object",
required: ["id"],
properties: %{id: %{type: "string"}}
}
end
defmodule MyApp.AsyncApi.Messages do
use AsyncApiSpex.Message,
name: "OrderCreated",
title: "Order created",
content_type: "application/json",
payload: MyApp.AsyncApi.Schemas
end
defmodule MyApp.AsyncApi do
@behaviour AsyncApiSpex.Spec
@impl true
def spec do
%AsyncApiSpex.Document{
info: %AsyncApiSpex.Info{title: "My App", version: "1.0.0"},
servers: %{"prod" => %AsyncApiSpex.Server{host: "kafka:9092", protocol: "kafka"}},
channels: %{
"orders" => %AsyncApiSpex.Channel{
address: "orders",
messages: %{"created" => MyApp.AsyncApi.Messages}
}
},
operations: %{
"send-orders" => %AsyncApiSpex.Operation{
action: :send,
channel: %AsyncApiSpex.Reference{ref: "#/channels/orders"},
messages: [%AsyncApiSpex.Reference{ref: "#/channels/orders/messages/created"}]
}
}
}
end
end
A message module can also be listed in a decorated channel's messages:, next
to schema modules.