gestalt-sdk 0.0.1-alpha.12

Rust SDK scaffolding and generated protocol bindings for Gestalt executable providers
Documentation
use std::collections::BTreeSet;
use std::path::Path;

use schemars::JsonSchema;
use serde_json::{Value as JsonValue, json};

use crate::error::{Error, Result};
use crate::generated::v1;

pub type Catalog = v1::Catalog;
pub type CatalogOperation = v1::CatalogOperation;
pub type CatalogParameter = v1::CatalogParameter;

impl Catalog {
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        let name = name.into();
        if !name.trim().is_empty() {
            self.name = name;
        }
        self
    }
}

pub fn write_catalog(catalog: &Catalog, path: impl AsRef<Path>) -> Result<()> {
    let path = path.as_ref();
    if let Some(parent) = path.parent()
        && !parent.as_os_str().is_empty()
    {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(&catalog_to_json_value(catalog))?;
    std::fs::write(path, json)?;
    Ok(())
}

fn catalog_to_json_value(catalog: &Catalog) -> JsonValue {
    let mut obj = serde_json::Map::new();
    obj.insert("name".to_owned(), json!(catalog.name));
    if !catalog.display_name.is_empty() {
        obj.insert("displayName".to_owned(), json!(catalog.display_name));
    }
    if !catalog.description.is_empty() {
        obj.insert("description".to_owned(), json!(catalog.description));
    }
    if !catalog.icon_svg.is_empty() {
        obj.insert("iconSvg".to_owned(), json!(catalog.icon_svg));
    }
    let ops: Vec<JsonValue> = catalog
        .operations
        .iter()
        .map(operation_to_json_value)
        .collect();
    obj.insert("operations".to_owned(), json!(ops));
    JsonValue::Object(obj)
}

fn operation_to_json_value(op: &CatalogOperation) -> JsonValue {
    let mut obj = serde_json::Map::new();
    obj.insert("id".to_owned(), json!(op.id));
    obj.insert("method".to_owned(), json!(op.method));
    if !op.title.is_empty() {
        obj.insert("title".to_owned(), json!(op.title));
    }
    if !op.description.is_empty() {
        obj.insert("description".to_owned(), json!(op.description));
    }
    if !op.input_schema.is_empty() {
        if let Ok(schema) = serde_json::from_str::<JsonValue>(&op.input_schema) {
            obj.insert("inputSchema".to_owned(), schema);
        }
    }
    if !op.output_schema.is_empty() {
        if let Ok(schema) = serde_json::from_str::<JsonValue>(&op.output_schema) {
            obj.insert("outputSchema".to_owned(), schema);
        }
    }
    if !op.tags.is_empty() {
        obj.insert("tags".to_owned(), json!(op.tags));
    }
    if !op.required_scopes.is_empty() {
        obj.insert("requiredScopes".to_owned(), json!(op.required_scopes));
    }
    if op.read_only {
        obj.insert("readOnly".to_owned(), json!(true));
    }
    if let Some(visible) = op.visible {
        obj.insert("visible".to_owned(), json!(visible));
    }
    if !op.transport.is_empty() {
        obj.insert("transport".to_owned(), json!(op.transport));
    }
    if !op.allowed_roles.is_empty() {
        obj.insert("allowedRoles".to_owned(), json!(op.allowed_roles));
    }
    if !op.parameters.is_empty() {
        let params: Vec<JsonValue> = op
            .parameters
            .iter()
            .map(|p| {
                let mut m = serde_json::Map::new();
                m.insert("name".to_owned(), json!(p.name));
                m.insert("type".to_owned(), json!(p.r#type));
                if !p.description.is_empty() {
                    m.insert("description".to_owned(), json!(p.description));
                }
                if p.required {
                    m.insert("required".to_owned(), json!(true));
                }
                if let Some(ref default) = p.default {
                    let val = proto_value_to_json(default.clone());
                    m.insert("default".to_owned(), val);
                }
                JsonValue::Object(m)
            })
            .collect();
        obj.insert("parameters".to_owned(), json!(params));
    }
    if let Some(ref ann) = op.annotations {
        let mut a = serde_json::Map::new();
        if let Some(v) = ann.read_only_hint {
            a.insert("readOnlyHint".to_owned(), json!(v));
        }
        if let Some(v) = ann.idempotent_hint {
            a.insert("idempotentHint".to_owned(), json!(v));
        }
        if let Some(v) = ann.destructive_hint {
            a.insert("destructiveHint".to_owned(), json!(v));
        }
        if let Some(v) = ann.open_world_hint {
            a.insert("openWorldHint".to_owned(), json!(v));
        }
        if !a.is_empty() {
            obj.insert("annotations".to_owned(), JsonValue::Object(a));
        }
    }
    JsonValue::Object(obj)
}

pub(crate) fn schema_json<T: JsonSchema>() -> Result<JsonValue> {
    serde_json::to_value(schemars::schema_for!(T)).map_err(Error::from)
}

pub(crate) fn schema_parameters(schema: &JsonValue) -> Vec<CatalogParameter> {
    let required = schema
        .get("required")
        .and_then(JsonValue::as_array)
        .map(|items| {
            items
                .iter()
                .filter_map(JsonValue::as_str)
                .map(ToOwned::to_owned)
                .collect::<BTreeSet<_>>()
        })
        .unwrap_or_default();

    let Some(properties) = schema.get("properties").and_then(JsonValue::as_object) else {
        return Vec::new();
    };

    properties
        .iter()
        .map(|(name, property)| CatalogParameter {
            name: name.clone(),
            r#type: schema_type(property),
            description: property
                .get("description")
                .and_then(JsonValue::as_str)
                .unwrap_or_default()
                .trim()
                .to_owned(),
            required: required.contains(name),
            default: property.get("default").map(json_value_to_proto_value),
        })
        .collect()
}

fn json_value_to_proto_value(value: &JsonValue) -> prost_types::Value {
    match value {
        JsonValue::Null => prost_types::Value {
            kind: Some(prost_types::value::Kind::NullValue(0)),
        },
        JsonValue::Bool(b) => prost_types::Value {
            kind: Some(prost_types::value::Kind::BoolValue(*b)),
        },
        JsonValue::Number(n) => prost_types::Value {
            kind: Some(prost_types::value::Kind::NumberValue(
                n.as_f64().unwrap_or(0.0),
            )),
        },
        JsonValue::String(s) => prost_types::Value {
            kind: Some(prost_types::value::Kind::StringValue(s.clone())),
        },
        JsonValue::Array(items) => prost_types::Value {
            kind: Some(prost_types::value::Kind::ListValue(
                prost_types::ListValue {
                    values: items.iter().map(json_value_to_proto_value).collect(),
                },
            )),
        },
        JsonValue::Object(map) => prost_types::Value {
            kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct {
                fields: map
                    .iter()
                    .map(|(k, v)| (k.clone(), json_value_to_proto_value(v)))
                    .collect(),
            })),
        },
    }
}

pub(crate) fn object_map(value: Option<prost_types::Struct>) -> serde_json::Map<String, JsonValue> {
    value
        .map(|structure| {
            structure
                .fields
                .into_iter()
                .map(|(key, value)| (key, proto_value_to_json(value)))
                .collect::<serde_json::Map<_, _>>()
        })
        .unwrap_or_default()
}

pub(crate) fn proto_value_to_json(value: prost_types::Value) -> JsonValue {
    match value.kind {
        Some(prost_types::value::Kind::NullValue(_)) | None => JsonValue::Null,
        Some(prost_types::value::Kind::NumberValue(number)) => json!(number),
        Some(prost_types::value::Kind::StringValue(text)) => json!(text),
        Some(prost_types::value::Kind::BoolValue(flag)) => json!(flag),
        Some(prost_types::value::Kind::StructValue(structure)) => {
            JsonValue::Object(object_map(Some(structure)))
        }
        Some(prost_types::value::Kind::ListValue(list)) => {
            JsonValue::Array(list.values.into_iter().map(proto_value_to_json).collect())
        }
    }
}

fn schema_type(schema: &JsonValue) -> String {
    if schema.get("properties").is_some() {
        return "object".to_owned();
    }
    if schema.get("items").is_some() {
        return "array".to_owned();
    }
    match schema.get("type") {
        Some(JsonValue::String(value)) => normalize_type(value).to_owned(),
        Some(JsonValue::Array(values)) => values
            .iter()
            .filter_map(JsonValue::as_str)
            .find(|value| *value != "null")
            .map(|value| normalize_type(value).to_owned())
            .unwrap_or_else(|| "object".to_owned()),
        _ => "object".to_owned(),
    }
}

fn normalize_type(value: &str) -> &'static str {
    match value {
        "integer" => "integer",
        "number" => "number",
        "boolean" => "boolean",
        "array" => "array",
        "object" => "object",
        _ => "string",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(serde::Deserialize, schemars::JsonSchema)]
    struct SampleInput {
        #[allow(dead_code)]
        #[schemars(description = "Search query")]
        query: String,
        #[allow(dead_code)]
        #[serde(default)]
        max_items: Option<u32>,
    }

    #[test]
    fn schema_parameters_derive_required_and_optional_fields() {
        let schema = schema_json::<SampleInput>().expect("schema");
        let mut params = schema_parameters(&schema);
        params.sort_by(|left, right| left.name.cmp(&right.name));

        assert_eq!(params.len(), 2);
        assert_eq!(params[0].name, "max_items");
        assert!(!params[0].required);
        assert_eq!(params[1].name, "query");
        assert!(params[1].required);
        assert_eq!(params[1].description, "Search query");
    }
}