wip-interface 0.2.0

Public Rust authoring API for WIP interfaces and operation value codecs
Documentation
use std::collections::BTreeMap;

use wip_interface::wip_protocol::{TypeExpr, UNION_CASE_FIELD, UNION_VALUE_FIELD, Value};
use wip_interface::{Bytes, Entry, JsonMember, JsonValue, WipType};

/// A request record.
#[derive(Debug, Clone, PartialEq, WipType)]
struct Request {
    /// Required title.
    title: String,
    /// Optional priority.
    priority: Option<i32>,
    /// Related Worldspace entries.
    related: Vec<Entry>,
    /// Opaque bytes.
    body: Bytes,
    /// Arbitrary metadata.
    metadata: JsonValue,
}

/// Available colors.
#[derive(Debug, Clone, PartialEq, WipType)]
enum Color {
    /// Warm red.
    Red,
    /// Cool blue.
    Blue,
}

/// A receipt record.
#[derive(Debug, Clone, PartialEq, WipType)]
struct Receipt {
    /// Receipt number.
    number: u32,
}

/// Application-level publication outcome.
#[derive(Debug, Clone, PartialEq, WipType)]
enum PublishOutcome {
    /// Work has not completed and carries no payload.
    Pending,
    /// Application success.
    Published(Receipt),
    /// Domain rejection, not a host failure.
    Rejected(String),
}

fn field<'a>(record: &'a BTreeMap<String, Value>, name: &str) -> &'a Value {
    record
        .get(name)
        .unwrap_or_else(|| panic!("missing `{name}`"))
}

#[test]
fn primitive_unit_record_list_entry_and_json_values_round_trip() {
    assert_eq!(().encode().unwrap(), Value::Unit);
    assert_eq!(<()>::decode(Value::Unit).unwrap(), ());
    assert_eq!(<()>::type_expr(), TypeExpr::Unit);

    let request = Request {
        title: "hello".into(),
        priority: None,
        related: vec![Entry::new("/articles/1").unwrap()],
        body: Bytes(vec![1, 2, 3]),
        metadata: JsonValue::Object(vec![JsonMember {
            name: "source".into(),
            value: JsonValue::Array(vec![JsonValue::String("test".into()), JsonValue::Null]),
        }]),
    };

    let encoded = request.clone().encode().unwrap();
    let Value::Record(fields) = &encoded else {
        panic!("record derive must encode a raw protocol record")
    };
    assert!(!fields.contains_key("priority"));
    assert_eq!(
        field(fields, "related"),
        &Value::List(vec![Value::String("/articles/1".into())])
    );
    assert_eq!(
        field(fields, "metadata"),
        &Value::Record(BTreeMap::from([(
            "source".into(),
            Value::List(vec![Value::String("test".into()), Value::Unit]),
        )]))
    );
    assert_eq!(Request::decode(encoded).unwrap(), request);

    let number = 42.5_f64;
    assert_eq!(f64::type_expr(), TypeExpr::Number);
    assert_eq!(f64::decode(number.encode().unwrap()).unwrap(), number);

    let narrow_number = 1.5_f32;
    assert_eq!(f32::type_expr(), TypeExpr::Number);
    assert_eq!(
        f32::decode(narrow_number.encode().unwrap()).unwrap(),
        narrow_number
    );
}

#[test]
fn enums_are_strings_and_unions_are_discriminated_records() {
    for color in [Color::Red, Color::Blue] {
        let encoded = color.clone().encode().unwrap();
        assert!(matches!(encoded, Value::String(_)));
        assert_eq!(Color::decode(encoded).unwrap(), color);
    }

    let pending = PublishOutcome::Pending.encode().unwrap();
    assert_eq!(
        pending,
        Value::Record(BTreeMap::from([(
            UNION_CASE_FIELD.into(),
            Value::String("Pending".into()),
        )]))
    );

    let published = PublishOutcome::Published(Receipt { number: 7 })
        .encode()
        .unwrap();
    let Value::Record(fields) = &published else {
        panic!("union must encode as a record")
    };
    assert_eq!(
        fields.get(UNION_CASE_FIELD),
        Some(&Value::String("Published".into()))
    );
    assert!(matches!(
        fields.get(UNION_VALUE_FIELD),
        Some(Value::Record(_))
    ));

    for outcome in [
        PublishOutcome::Pending,
        PublishOutcome::Published(Receipt { number: 7 }),
        PublishOutcome::Rejected("policy".into()),
    ] {
        let encoded = outcome.clone().encode().unwrap();
        assert_eq!(PublishOutcome::decode(encoded).unwrap(), outcome);
    }
}

#[test]
fn derived_declarations_use_canonical_named_type_expressions() {
    let mut declarations = Vec::new();
    Request::collect_declarations(&mut declarations);
    Color::collect_declarations(&mut declarations);
    PublishOutcome::collect_declarations(&mut declarations);

    let request = declarations
        .iter()
        .find(|declaration| declaration.name == "Request")
        .unwrap();
    assert_eq!(
        request.documentation.as_ref().unwrap().summary,
        "A request record."
    );
    let TypeExpr::Record { fields } = &request.definition else {
        panic!("Request must be a record declaration")
    };
    assert_eq!(
        fields[0].documentation.as_ref().unwrap().summary,
        "Required title."
    );
    assert!(fields[0].required);
    assert!(!fields[1].required);
    assert_eq!(fields[1].r#type, TypeExpr::Integer);
    assert_eq!(
        fields[2].r#type,
        TypeExpr::List {
            items: Box::new(TypeExpr::Entry),
        }
    );

    let color = declarations
        .iter()
        .find(|declaration| declaration.name == "Color")
        .unwrap();
    let TypeExpr::Enum { cases } = &color.definition else {
        panic!("Color must be an enum declaration")
    };
    assert_eq!(
        cases[0].documentation.as_ref().unwrap().summary,
        "Warm red."
    );

    let outcome = declarations
        .iter()
        .find(|declaration| declaration.name == "PublishOutcome")
        .unwrap();
    let TypeExpr::Union { cases } = &outcome.definition else {
        panic!("PublishOutcome must be a union declaration")
    };
    assert_eq!(cases[0].payload, None);
    assert_eq!(
        cases[1].payload,
        Some(TypeExpr::Named {
            name: "Receipt".into(),
        })
    );
    assert!(
        declarations
            .iter()
            .any(|declaration| declaration.name == "Receipt")
    );
}

#[test]
fn codecs_reject_invalid_boundaries_and_json_bytes() {
    assert!(i64::MAX.encode().is_err());
    assert!(f64::NAN.encode().is_err());
    assert!(Request::decode(Value::Record(BTreeMap::new())).is_err());
    assert!(
        Request::decode(Value::Record(BTreeMap::from([(
            "unknown".into(),
            Value::String("value".into()),
        )])))
        .is_err()
    );
    assert!(Color::decode(Value::String("Green".into())).is_err());
    assert!(Entry::new("articles/1").is_err());
    assert!(Entry::decode(Value::String("/articles/../1".into())).is_err());
    assert!(JsonValue::decode(Value::Bytes(vec![1])).is_err());
    assert!(
        JsonValue::decode(Value::Record(BTreeMap::from([(
            "nested".into(),
            Value::List(vec![Value::Bytes(vec![1])]),
        )])))
        .is_err()
    );
}