use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
Patch,
}
impl fmt::Display for HttpMethod {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Get => write!(f, "get"),
Self::Post => write!(f, "post"),
Self::Put => write!(f, "put"),
Self::Delete => write!(f, "delete"),
Self::Patch => write!(f, "patch"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PropertyRef {
pub name: String,
pub data_type: String,
pub optional: bool,
}
impl PropertyRef {
pub fn required(name: impl Into<String>, data_type: impl Into<String>) -> Self {
Self {
name: name.into(),
data_type: data_type.into(),
optional: false,
}
}
pub fn optional(name: impl Into<String>, data_type: impl Into<String>) -> Self {
Self {
name: name.into(),
data_type: data_type.into(),
optional: true,
}
}
}
#[derive(Debug, Clone)]
pub struct SammOperation {
pub name: String,
pub input_props: Vec<PropertyRef>,
pub output_props: Vec<PropertyRef>,
}
impl SammOperation {
pub fn new(
name: impl Into<String>,
input_props: Vec<PropertyRef>,
output_props: Vec<PropertyRef>,
) -> Self {
Self {
name: name.into(),
input_props,
output_props,
}
}
}
#[derive(Debug, Clone)]
pub struct RestEndpoint {
pub method: HttpMethod,
pub path: String,
pub query_params: Vec<String>,
pub body_schema: Option<String>,
pub response_schema: String,
}
#[derive(Debug, Clone)]
pub struct MqttTopic {
pub topic_pattern: String,
pub qos: u8,
pub payload_schema: String,
}
#[derive(Debug, Clone)]
pub enum ApiMapping {
Rest(RestEndpoint),
Mqtt(MqttTopic),
Both(RestEndpoint, MqttTopic),
}
fn build_json_schema(props: &[PropertyRef]) -> String {
if props.is_empty() {
return r#"{"type":"object","properties":{}}"#.to_string();
}
let mut required: Vec<&str> = Vec::new();
let mut properties = Vec::new();
for p in props {
let json_type = xsd_to_json_type(&p.data_type);
properties.push(format!(r#""{}":{{"type":"{}"}}"#, p.name, json_type));
if !p.optional {
required.push(p.name.as_str());
}
}
let props_str = properties.join(",");
if required.is_empty() {
format!(r#"{{"type":"object","properties":{{{props_str}}}}}"#)
} else {
let req_str = required
.iter()
.map(|r| format!("\"{r}\""))
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"type":"object","properties":{{{props_str}}},"required":[{req_str}]}}"#)
}
}
fn xsd_to_json_type(dt: &str) -> &'static str {
match dt.to_lowercase().as_str() {
"string" | "xsd:string" | "http://www.w3.org/2001/xmlschema#string" => "string",
"integer" | "int" | "xsd:integer" | "xsd:int" | "long" | "short" => "integer",
"float" | "double" | "decimal" | "xsd:float" | "xsd:double" | "xsd:decimal" => "number",
"boolean" | "xsd:boolean" => "boolean",
_ => "string", }
}
fn to_kebab_case(s: &str) -> String {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
out.push('-');
}
out.push(ch.to_lowercase().next().unwrap_or(ch));
}
out
}
fn to_snake_case(s: &str) -> String {
let mut out = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
out.push('_');
}
out.push(ch.to_lowercase().next().unwrap_or(ch));
}
out
}
#[derive(Debug, Default)]
pub struct OperationMapper {
pub default_qos: u8,
}
impl OperationMapper {
pub fn new() -> Self {
Self { default_qos: 1 }
}
pub fn with_qos(qos: u8) -> Self {
Self {
default_qos: qos.min(2),
}
}
pub fn map_to_rest(&self, op: &SammOperation) -> RestEndpoint {
let path = format!("/operations/{}", to_kebab_case(&op.name));
let response_schema = build_json_schema(&op.output_props);
if op.input_props.is_empty() {
RestEndpoint {
method: HttpMethod::Get,
path,
query_params: Vec::new(),
body_schema: None,
response_schema,
}
} else {
let all_simple = op.input_props.iter().all(|p| {
matches!(
xsd_to_json_type(&p.data_type),
"string" | "integer" | "boolean"
)
});
if all_simple && op.input_props.len() <= 5 {
let query_params = op.input_props.iter().map(|p| p.name.clone()).collect();
RestEndpoint {
method: HttpMethod::Get,
path,
query_params,
body_schema: None,
response_schema,
}
} else {
let body_schema = Some(build_json_schema(&op.input_props));
RestEndpoint {
method: HttpMethod::Post,
path,
query_params: Vec::new(),
body_schema,
response_schema,
}
}
}
}
pub fn map_to_mqtt(&self, op: &SammOperation, base_topic: &str) -> MqttTopic {
let snake = to_snake_case(&op.name);
let base = base_topic.trim_end_matches('/');
let topic_pattern = format!("{base}/{snake}/request");
let input_schema = build_json_schema(&op.input_props);
let output_schema = build_json_schema(&op.output_props);
let payload_schema = format!(
r#"{{"type":"object","properties":{{"request":{input_schema},"response":{output_schema}}}}}"#
);
MqttTopic {
topic_pattern,
qos: self.default_qos,
payload_schema,
}
}
pub fn generate_openapi(&self, ops: &[SammOperation], base_url: &str) -> String {
let mut yaml = format!(
"openapi: \"3.0.3\"\ninfo:\n title: SAMM API\n version: \"1.0.0\"\nservers:\n - url: \"{base_url}\"\npaths:\n"
);
for op in ops {
let endpoint = self.map_to_rest(op);
let path = &endpoint.path;
let method = endpoint.method.to_string();
yaml.push_str(&format!(" \"{path}\":\n"));
yaml.push_str(&format!(" {method}:\n"));
yaml.push_str(&format!(
" summary: \"Invoke {} operation\"\n",
op.name
));
yaml.push_str(&format!(" operationId: \"{}\"\n", op.name));
if !endpoint.query_params.is_empty() {
yaml.push_str(" parameters:\n");
for qp in &endpoint.query_params {
let prop = op.input_props.iter().find(|p| p.name == *qp);
let json_type = prop
.map(|p| xsd_to_json_type(&p.data_type))
.unwrap_or("string");
let required = prop.map(|p| !p.optional).unwrap_or(true);
yaml.push_str(&format!(
" - name: \"{qp}\"\n in: query\n required: {required}\n schema:\n type: \"{json_type}\"\n"
));
}
}
if let Some(body) = &endpoint.body_schema {
yaml.push_str(" requestBody:\n");
yaml.push_str(" required: true\n");
yaml.push_str(" content:\n");
yaml.push_str(" application/json:\n");
yaml.push_str(&format!(" schema: {body}\n"));
}
yaml.push_str(" responses:\n");
yaml.push_str(" '200':\n");
yaml.push_str(" description: \"Successful response\"\n");
yaml.push_str(" content:\n");
yaml.push_str(" application/json:\n");
yaml.push_str(&format!(
" schema: {}\n",
endpoint.response_schema
));
}
yaml
}
pub fn generate_asyncapi(&self, ops: &[SammOperation], server: &str) -> String {
let mut yaml = format!(
"asyncapi: \"2.6.0\"\ninfo:\n title: SAMM MQTT API\n version: \"1.0.0\"\nservers:\n production:\n url: \"{server}\"\n protocol: mqtt\nchannels:\n"
);
for op in ops {
let mqtt = self.map_to_mqtt(op, "samm");
let topic = &mqtt.topic_pattern;
let qos = mqtt.qos;
yaml.push_str(&format!(" \"{topic}\":\n"));
yaml.push_str(&format!(
" description: \"Request channel for {} operation\"\n",
op.name
));
yaml.push_str(" publish:\n");
yaml.push_str(&format!(" operationId: \"publish{}\"\n", op.name));
yaml.push_str(" message:\n");
yaml.push_str(" payload:\n");
yaml.push_str(" type: object\n");
yaml.push_str(" bindings:\n");
yaml.push_str(" mqtt:\n");
yaml.push_str(&format!(" qos: {qos}\n"));
}
yaml
}
pub fn map_to_both(&self, op: &SammOperation, base_topic: &str) -> ApiMapping {
let rest = self.map_to_rest(op);
let mqtt = self.map_to_mqtt(op, base_topic);
ApiMapping::Both(rest, mqtt)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn mapper() -> OperationMapper {
OperationMapper::new()
}
fn simple_op() -> SammOperation {
SammOperation::new(
"getTemperature",
vec![PropertyRef::required("deviceId", "string")],
vec![PropertyRef::required("temperature", "double")],
)
}
fn write_op() -> SammOperation {
SammOperation::new(
"setConfiguration",
vec![
PropertyRef::required("key", "string"),
PropertyRef::required("value", "string"),
PropertyRef::optional("ttl", "integer"),
],
vec![PropertyRef::required("success", "boolean")],
)
}
fn no_input_op() -> SammOperation {
SammOperation::new(
"getStatus",
vec![],
vec![PropertyRef::required("status", "string")],
)
}
#[test]
fn test_property_ref_required() {
let p = PropertyRef::required("name", "string");
assert!(!p.optional);
assert_eq!(p.name, "name");
}
#[test]
fn test_property_ref_optional() {
let p = PropertyRef::optional("ttl", "integer");
assert!(p.optional);
}
#[test]
fn test_http_method_display() {
assert_eq!(HttpMethod::Get.to_string(), "get");
assert_eq!(HttpMethod::Post.to_string(), "post");
assert_eq!(HttpMethod::Put.to_string(), "put");
assert_eq!(HttpMethod::Delete.to_string(), "delete");
assert_eq!(HttpMethod::Patch.to_string(), "patch");
}
#[test]
fn test_map_to_rest_get_with_query_param() {
let op = simple_op();
let ep = mapper().map_to_rest(&op);
assert_eq!(ep.method, HttpMethod::Get);
assert!(ep.path.contains("get-temperature"));
assert!(ep.query_params.contains(&"deviceId".to_string()));
assert!(ep.body_schema.is_none());
}
#[test]
fn test_map_to_rest_no_input_is_get() {
let op = no_input_op();
let ep = mapper().map_to_rest(&op);
assert_eq!(ep.method, HttpMethod::Get);
assert!(ep.query_params.is_empty());
}
#[test]
fn test_map_to_rest_many_inputs_is_post() {
let op = SammOperation::new(
"complexOp",
(0..7)
.map(|i| PropertyRef::required(format!("param{i}"), "string"))
.collect(),
vec![PropertyRef::required("result", "boolean")],
);
let ep = mapper().map_to_rest(&op);
assert_eq!(ep.method, HttpMethod::Post);
assert!(ep.body_schema.is_some());
}
#[test]
fn test_map_to_rest_path_format() {
let op = simple_op();
let ep = mapper().map_to_rest(&op);
assert!(ep.path.starts_with("/operations/"));
}
#[test]
fn test_map_to_rest_response_schema_has_type() {
let op = simple_op();
let ep = mapper().map_to_rest(&op);
assert!(ep.response_schema.contains("temperature"));
assert!(ep.response_schema.contains("number"));
}
#[test]
fn test_map_to_rest_body_schema_content() {
let op = write_op();
let ep = mapper().map_to_rest(&op);
if ep.method == HttpMethod::Post {
let body = ep.body_schema.as_ref().expect("body schema");
assert!(body.contains("key"));
assert!(body.contains("value"));
} else {
assert_eq!(ep.method, HttpMethod::Get);
assert!(ep.query_params.contains(&"key".to_string()));
}
}
#[test]
fn test_map_to_rest_kebab_case_path() {
let op = SammOperation::new("getMyData", vec![], vec![]);
let ep = mapper().map_to_rest(&op);
assert!(ep.path.contains("get-my-data"));
}
#[test]
fn test_map_to_mqtt_topic_pattern() {
let op = simple_op();
let topic = mapper().map_to_mqtt(&op, "factory/device");
assert!(topic.topic_pattern.starts_with("factory/device/"));
assert!(topic.topic_pattern.ends_with("/request"));
}
#[test]
fn test_map_to_mqtt_qos_default() {
let op = simple_op();
let topic = mapper().map_to_mqtt(&op, "base");
assert_eq!(topic.qos, 1);
}
#[test]
fn test_map_to_mqtt_qos_custom() {
let m = OperationMapper::with_qos(2);
let op = simple_op();
let topic = m.map_to_mqtt(&op, "base");
assert_eq!(topic.qos, 2);
}
#[test]
fn test_map_to_mqtt_qos_capped() {
let m = OperationMapper::with_qos(5);
let op = simple_op();
let topic = m.map_to_mqtt(&op, "base");
assert!(topic.qos <= 2);
}
#[test]
fn test_map_to_mqtt_payload_schema() {
let op = simple_op();
let topic = mapper().map_to_mqtt(&op, "base");
assert!(topic.payload_schema.contains("request"));
assert!(topic.payload_schema.contains("response"));
}
#[test]
fn test_map_to_mqtt_snake_case_topic() {
let op = SammOperation::new("getTemperature", vec![], vec![]);
let topic = mapper().map_to_mqtt(&op, "base");
assert!(topic.topic_pattern.contains("get_temperature"));
}
#[test]
fn test_map_to_mqtt_base_topic_trailing_slash() {
let op = simple_op();
let topic = mapper().map_to_mqtt(&op, "factory/");
assert!(!topic.topic_pattern.contains("//"));
}
#[test]
fn test_generate_openapi_contains_openapi_version() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("openapi:"));
assert!(yaml.contains("3.0.3"));
}
#[test]
fn test_generate_openapi_contains_path() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("get-temperature"));
}
#[test]
fn test_generate_openapi_contains_operation_id() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("getTemperature"));
}
#[test]
fn test_generate_openapi_contains_base_url() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("https://api.example.org"));
}
#[test]
fn test_generate_openapi_multiple_ops() {
let ops = vec![simple_op(), no_input_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("get-temperature"));
assert!(yaml.contains("get-status"));
}
#[test]
fn test_generate_openapi_200_response() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("'200'"));
}
#[test]
fn test_generate_openapi_query_parameter() {
let ops = vec![simple_op()];
let yaml = mapper().generate_openapi(&ops, "https://api.example.org");
assert!(yaml.contains("deviceId"));
assert!(yaml.contains("in: query"));
}
#[test]
fn test_generate_asyncapi_contains_version() {
let ops = vec![simple_op()];
let yaml = mapper().generate_asyncapi(&ops, "mqtt://broker.example.org:1883");
assert!(yaml.contains("asyncapi:"));
assert!(yaml.contains("2.6.0"));
}
#[test]
fn test_generate_asyncapi_contains_server() {
let ops = vec![simple_op()];
let yaml = mapper().generate_asyncapi(&ops, "mqtt://broker.example.org:1883");
assert!(yaml.contains("mqtt://broker.example.org:1883"));
}
#[test]
fn test_generate_asyncapi_contains_channel() {
let ops = vec![simple_op()];
let yaml = mapper().generate_asyncapi(&ops, "mqtt://broker.example.org:1883");
assert!(yaml.contains("get_temperature"));
assert!(yaml.contains("request"));
}
#[test]
fn test_generate_asyncapi_qos() {
let ops = vec![simple_op()];
let yaml = mapper().generate_asyncapi(&ops, "mqtt://broker.example.org:1883");
assert!(yaml.contains("qos:"));
}
#[test]
fn test_map_to_both() {
let op = simple_op();
let mapping = mapper().map_to_both(&op, "factory");
assert!(matches!(mapping, ApiMapping::Both(_, _)));
}
#[test]
fn test_json_schema_empty_props() {
let schema = build_json_schema(&[]);
assert!(schema.contains("object"));
assert!(schema.contains("properties"));
}
#[test]
fn test_json_schema_required_field() {
let props = vec![PropertyRef::required("name", "string")];
let schema = build_json_schema(&props);
assert!(schema.contains("required"));
assert!(schema.contains("name"));
}
#[test]
fn test_json_schema_optional_not_in_required() {
let props = vec![PropertyRef::optional("ttl", "integer")];
let schema = build_json_schema(&props);
assert!(!schema.contains("\"required\""));
}
#[test]
fn test_xsd_to_json_type_string() {
assert_eq!(xsd_to_json_type("string"), "string");
assert_eq!(xsd_to_json_type("xsd:string"), "string");
}
#[test]
fn test_xsd_to_json_type_integer() {
assert_eq!(xsd_to_json_type("integer"), "integer");
assert_eq!(xsd_to_json_type("int"), "integer");
}
#[test]
fn test_xsd_to_json_type_double() {
assert_eq!(xsd_to_json_type("double"), "number");
assert_eq!(xsd_to_json_type("float"), "number");
assert_eq!(xsd_to_json_type("decimal"), "number");
}
#[test]
fn test_xsd_to_json_type_boolean() {
assert_eq!(xsd_to_json_type("boolean"), "boolean");
}
#[test]
fn test_xsd_to_json_type_unknown() {
assert_eq!(xsd_to_json_type("myCustomType"), "string");
}
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("getTemperature"), "get-temperature");
assert_eq!(to_kebab_case("setMyValue"), "set-my-value");
assert_eq!(to_kebab_case("simple"), "simple");
}
#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("getTemperature"), "get_temperature");
assert_eq!(to_snake_case("setMyValue"), "set_my_value");
}
#[test]
fn test_samm_operation_new() {
let op = SammOperation::new("myOp", vec![], vec![]);
assert_eq!(op.name, "myOp");
assert!(op.input_props.is_empty());
assert!(op.output_props.is_empty());
}
}