refactor: rename crate fai-module-sdk -> chain-module-sdk
Some checks failed
CI / Linux x86_64 (Forgejo) (push) Failing after 2s

The SDK is product-scoped (Ch∆In's module SDK), not a generic vendor
crate — a future Brain SDK would collide. Crate + macros renamed to
chain-module-sdk[-macros]; repo + dir follow.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-16 11:26:37 +02:00
parent cd4457310b
commit 98e98e9371
14 changed files with 58 additions and 58 deletions

View file

@ -0,0 +1,23 @@
[package]
name = "chain-module-sdk"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
homepage.workspace = true
rust-version.workspace = true
description = "Stable, ergonomic surface for writing Ch∆In modules in Rust"
[dependencies]
chain-module-sdk-macros = { path = "../chain-module-sdk-macros" }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
wit-bindgen = { workspace = true }
[dev-dependencies]
sha2 = { workspace = true }
[lints]
workspace = true

View file

@ -0,0 +1,13 @@
//! Invocation context passed to a module on every call.
//!
//! Mirrors the WIT `invocation-context` record. The
//! `#[fai_module]` macro builds this from the WIT-generated value
//! before handing it to user code.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Context {
pub invocation_id: String,
pub capability_namespace: String,
pub capability_name: String,
pub deadline_ms: u64,
}

View file

@ -0,0 +1,49 @@
//! Module error type — mirrors the WIT `invocation-error` variant.
use thiserror::Error;
/// Error a module returns when invocation fails.
///
/// The variants match the WIT `invocation-error` cases one-to-one,
/// so a module's structured failure is preserved over the wire.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ModuleError {
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("resource exhausted: {0}")]
ResourceExhausted(String),
#[error("deadline exceeded")]
DeadlineExceeded,
#[error("internal error: {0}")]
Internal(String),
}
impl ModuleError {
pub fn invalid_input(message: impl Into<String>) -> Self {
ModuleError::InvalidInput(message.into())
}
pub fn permission_denied(message: impl Into<String>) -> Self {
ModuleError::PermissionDenied(message.into())
}
pub fn resource_exhausted(message: impl Into<String>) -> Self {
ModuleError::ResourceExhausted(message.into())
}
pub fn internal(message: impl Into<String>) -> Self {
ModuleError::Internal(message.into())
}
}
impl From<serde_json::Error> for ModuleError {
fn from(value: serde_json::Error) -> Self {
ModuleError::InvalidInput(format!("invalid JSON: {value}"))
}
}

View file

