use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use crate::generated::api_types::CanvasAction;
use crate::types::SessionId;
pub type CanvasJsonSchema = serde_json::Map<String, Value>;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct CanvasDeclaration {
pub id: String,
pub display_name: String,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actions: Option<Vec<CanvasAction>>,
}
impl CanvasDeclaration {
pub fn new(
id: impl Into<String>,
display_name: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: id.into(),
display_name: display_name.into(),
description: description.into(),
input_schema: None,
actions: None,
}
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct CanvasOpenResponse {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CanvasHostContext {
#[serde(default)]
pub capabilities: CanvasHostCapabilities,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CanvasHostCapabilities {
#[serde(default)]
pub canvases: bool,
}
#[derive(Debug, Clone)]
pub struct CanvasOpenContext {
pub session_id: SessionId,
pub extension_id: String,
pub canvas_id: String,
pub instance_id: String,
pub input: Value,
pub host: Option<CanvasHostContext>,
}
#[derive(Debug, Clone)]
pub struct CanvasActionContext {
pub session_id: SessionId,
pub extension_id: String,
pub canvas_id: String,
pub instance_id: String,
pub action_name: String,
pub input: Value,
pub host: Option<CanvasHostContext>,
}
#[derive(Debug, Clone)]
pub struct CanvasLifecycleContext {
pub session_id: SessionId,
pub extension_id: String,
pub canvas_id: String,
pub instance_id: String,
pub host: Option<CanvasHostContext>,
}
#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[error("{code}: {message}")]
pub struct CanvasError {
pub code: String,
pub message: String,
}
impl CanvasError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
pub fn no_handler() -> Self {
Self::new(
"canvas_action_no_handler",
"No handler implemented for this canvas action",
)
}
}
pub type CanvasResult<T> = Result<T, CanvasError>;
#[async_trait]
pub trait CanvasHandler: Send + Sync {
async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult<CanvasOpenResponse>;
async fn on_action(&self, _ctx: CanvasActionContext) -> CanvasResult<Value> {
Err(CanvasError::no_handler())
}
async fn on_close(&self, _ctx: CanvasLifecycleContext) -> CanvasResult<()> {
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanvasProviderRequestParams {
pub session_id: SessionId,
pub extension_id: String,
pub canvas_id: String,
pub instance_id: String,
#[serde(default)]
pub input: Value,
#[serde(default)]
pub host: Option<CanvasHostContext>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct CanvasInvokeParams {
pub session_id: SessionId,
pub extension_id: String,
pub canvas_id: String,
pub instance_id: String,
pub action_name: String,
#[serde(default)]
pub input: Value,
#[serde(default)]
pub host: Option<CanvasHostContext>,
}
impl CanvasProviderRequestParams {
pub(crate) fn into_open_context(self) -> CanvasOpenContext {
CanvasOpenContext {
session_id: self.session_id,
extension_id: self.extension_id,
canvas_id: self.canvas_id,
instance_id: self.instance_id,
input: self.input,
host: self.host,
}
}
pub(crate) fn into_lifecycle_context(self) -> CanvasLifecycleContext {
CanvasLifecycleContext {
session_id: self.session_id,
extension_id: self.extension_id,
canvas_id: self.canvas_id,
instance_id: self.instance_id,
host: self.host,
}
}
}
impl CanvasInvokeParams {
pub(crate) fn into_action_context(self) -> CanvasActionContext {
CanvasActionContext {
session_id: self.session_id,
extension_id: self.extension_id,
canvas_id: self.canvas_id,
instance_id: self.instance_id,
action_name: self.action_name,
input: self.input,
host: self.host,
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
struct EchoHandler;
#[async_trait]
impl CanvasHandler for EchoHandler {
async fn on_open(&self, ctx: CanvasOpenContext) -> CanvasResult<CanvasOpenResponse> {
Ok(CanvasOpenResponse {
url: Some(format!("https://example.test/{}", ctx.canvas_id)),
title: Some("Echo".to_string()),
status: Some("ready".to_string()),
})
}
async fn on_action(&self, ctx: CanvasActionContext) -> CanvasResult<Value> {
Ok(json!({ "echoed": ctx.action_name, "input": ctx.input }))
}
}
#[test]
fn declaration_serializes_camel_case_and_skips_none() {
let decl = CanvasDeclaration {
id: "counter".to_string(),
display_name: "Counter".to_string(),
description: "Count things".to_string(),
input_schema: None,
actions: Some(vec![CanvasAction {
name: "increment".to_string(),
description: Some("bump".to_string()),
input_schema: None,
}]),
};
let value = serde_json::to_value(&decl).unwrap();
assert_eq!(value["id"], "counter");
assert_eq!(value["displayName"], "Counter");
assert_eq!(value["description"], "Count things");
assert_eq!(value["actions"][0]["name"], "increment");
}
#[tokio::test]
async fn handler_on_open_returns_response() {
let handler = EchoHandler;
let response = handler
.on_open(CanvasOpenContext {
session_id: SessionId::from("s1"),
extension_id: "project:echo".to_string(),
canvas_id: "echo".to_string(),
instance_id: "echo-1".to_string(),
input: json!({ "x": 1 }),
host: None,
})
.await
.unwrap();
assert_eq!(response.url.as_deref(), Some("https://example.test/echo"));
assert_eq!(response.title.as_deref(), Some("Echo"));
assert_eq!(response.status.as_deref(), Some("ready"));
}
#[tokio::test]
async fn handler_on_action_returns_value() {
let handler = EchoHandler;
let result = handler
.on_action(CanvasActionContext {
session_id: SessionId::from("s1"),
extension_id: "project:echo".to_string(),
canvas_id: "echo".to_string(),
instance_id: "inst-1".to_string(),
action_name: "shout".to_string(),
input: json!("hi"),
host: None,
})
.await
.unwrap();
assert_eq!(result["echoed"], "shout");
assert_eq!(result["input"], "hi");
}
#[tokio::test]
async fn default_on_action_returns_no_handler_error() {
struct OpenOnly;
#[async_trait]
impl CanvasHandler for OpenOnly {
async fn on_open(&self, _ctx: CanvasOpenContext) -> CanvasResult<CanvasOpenResponse> {
Ok(CanvasOpenResponse {
url: None,
title: None,
status: None,
})
}
}
let err = OpenOnly
.on_action(CanvasActionContext {
session_id: SessionId::from("s1"),
extension_id: "project:open-only".to_string(),
canvas_id: "x".to_string(),
instance_id: "x-1".to_string(),
action_name: "anything".to_string(),
input: Value::Null,
host: None,
})
.await
.unwrap_err();
assert_eq!(err.code, "canvas_action_no_handler");
}
}