feat: update plugin service authoring templates
This commit is contained in:
@@ -468,15 +468,48 @@ mod tests {
|
||||
assert!(error.message().len() <= MAX_ERROR_MESSAGE_BYTES + "…".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_output_helper_builds_runtime_command_envelope() {
|
||||
let event = PluginIngressEvent {
|
||||
kind: "websocket_text".to_string(),
|
||||
source: "websocket:wss://example.test/socket".to_string(),
|
||||
ingress_name: "example_ws".to_string(),
|
||||
payload: json!({"text":"ping"}),
|
||||
created_at: "2026-06-25T00:00:00Z".to_string(),
|
||||
attempt: 1,
|
||||
correlation_id: "event-1".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(event.websocket_text(), Some("ping"));
|
||||
let output =
|
||||
ServiceOutput::websocket_send(&event, "reply-1", "wss://example.test/socket", "pong")
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(output).unwrap();
|
||||
|
||||
assert_eq!(value["accepted"], true);
|
||||
assert_eq!(value["output_commands"][0]["source_event_id"], "event-1");
|
||||
assert_eq!(value["output_commands"][0]["command_id"], "reply-1");
|
||||
assert_eq!(value["output_commands"][0]["kind"], "websocket_send");
|
||||
assert_eq!(value["output_commands"][0]["payload"]["text"], "pong");
|
||||
assert_eq!(
|
||||
value["output_commands"][0]["requested_at"],
|
||||
"2026-06-25T00:00:00Z"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_constants_match_current_world() {
|
||||
assert!(TOOL_WIT.contains("package yoi:plugin@1.0.0"));
|
||||
assert!(TOOL_WIT.contains("world tool"));
|
||||
assert!(TOOL_WIT.contains("export call"));
|
||||
assert_eq!(TOOL_WORLD, "yoi:plugin/tool@1.0.0");
|
||||
assert!(HOST_WIT.contains("interface https"));
|
||||
assert!(HOST_WIT.contains("interface request"));
|
||||
assert!(HOST_WIT.contains("interface websocket"));
|
||||
assert!(HOST_WIT.contains("interface fs"));
|
||||
assert!(HOST_WIT.contains("%list: func"));
|
||||
assert!(INSTANCE_WIT.contains("world instance"));
|
||||
assert!(INSTANCE_WIT.contains("export handle-ingress"));
|
||||
assert!(INSTANCE_WIT.contains("websocket_send"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,12 +521,48 @@ pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
|
||||
pub const INSTANCE_WIT: &str =
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PluginIngressEvent {
|
||||
pub kind: String,
|
||||
pub source: String,
|
||||
#[serde(default)]
|
||||
pub ingress_name: String,
|
||||
#[serde(default)]
|
||||
pub payload: Value,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default = "default_attempt")]
|
||||
pub attempt: u32,
|
||||
#[serde(default)]
|
||||
pub correlation_id: String,
|
||||
}
|
||||
|
||||
impl PluginIngressEvent {
|
||||
/// Return the text payload carried by a host-owned WebSocket ingress event.
|
||||
pub fn websocket_text(&self) -> Option<&str> {
|
||||
self.payload.get("text").and_then(Value::as_str)
|
||||
}
|
||||
|
||||
/// Build a `websocket_send` output command that replies through the
|
||||
/// host-owned Service WebSocket driver. The host still validates the target
|
||||
/// URL and matching grants before sending.
|
||||
pub fn websocket_send(
|
||||
&self,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<ServiceOutputCommand> {
|
||||
ServiceOutputCommand::new(
|
||||
self,
|
||||
command_id,
|
||||
ServiceOutputCommandKind::WebSocketSend,
|
||||
serde_json::json!({ "url": url.into(), "text": text.into() }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_attempt() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
@@ -519,12 +588,118 @@ impl PluginStatus {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ServiceOutputCommandKind {
|
||||
DiagnosticStatusUpdate,
|
||||
HostRequestDispatch,
|
||||
#[serde(rename = "websocket_send")]
|
||||
WebSocketSend,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutputCommand {
|
||||
pub correlation_id: String,
|
||||
pub source_event_id: String,
|
||||
pub command_id: String,
|
||||
pub kind: ServiceOutputCommandKind,
|
||||
pub payload: Value,
|
||||
pub requested_at: String,
|
||||
}
|
||||
|
||||
impl ServiceOutputCommand {
|
||||
pub fn new(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
kind: ServiceOutputCommandKind,
|
||||
payload: impl Serialize,
|
||||
) -> Result<Self> {
|
||||
let command_id = sanitize_command_id(command_id.into());
|
||||
if command_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command_id must not be empty",
|
||||
));
|
||||
}
|
||||
let source_event_id = event.correlation_id.clone();
|
||||
if source_event_id.is_empty() {
|
||||
return Err(ToolError::invalid_output(
|
||||
"service output command requires ingress event correlation_id",
|
||||
));
|
||||
}
|
||||
let requested_at = if event.created_at.is_empty() {
|
||||
"1970-01-01T00:00:00Z".to_string()
|
||||
} else {
|
||||
event.created_at.clone()
|
||||
};
|
||||
Ok(Self {
|
||||
correlation_id: sanitize_command_id(format!("{source_event_id}:{command_id}")),
|
||||
source_event_id,
|
||||
command_id,
|
||||
kind,
|
||||
payload: serde_json::to_value(payload).map_err(ToolError::serialization)?,
|
||||
requested_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ServiceOutput {
|
||||
#[serde(default)]
|
||||
pub accepted: bool,
|
||||
#[serde(default, skip_serializing_if = "Value::is_null")]
|
||||
pub data: Value,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output_commands: Vec<ServiceOutputCommand>,
|
||||
}
|
||||
|
||||
impl ServiceOutput {
|
||||
pub fn accepted(data: impl Serialize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
accepted: true,
|
||||
data: serde_json::to_value(data).map_err(ToolError::serialization)?,
|
||||
output_commands: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
accepted: true,
|
||||
data: Value::Null,
|
||||
output_commands: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_command(mut self, command: ServiceOutputCommand) -> Self {
|
||||
self.output_commands.push(command);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn websocket_send(
|
||||
event: &PluginIngressEvent,
|
||||
command_id: impl Into<String>,
|
||||
url: impl Into<String>,
|
||||
text: impl Into<String>,
|
||||
) -> Result<Self> {
|
||||
Ok(Self::empty().with_command(event.websocket_send(command_id, url, text)?))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_command_id(value: String) -> String {
|
||||
bounded_text(
|
||||
value
|
||||
.chars()
|
||||
.map(|ch| if ch.is_control() { '-' } else { ch })
|
||||
.collect(),
|
||||
128,
|
||||
)
|
||||
}
|
||||
|
||||
/// Rust-facing instance Plugin contract. Hosts call `start` once, then route
|
||||
/// Tool/Ingress surfaces through the same mutable instance.
|
||||
pub trait Plugin: Sized + 'static {
|
||||
fn start(config: Value) -> Result<Self>;
|
||||
fn handle_tool(&mut self, name: &str, input: Value) -> Result<ToolOutput>;
|
||||
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<Value>;
|
||||
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> Result<ServiceOutput>;
|
||||
fn status(&self) -> Result<PluginStatus> {
|
||||
Ok(PluginStatus::ready(Value::Null))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ const TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const TEMPLATE_README: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/README.md");
|
||||
const SERVICE_TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
|
||||
const SERVICE_TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
|
||||
const SERVICE_TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
|
||||
const SERVICE_TEMPLATE_README: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/README.md");
|
||||
const SAMPLE_LIB: &str = include_str!("../../../docs/examples/plugin-component-tool/lib.rs");
|
||||
const PDK_CARGO: &str = include_str!("../Cargo.toml");
|
||||
|
||||
@@ -25,7 +33,7 @@ fn rust_component_tool_template_has_expected_files() {
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-revision>\""));
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
@@ -45,6 +53,56 @@ fn rust_component_tool_template_has_expected_files() {
|
||||
assert!(TEMPLATE_README.contains("Component Model Tool Plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_component_service_template_has_event_output_pattern() {
|
||||
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value =
|
||||
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/instance@1.0.0")
|
||||
);
|
||||
assert_eq!(
|
||||
plugin["services"].as_array().expect("services array").len(),
|
||||
1
|
||||
);
|
||||
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
|
||||
assert!(
|
||||
ingress["event_kinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("websocket_text"))
|
||||
);
|
||||
assert!(
|
||||
ingress["sources"]
|
||||
.as_array()
|
||||
.expect("sources")
|
||||
.iter()
|
||||
.any(|source| source
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("websocket:wss://"))
|
||||
);
|
||||
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
|
||||
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
|
||||
assert!(SERVICE_TEMPLATE_README.contains("output command"));
|
||||
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documented_sample_uses_pdk_component_path() {
|
||||
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
@@ -57,8 +115,17 @@ fn documented_sample_uses_pdk_component_path() {
|
||||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_service_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-instance");
|
||||
}
|
||||
|
||||
fn cargo_check_template(template: &str) {
|
||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let template_dir = crate_dir.join("../../resources/plugin/templates/rust-component-tool");
|
||||
let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}"));
|
||||
let manifest_path = template_dir.join("Cargo.toml");
|
||||
let lock_path = template_dir.join("Cargo.lock");
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
Reference in New Issue
Block a user