use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::Value;
use crate::runtime::App;
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Spec {
pub asyncapi: String,
pub info: Info,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub servers: BTreeMap<String, Server>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub channels: BTreeMap<String, Channel>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub operations: BTreeMap<String, Operation>,
pub components: Components,
}
impl Spec {
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn to_yaml(&self) -> Result<String, serde_norway::Error> {
serde_norway::to_string(self)
}
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Server {
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
pub protocol: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub security: Vec<Reference>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Info {
pub title: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Channel {
pub address: String,
pub messages: BTreeMap<String, Reference>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Operation {
pub action: String,
pub channel: Reference,
pub messages: Vec<Reference>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct Components {
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub messages: BTreeMap<String, MessageObject>,
#[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty")]
pub security_schemes: BTreeMap<String, Value>,
}
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct MessageObject {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload: Option<Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Reference {
#[serde(rename = "$ref")]
pub reference: String,
}
impl Reference {
fn new(target: impl Into<String>) -> Self {
Self {
reference: target.into(),
}
}
}
#[must_use]
pub fn build_spec<A: App>(app: &A) -> Spec {
let info = Info {
title: app.info().title.clone(),
version: app.info().version.clone(),
description: app.info().description.clone(),
};
let mut security_schemes = BTreeMap::new();
let servers = app
.servers()
.iter()
.map(|(name, spec)| {
let security = spec
.security
.iter()
.enumerate()
.map(|(index, scheme)| {
let key = if index == 0 {
name.clone()
} else {
format!("{name}-{index}")
};
security_schemes.insert(key.clone(), security_scheme_object(scheme));
Reference::new(format!("#/components/securitySchemes/{key}"))
})
.collect();
(
name.clone(),
Server {
host: spec.host.clone(),
protocol: spec.protocol.clone(),
description: spec.description.clone(),
security,
},
)
})
.collect();
let mut channels = BTreeMap::new();
let mut operations = BTreeMap::new();
let mut messages = BTreeMap::new();
for handler in app.handlers() {
let name = handler.name.as_ref();
let payload = handler
.payload_schema
.as_deref()
.and_then(|json| serde_json::from_str::<Value>(json).ok());
let schema_str = |key: &str| {
payload
.as_ref()
.and_then(|schema| schema.get(key))
.and_then(Value::as_str)
.map(str::to_owned)
};
let message_name = handler
.message_name
.as_ref()
.map(ToString::to_string)
.or_else(|| schema_str("title"))
.unwrap_or_else(|| message_name(handler.input_type));
channels.entry(name.to_owned()).or_insert_with(|| Channel {
address: name.to_owned(),
messages: BTreeMap::from([(
message_name.clone(),
Reference::new(format!("#/components/messages/{message_name}")),
)]),
});
operations.insert(
operation_id(name),
Operation {
action: "receive".to_owned(),
channel: Reference::new(format!("#/channels/{name}")),
messages: vec![Reference::new(format!(
"#/channels/{name}/messages/{message_name}"
))],
description: handler.description.as_ref().map(ToString::to_string),
},
);
let message_description = handler
.message_description
.as_ref()
.map(ToString::to_string)
.or_else(|| schema_str("description"))
.or_else(|| handler.description.as_ref().map(ToString::to_string));
messages
.entry(message_name.clone())
.or_insert_with(|| MessageObject {
name: message_name,
description: message_description,
payload,
});
}
Spec {
asyncapi: "3.0.0".to_owned(),
info,
servers,
channels,
operations,
components: Components {
messages,
security_schemes,
},
}
}
fn security_scheme_object(scheme: &crate::SecurityScheme) -> Value {
use crate::capability::SecuritySchemeKind as Kind;
let parse = |raw: &str| serde_json::from_str::<Value>(raw).unwrap_or(Value::Null);
let mut object = match &scheme.kind {
Kind::UserPassword => serde_json::json!({ "type": "userPassword" }),
Kind::ApiKey { location } => {
serde_json::json!({ "type": "apiKey", "in": location.as_api() })
}
Kind::X509 => serde_json::json!({ "type": "X509" }),
Kind::Plain => serde_json::json!({ "type": "plain" }),
Kind::ScramSha256 => serde_json::json!({ "type": "scramSha256" }),
Kind::ScramSha512 => serde_json::json!({ "type": "scramSha512" }),
Kind::Gssapi => serde_json::json!({ "type": "gssapi" }),
Kind::Http { scheme } => serde_json::json!({ "type": "http", "scheme": scheme }),
Kind::HttpApiKey { name, location } => serde_json::json!({
"type": "httpApiKey",
"name": name,
"in": location.as_api(),
}),
Kind::OpenIdConnect { url } => serde_json::json!({
"type": "openIdConnect",
"openIdConnectUrl": url,
}),
Kind::Oauth2 { flows } => serde_json::json!({ "type": "oauth2", "flows": parse(flows) }),
Kind::Custom { object } => parse(object),
};
if let (Some(description), Some(fields)) = (&scheme.description, object.as_object_mut()) {
fields.insert("description".to_owned(), Value::String(description.clone()));
}
object
}
#[must_use]
pub fn render_viewer_html(spec_url: &str, opts: &ViewerOptions<'_>) -> String {
let title = opts.title;
let cdn = opts.cdn_base.trim_end_matches('/');
let spec = spec_url.replace('"', """);
format!(
"<!DOCTYPE html>\n\
<html lang=\"en\">\n\
<head>\n\
<meta charset=\"utf-8\" />\n\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\
<title>{title}</title>\n\
<link rel=\"stylesheet\" href=\"{cdn}/styles/default.min.css\" />\n\
</head>\n\
<body>\n\
<div id=\"asyncapi\"></div>\n\
<script src=\"{cdn}/browser/standalone/index.js\"></script>\n\
<script>\n\
AsyncApiStandalone.render(\n\
{{ schema: {{ url: \"{spec}\" }}, config: {{ show: {{ sidebar: true }} }} }},\n\
document.getElementById(\"asyncapi\"),\n\
);\n\
</script>\n\
</body>\n\
</html>\n"
)
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ViewerOptions<'a> {
pub title: &'a str,
pub cdn_base: &'a str,
}
impl<'a> ViewerOptions<'a> {
#[must_use]
pub const fn with_title(mut self, title: &'a str) -> Self {
self.title = title;
self
}
#[must_use]
pub const fn with_cdn_base(mut self, cdn_base: &'a str) -> Self {
self.cdn_base = cdn_base;
self
}
}
impl Default for ViewerOptions<'_> {
fn default() -> Self {
Self {
title: "AsyncAPI",
cdn_base: "https://cdn.jsdelivr.net/npm/@asyncapi/react-component@2.6.4",
}
}
}
fn message_name(type_name: &str) -> String {
type_name
.rsplit("::")
.next()
.unwrap_or(type_name)
.to_owned()
}
fn operation_id(name: &str) -> String {
let sanitized: String = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
format!("receive_{sanitized}")
}
#[cfg(test)]
mod tests {
use crate::{ApiKeyLocation, HttpApiKeyLocation, SecurityScheme};
use super::security_scheme_object;
#[test]
fn every_scheme_kind_renders_its_document_object() {
let cases = [
(
SecurityScheme::user_password(),
serde_json::json!({ "type": "userPassword" }),
),
(
SecurityScheme::api_key(ApiKeyLocation::Password),
serde_json::json!({ "type": "apiKey", "in": "password" }),
),
(
SecurityScheme::x509(),
serde_json::json!({ "type": "X509" }),
),
(
SecurityScheme::plain(),
serde_json::json!({ "type": "plain" }),
),
(
SecurityScheme::scram_sha256(),
serde_json::json!({ "type": "scramSha256" }),
),
(
SecurityScheme::scram_sha512(),
serde_json::json!({ "type": "scramSha512" }),
),
(
SecurityScheme::gssapi(),
serde_json::json!({ "type": "gssapi" }),
),
(
SecurityScheme::http("bearer"),
serde_json::json!({ "type": "http", "scheme": "bearer" }),
),
(
SecurityScheme::http_api_key("X-Api-Key", HttpApiKeyLocation::Header),
serde_json::json!({ "type": "httpApiKey", "name": "X-Api-Key", "in": "header" }),
),
(
SecurityScheme::open_id_connect("https://idp.example.com/.well-known"),
serde_json::json!({
"type": "openIdConnect",
"openIdConnectUrl": "https://idp.example.com/.well-known",
}),
),
(
SecurityScheme::oauth2(serde_json::json!({ "clientCredentials": {} })),
serde_json::json!({ "type": "oauth2", "flows": { "clientCredentials": {} } }),
),
(
SecurityScheme::custom(serde_json::json!({ "type": "symmetricEncryption" })),
serde_json::json!({ "type": "symmetricEncryption" }),
),
];
for (scheme, expected) in cases {
assert_eq!(security_scheme_object(&scheme), expected);
}
}
#[test]
fn description_lands_in_the_rendered_object() {
let object = security_scheme_object(&SecurityScheme::plain().with_description("over TLS"));
assert_eq!(object["description"], "over TLS");
}
}