@ -0,0 +1,170 @@
//! Typed access to the inputs a module receives.
use serde::de::DeserializeOwned;
use crate::error::ModuleError;
use crate::payload::{BytesValue, FileRef, Payload};
/// The inputs a module receives, keyed by name as declared in the
/// flow YAML.
///
/// The `require_*` accessors validate both presence and variant in
/// one step, returning a clear `InvalidInput` error if either check
/// fails. Module authors should prefer them over `get` + manual
/// matching.
#[derive(Debug, Default)]
pub struct Inputs {
items: Vec<(String, Payload)>,
}
impl Inputs {
/// Build directly from a list of `(name, payload)` pairs.
/// Used by the `#[fai_module]` macro after WIT conversion.
#[doc(hidden)]
pub fn __from_pairs(items: Vec<(String, Payload)>) -> Self {
Self { items }
}
/// Build from a list of pairs — convenient for tests.
pub fn from_pairs<I, K, V>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<Payload>,
{
Self {
items: pairs
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
}
}
/// Number of inputs.
pub fn len(&self) -> usize {
self.items.len()
}
/// `true` if there are no inputs.
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Iterate over `(name, payload)` pairs.
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
self.items.iter().map(|(k, v)| (k.as_str(), v))
}
/// Look up a payload by name, returning `None` if absent.
pub fn get(&self, name: &str) -> Option<&Payload> {
self.items
.iter()
.find_map(|(k, v)| if k == name { Some(v) } else { None })
}
/// Require a `Text` input under `name`. Errors if missing or wrong variant.
pub fn require_text(&self, name: &str) -> Result<&str, ModuleError> {
match self.require(name)? {
Payload::Text(s) => Ok(s.as_str()),
other => Err(wrong_variant(name, "text", other)),
}
}
/// Require a `Bytes` input under `name`. Returns `(mime, data)`.
pub fn require_bytes(&self, name: &str) -> Result<&BytesValue, ModuleError> {
match self.require(name)? {
Payload::Bytes(b) => Ok(b),
other => Err(wrong_variant(name, "bytes", other)),
}
}
/// Require a `Json` input under `name`, returned as the raw JSON string.
pub fn require_json_str(&self, name: &str) -> Result<&str, ModuleError> {
match self.require(name)? {
Payload::Json(s) => Ok(s.as_str()),
other => Err(wrong_variant(name, "json", other)),
}
}
/// Require a `Json` input under `name`, parsed as `T`.
pub fn require_json<T: DeserializeOwned>(&self, name: &str) -> Result<T, ModuleError> {
let raw = self.require_json_str(name)?;
serde_json::from_str(raw).map_err(ModuleError::from)
}
/// Require a `FileRef` input under `name`.
pub fn require_file_ref(&self, name: &str) -> Result<&FileRef, ModuleError> {
match self.require(name)? {
Payload::FileRef(f) => Ok(f),
other => Err(wrong_variant(name, "file-ref", other)),
}
}
fn require(&self, name: &str) -> Result<&Payload, ModuleError> {
self.get(name)
.ok_or_else(|| ModuleError::InvalidInput(format!("missing input: {name}")))
}
}
fn wrong_variant(name: &str, expected: &str, got: &Payload) -> ModuleError {
ModuleError::InvalidInput(format!(
"input '{name}' has wrong type: expected {expected}, got {got}",
got = got.type_name(),
))
}
impl From<String> for Payload {
fn from(value: String) -> Self {
Payload::Text(value)
}
}
impl From<&str> for Payload {
fn from(value: &str) -> Self {
Payload::Text(value.to_string())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn require_text_ok() {
let i = Inputs::from_pairs([("greeting", "hello")]);
assert_eq!(i.require_text("greeting").unwrap(), "hello");
}
#[test]
fn require_text_missing_is_invalid_input() {
let i = Inputs::default();
let err = i.require_text("greeting").unwrap_err();
assert!(matches!(err, ModuleError::InvalidInput(_)));
assert!(format!("{err}").contains("missing input"));
}
#[test]
fn require_text_wrong_variant_is_invalid_input() {
let i = Inputs::from_pairs([("blob", Payload::bytes("text/plain", b"data".to_vec()))]);
let err = i.require_text("blob").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("expected text"), "msg = {msg}");
assert!(msg.contains("got bytes"), "msg = {msg}");
}
#[test]
fn require_json_parses_into_target() {
#[derive(serde::Deserialize, PartialEq, Debug)]
struct Goal {
text: String,
}
let i = Inputs::from_pairs([("goal", Payload::Json(r#"{"text":"hi"}"#.to_string()))]);
assert_eq!(
i.require_json::<Goal>("goal").unwrap(),
Goal {
text: "hi".to_string()
}
);
}
}

View file

@ -0,0 +1,55 @@
//! Ergonomic Rust surface for writing Ch∆In modules.
//!
//! A module written against this crate looks like:
//!
//! ```ignore
//! use chain_module_sdk::prelude::*;
//!
//! #[fai_module]
//! fn invoke(_ctx: Context, inputs: Inputs) -> Result<Outputs, ModuleError> {
//! let text = inputs.require_text("input")?;
//! Ok(Outputs::new().with_text("output", format!("got: {text}")))
//! }
//! ```
//!
//! The macro generates the WIT bindings, the `Guest` impl, and the
//! `wit_bindgen::export!` invocation behind the scenes. Module authors
//! never touch `wit_bindgen` directly.
//!
//! # Stability
//!
//! This crate insulates modules from the underlying WIT contract
//! (`chain:platform`, frozen at v1.0). The WIT may evolve in additive
//! minor versions or, eventually, a coordinated v2.0; the SDK absorbs
//! that change so module code keeps compiling.
mod context;
mod error;
mod inputs;
mod outputs;
mod payload;
pub use context::Context;
pub use error::ModuleError;
pub use inputs::Inputs;
pub use outputs::Outputs;
pub use payload::{BytesValue, FileRef, Payload};
pub use chain_module_sdk_macros::fai_module;
/// Convenience prelude — `use chain_module_sdk::prelude::*;` brings
/// every type a typical module needs into scope.
pub mod prelude {
pub use super::{
BytesValue, Context, FileRef, Inputs, ModuleError, Outputs, Payload, fai_module,
};
}
/// Internal re-exports the `#[fai_module]` macro expansion depends on.
///
/// Not part of the public API. Anything in this module may change
/// between SDK patch releases.
#[doc(hidden)]
pub mod __rt {
pub use wit_bindgen;
}

View file

@ -0,0 +1,119 @@
//! Builder for the outputs a module returns.
use serde::Serialize;
use crate::error::ModuleError;
use crate::payload::Payload;
/// Output values a module produces, keyed by name as expected by
/// the calling flow step.
#[derive(Debug, Default)]
pub struct Outputs {
items: Vec<(String, Payload)>,
}
impl Outputs {
pub fn new() -> Self {
Self::default()
}
pub fn with_text(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.items.push((name.into(), Payload::Text(value.into())));
self
}
pub fn with_json_str(mut self, name: impl Into<String>, raw: impl Into<String>) -> Self {
self.items.push((name.into(), Payload::Json(raw.into())));
self
}
/// Serialize `value` as JSON and add it under `name`. Errors are
/// returned as `ModuleError::Internal` since serialization of a
/// well-typed value should not fail under normal conditions.
pub fn with_json<T: Serialize>(
mut self,
name: impl Into<String>,
value: &T,
) -> Result<Self, ModuleError> {
let raw = serde_json::to_string(value).map_err(|e| {
ModuleError::Internal(format!("failed to serialize output as JSON: {e}"))
})?;
self.items.push((name.into(), Payload::Json(raw)));
Ok(self)
}
pub fn with_bytes(
mut self,
name: impl Into<String>,
mime_type: impl Into<String>,
data: impl Into<Vec<u8>>,
) -> Self {
self.items
.push((name.into(), Payload::bytes(mime_type, data)));
self
}
pub fn with_payload(mut self, name: impl Into<String>, payload: Payload) -> Self {
self.items.push((name.into(), payload));
self
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &Payload)> {
self.items.iter().map(|(k, v)| (k.as_str(), v))
}
/// Consume the builder into the underlying `(name, payload)` list.
/// Used by the `#[fai_module]` macro after WIT conversion.
#[doc(hidden)]
pub fn __into_pairs(self) -> Vec<(String, Payload)> {
self.items
}
/// Public consume for tests / advanced callers.
pub fn into_pairs(self) -> Vec<(String, Payload)> {
self.items
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::panic)]
use super::*;
#[test]
fn fluent_builder_collects_items_in_order() {
let out = Outputs::new().with_text("greeting", "hi").with_bytes(
"blob",
"application/octet-stream",
vec![1u8, 2, 3],
);
let pairs = out.into_pairs();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].0, "greeting");
assert!(matches!(pairs[1].1, Payload::Bytes(_)));
}
#[test]
fn with_json_serializes_typed_value() {
#[derive(serde::Serialize)]
struct Reply {
ok: bool,
}
let out = Outputs::new()
.with_json("reply", &Reply { ok: true })
.unwrap();
let pairs = out.into_pairs();
match &pairs[0].1 {
Payload::Json(s) => assert!(s.contains(r#""ok":true"#)),
other => panic!("expected Json, got {other:?}"),
}
}
}

View file

@ -0,0 +1,76 @@
//! Module-side mirror of the WIT `payload` variant.
//!
//! The shape is fixed by the v1.0 WIT freeze. Field-by-field
//! conversion to and from the WIT-generated type happens inside
//! the `#[fai_module]` macro expansion in the user crate.
use serde::{Deserialize, Serialize};
/// A typed value flowing between the host and a module.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Payload {
/// Plain UTF-8 text.
Text(String),
/// JSON encoded as a string.
Json(String),
/// Raw bytes with a MIME type.
Bytes(BytesValue),
/// File reference by URI.
FileRef(FileRef),
}
/// Inline byte content with a MIME type.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BytesValue {
pub mime_type: String,
pub data: Vec<u8>,
}
/// File reference by URI plus integrity metadata.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub uri: String,
pub mime_type: String,
pub size_bytes: u64,
pub sha256: String,
}
impl Payload {
pub fn text(value: impl Into<String>) -> Self {
Payload::Text(value.into())
}
pub fn json(value: impl Into<String>) -> Self {
Payload::Json(value.into())
}
pub fn bytes(mime_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
Payload::Bytes(BytesValue {
mime_type: mime_type.into(),
data: data.into(),
})
}
pub fn file_ref(
uri: impl Into<String>,
mime_type: impl Into<String>,
size_bytes: u64,
sha256: impl Into<String>,
) -> Self {
Payload::FileRef(FileRef {
uri: uri.into(),
mime_type: mime_type.into(),
size_bytes,
sha256: sha256.into(),
})
}
pub fn type_name(&self) -> &'static str {
match self {
Payload::Text(_) => "text",
Payload::Json(_) => "json",
Payload::Bytes(_) => "bytes",
Payload::FileRef(_) => "file-ref",
}
}
}

