use crate::error::{Error, Result};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::fmt;
use std::sync::Arc;
pub const PORTABLE: &str = "portable_json";
pub const DBOS_JSON: &str = "DBOS_JSON";
const NIL_MARKER: &str = "__DBOS_NIL";
pub trait SerializerCodec: Send + Sync {
fn name(&self) -> &str;
fn encode(&self, value: &Value) -> Result<String>;
fn decode(&self, stored: &str) -> Result<Value>;
}
#[derive(Clone, Default)]
pub enum Serializer {
#[default]
Json,
Portable,
Custom(Arc<dyn SerializerCodec>),
}
impl fmt::Debug for Serializer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Serializer::Json => f.write_str("Json"),
Serializer::Portable => f.write_str("Portable"),
Serializer::Custom(c) => write!(f, "Custom({:?})", c.name()),
}
}
}
impl Serializer {
pub fn custom(codec: Arc<dyn SerializerCodec>) -> Self {
Serializer::Custom(codec)
}
pub fn name(&self) -> &str {
match self {
Serializer::Json => DBOS_JSON,
Serializer::Portable => PORTABLE,
Serializer::Custom(c) => c.name(),
}
}
pub fn encode(&self, value: &Value) -> Result<String> {
match self {
Serializer::Portable => {
if value.is_null() {
return Ok("null".to_string());
}
Ok(serde_json::to_string(value)?)
}
Serializer::Json => {
if value.is_null() {
return Ok(NIL_MARKER.to_string());
}
Ok(STANDARD.encode(serde_json::to_vec(value)?))
}
Serializer::Custom(c) => c.encode(value),
}
}
}
pub fn decode(serializer: &Serializer, format: Option<&str>, stored: &str) -> Result<Value> {
match format.unwrap_or("") {
PORTABLE => {
if stored == "null" {
return Ok(Value::Null);
}
Ok(serde_json::from_str(stored)?)
}
other if matches!(serializer, Serializer::Custom(c) if c.name() == other) => {
match serializer {
Serializer::Custom(c) => c.decode(stored),
_ => unreachable!(),
}
}
"" | DBOS_JSON => {
if stored == NIL_MARKER {
return Ok(Value::Null);
}
let bytes = STANDARD.decode(stored).map_err(|e| {
Error::Serialization(format!("invalid base64 in {DBOS_JSON} value: {e}"))
})?;
Ok(serde_json::from_slice(&bytes)?)
}
other => Err(Error::Serialization(format!(
"value uses serialization format {other:?}, which this SDK cannot decode; \
use {PORTABLE} for cross-language interop, or configure a matching custom serializer"
))),
}
}
pub fn decode_opt(
serializer: &Serializer,
format: Option<&str>,
stored: Option<&str>,
) -> Result<Option<Value>> {
match stored {
Some(s) => Ok(Some(decode(serializer, format, s)?)),
None => Ok(None),
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PortableWorkflowArgs {
#[serde(rename = "positionalArgs", default)]
pub positional_args: Vec<Value>,
#[serde(rename = "namedArgs", default)]
pub named_args: Map<String, Value>,
}
impl PortableWorkflowArgs {
pub fn single(arg: Value) -> Self {
Self {
positional_args: vec![arg],
named_args: Map::new(),
}
}
}
fn as_envelope(value: &Value) -> Option<PortableWorkflowArgs> {
let obj = value.as_object()?;
let positional_args = obj.get("positionalArgs")?.as_array()?.clone();
let named_args = match obj.get("namedArgs") {
Some(Value::Object(m)) => m.clone(),
None | Some(Value::Null) => Map::new(),
Some(_) => return None,
};
Some(PortableWorkflowArgs {
positional_args,
named_args,
})
}
pub fn encode_input(serializer: &Serializer, value: &Value) -> Result<String> {
if matches!(serializer, Serializer::Portable) {
let envelope =
as_envelope(value).unwrap_or_else(|| PortableWorkflowArgs::single(value.clone()));
return Ok(serde_json::to_string(&envelope)?);
}
serializer.encode(value)
}
pub fn decode_input(serializer: &Serializer, format: Option<&str>, stored: &str) -> Result<Value> {
let value = decode(serializer, format, stored)?;
if format == Some(PORTABLE) {
Ok(first_positional(value))
} else {
Ok(value)
}
}
pub fn decode_input_opt(
serializer: &Serializer,
format: Option<&str>,
stored: Option<&str>,
) -> Result<Option<Value>> {
match stored {
Some(s) => Ok(Some(decode_input(serializer, format, s)?)),
None => Ok(None),
}
}
fn first_positional(value: Value) -> Value {
match as_envelope(&value) {
Some(mut env) if !env.positional_args.is_empty() => env.positional_args.swap_remove(0),
Some(_) => Value::Null,
None => value,
}
}
pub const PORTABLE_ERROR_NAME: &str = "Portable Error";
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PortableWorkflowError {
pub name: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub code: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
}
pub fn encode_error(serializer: &Serializer, err: &Error) -> String {
if matches!(serializer, Serializer::Portable) {
let env = match err {
Error::Portable(pe) => (**pe).clone(),
other => PortableWorkflowError {
name: PORTABLE_ERROR_NAME.to_string(),
message: other.to_string(),
code: None,
data: None,
},
};
return serde_json::to_string(&env).unwrap_or_else(|_| err.to_string());
}
err.to_string()
}
pub fn decode_error(format: Option<&str>, stored: &str) -> (String, Option<PortableWorkflowError>) {
if format == Some(PORTABLE) {
if let Ok(env) = serde_json::from_str::<PortableWorkflowError>(stored) {
return (env.message.clone(), Some(env));
}
}
(stored.to_string(), None)
}
pub fn decode_error_opt(
format: Option<&str>,
stored: Option<&str>,
) -> (Option<String>, Option<PortableWorkflowError>) {
match stored {
Some(s) => {
let (msg, env) = decode_error(format, s);
(Some(msg), env)
}
None => (None, None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn json_roundtrip_is_base64() {
let v = json!({"a": 1, "b": "x"});
let enc = Serializer::Json.encode(&v).unwrap();
assert!(!enc.starts_with('{'));
assert_eq!(decode(&Serializer::Json, Some(DBOS_JSON), &enc).unwrap(), v);
assert_eq!(decode(&Serializer::Json, None, &enc).unwrap(), v);
}
#[test]
fn portable_roundtrip_is_plain_json() {
let v = json!({"a": 1});
let enc = Serializer::Portable.encode(&v).unwrap();
assert_eq!(enc, r#"{"a":1}"#);
assert_eq!(decode(&Serializer::Json, Some(PORTABLE), &enc).unwrap(), v);
}
#[test]
fn nil_markers_roundtrip() {
assert_eq!(Serializer::Json.encode(&Value::Null).unwrap(), NIL_MARKER);
assert_eq!(Serializer::Portable.encode(&Value::Null).unwrap(), "null");
assert_eq!(
decode(&Serializer::Json, Some(DBOS_JSON), NIL_MARKER).unwrap(),
Value::Null
);
assert_eq!(
decode(&Serializer::Json, Some(PORTABLE), "null").unwrap(),
Value::Null
);
}
#[test]
fn unknown_format_errors() {
assert!(decode(&Serializer::Json, Some("DBOS_PICKLE"), "abc").is_err());
assert!(decode(&Serializer::Json, Some("some_other_format"), "abc").is_err());
}
#[test]
fn portable_input_wraps_single_value() {
let enc = encode_input(&Serializer::Portable, &json!("hello")).unwrap();
assert_eq!(enc, r#"{"positionalArgs":["hello"],"namedArgs":{}}"#);
assert_eq!(
decode_input(&Serializer::Json, Some(PORTABLE), &enc).unwrap(),
json!("hello")
);
}
#[test]
fn portable_input_matches_cross_language_canonical() {
let args = PortableWorkflowArgs {
positional_args: vec![
json!("hello-interop"),
json!(42),
json!("2025-06-15T10:30:00.000Z"),
json!(["alpha", "beta", "gamma"]),
json!({"key1": "value1", "key2": 99, "nested": {"deep": true}}),
json!(true),
json!(null),
],
named_args: Map::new(),
};
let enc =
encode_input(&Serializer::Portable, &serde_json::to_value(&args).unwrap()).unwrap();
assert_eq!(
enc,
r#"{"positionalArgs":["hello-interop",42,"2025-06-15T10:30:00.000Z",["alpha","beta","gamma"],{"key1":"value1","key2":99,"nested":{"deep":true}},true,null],"namedArgs":{}}"#
);
}
#[test]
fn portable_input_keeps_explicit_named_args() {
let mut named = Map::new();
named.insert("name".to_string(), json!("test"));
let args = PortableWorkflowArgs {
positional_args: vec![json!(1)],
named_args: named,
};
let enc =
encode_input(&Serializer::Portable, &serde_json::to_value(&args).unwrap()).unwrap();
assert_eq!(enc, r#"{"positionalArgs":[1],"namedArgs":{"name":"test"}}"#);
}
#[test]
fn portable_input_reads_either_field_order() {
for stored in [
r#"{"positionalArgs":["x"],"namedArgs":{}}"#,
r#"{"namedArgs":{},"positionalArgs":["x"]}"#,
r#"{"positionalArgs":["x"]}"#, ] {
assert_eq!(
decode_input(&Serializer::Json, Some(PORTABLE), stored).unwrap(),
json!("x")
);
}
assert_eq!(
decode_input(
&Serializer::Json,
Some(PORTABLE),
r#"{"positionalArgs":[],"namedArgs":{}}"#
)
.unwrap(),
Value::Null
);
}
#[test]
fn portable_error_wraps_under_generic_name() {
let enc = encode_error(&Serializer::Portable, &Error::app("boom"));
assert_eq!(enc, r#"{"name":"Portable Error","message":"boom"}"#);
let (msg, info) = decode_error(Some(PORTABLE), &enc);
assert_eq!(msg, "boom");
let info = info.expect("portable error decodes to a structured envelope");
assert_eq!(info.name, PORTABLE_ERROR_NAME);
assert_eq!(info.message, "boom");
assert!(info.code.is_none() && info.data.is_none());
}
#[test]
fn portable_error_keeps_typed_name_and_code() {
let err = Error::Portable(Box::new(PortableWorkflowError {
name: "ValidationError".to_string(),
message: "invalid input".to_string(),
code: Some(json!(400)),
data: Some(json!({"field": "email"})),
}));
let enc = encode_error(&Serializer::Portable, &err);
let (msg, info) = decode_error(Some(PORTABLE), &enc);
assert_eq!(msg, "invalid input");
let info = info.expect("typed portable error decodes");
assert_eq!(info.name, "ValidationError");
assert_eq!(info.code, Some(json!(400)));
assert_eq!(info.data, Some(json!({"field": "email"})));
}
#[test]
fn json_error_stays_bare() {
assert_eq!(encode_error(&Serializer::Json, &Error::app("boom")), "boom");
assert_eq!(
encode_error(&Serializer::Json, &Error::portable("Validation", "boom")),
"boom"
);
assert_eq!(
decode_error(Some(DBOS_JSON), "boom"),
("boom".to_string(), None)
);
assert_eq!(decode_error(None, "boom"), ("boom".to_string(), None));
}
#[test]
fn portable_error_reads_another_sdk_structured_error() {
let stored = r#"{"name":"ValidationError","message":"invalid input","code":400,"data":{"field":"email"}}"#;
let (msg, info) = decode_error(Some(PORTABLE), stored);
assert_eq!(msg, "invalid input");
let info = info.expect("structured envelope decodes");
assert_eq!(info.name, "ValidationError");
assert_eq!(info.code, Some(json!(400)));
assert_eq!(info.data, Some(json!({"field": "email"})));
}
#[test]
fn portable_error_falls_back_on_non_envelope() {
assert_eq!(
decode_error(Some(PORTABLE), "just a string"),
("just a string".to_string(), None)
);
assert_eq!(decode_error_opt(Some(PORTABLE), None), (None, None));
}
#[test]
fn json_input_is_not_enveloped() {
let enc = encode_input(&Serializer::Json, &json!("hello")).unwrap();
assert_eq!(enc, Serializer::Json.encode(&json!("hello")).unwrap());
assert_eq!(
decode_input(&Serializer::Json, Some(DBOS_JSON), &enc).unwrap(),
json!("hello")
);
let shaped = json!({"positionalArgs": ["x"], "namedArgs": {}});
let enc2 = encode_input(&Serializer::Json, &shaped).unwrap();
assert_eq!(
decode_input(&Serializer::Json, Some(DBOS_JSON), &enc2).unwrap(),
shaped
);
}
}