View file

@ -0,0 +1,53 @@
//! Snapshot test for the WIT mirror.
//!
//! The SDK ships its own copy of `wit/world.wit` so the proc-macro
//! can embed it via `include_str!` at compile time. The hash MUST
//! match the platform's frozen v1.0 hash. If this test fails, the
//! mirror has drifted and the SDK cannot guarantee binary
//! compatibility with the platform.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use sha2::{Digest, Sha256};
/// Identical to FROZEN_WIT_SHA256 in
/// fai/platform :: crates/fai_runtime/tests/wit_freeze.rs.
/// Update both repos in lockstep, never one alone.
const PLATFORM_FROZEN_WIT_SHA256: &str =
"6f4e2266a4264832a16fe5a3704622cd978b290b90f3300c3ed236d98b689b5f";
fn world_wit_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("crates parent")
.parent()
.expect("workspace root")
.join("wit")
.join("world.wit")
}
#[test]
fn wit_mirror_matches_platform_frozen_hash() {
let path = world_wit_path();
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual = format!("{:x}", hasher.finalize());
assert_eq!(
actual, PLATFORM_FROZEN_WIT_SHA256,
"\n\nwit/world.wit in this repo has drifted from the platform's\n\
frozen v1.0 hash. The SDK MUST mirror the platform exactly\n\
or modules built with this SDK will not be wire-compatible.\n\n\
If the platform intentionally bumped to a new minor (e.g.\n\
v1.1 with additive changes), update BOTH:\n\
1. wit/world.wit here to match the platform copy\n\
2. PLATFORM_FROZEN_WIT_SHA256 in this test\n\n\
If the platform is still on the old hash, revert this WIT\n\
change.\n\n\
actual hash: {actual}\n"
);
}