304 lines
9.1 KiB
Rust
304 lines
9.1 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use wip_interface::wip_protocol::{INTERFACE_FORMAT_V1, TypeExpr, Value};
|
|
use wip_interface::{
|
|
DispatchError, DispatchPhase, EffectGuarantee, HostError, HostErrorKind, OperationResult,
|
|
WipType, interface,
|
|
};
|
|
|
|
/// Publication request.
|
|
#[derive(Debug, Clone, PartialEq, WipType)]
|
|
pub struct PublishRequest {
|
|
/// Item title.
|
|
pub title: String,
|
|
}
|
|
|
|
/// Application outcome, including domain failure.
|
|
#[derive(Debug, Clone, PartialEq, WipType)]
|
|
pub enum PublishOutcome {
|
|
/// The item was accepted.
|
|
Published(bool),
|
|
/// The application rejected the item.
|
|
Rejected(String),
|
|
}
|
|
|
|
/// Catalog editing capability.
|
|
///
|
|
/// The shape is implementation-independent.
|
|
#[interface]
|
|
pub trait Catalog {
|
|
/// Publishes one item.
|
|
///
|
|
/// This operation can report a domain rejection in its application union.
|
|
///
|
|
/// # Returns
|
|
/// The application publication outcome.
|
|
fn publish(
|
|
&self,
|
|
/// Item to publish.
|
|
request: PublishRequest,
|
|
/// Optional audit note.
|
|
note: Option<String>,
|
|
) -> OperationResult<PublishOutcome>;
|
|
|
|
/// Checks connectivity without returning application data.
|
|
fn ping(&self) -> OperationResult<()>;
|
|
|
|
/// Demonstrates a host/runtime failure.
|
|
///
|
|
/// # Returns
|
|
/// A value only when the host succeeds.
|
|
fn host_value(&self) -> OperationResult<String>;
|
|
}
|
|
|
|
struct AcceptingCatalog;
|
|
|
|
impl Catalog for AcceptingCatalog {
|
|
fn publish(
|
|
&self,
|
|
request: PublishRequest,
|
|
note: Option<String>,
|
|
) -> OperationResult<PublishOutcome> {
|
|
assert_eq!(request.title, "example");
|
|
assert_eq!(note.as_deref(), Some("audit"));
|
|
Ok(PublishOutcome::Published(true))
|
|
}
|
|
|
|
fn ping(&self) -> OperationResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn host_value(&self) -> OperationResult<String> {
|
|
Err(HostError::internal(
|
|
"backend unavailable",
|
|
EffectGuarantee::Unknown,
|
|
))
|
|
}
|
|
}
|
|
|
|
struct RejectingCatalog;
|
|
|
|
impl Catalog for RejectingCatalog {
|
|
fn publish(
|
|
&self,
|
|
_request: PublishRequest,
|
|
_note: Option<String>,
|
|
) -> OperationResult<PublishOutcome> {
|
|
Ok(PublishOutcome::Rejected("policy".into()))
|
|
}
|
|
|
|
fn ping(&self) -> OperationResult<()> {
|
|
Ok(())
|
|
}
|
|
|
|
fn host_value(&self) -> OperationResult<String> {
|
|
Ok("ready".into())
|
|
}
|
|
}
|
|
|
|
struct PanickingCatalog;
|
|
|
|
impl Catalog for PanickingCatalog {
|
|
fn publish(
|
|
&self,
|
|
_request: PublishRequest,
|
|
_note: Option<String>,
|
|
) -> OperationResult<PublishOutcome> {
|
|
panic!("private panic payload")
|
|
}
|
|
|
|
fn ping(&self) -> OperationResult<()> {
|
|
panic!("private panic payload")
|
|
}
|
|
|
|
fn host_value(&self) -> OperationResult<String> {
|
|
panic!("private panic payload")
|
|
}
|
|
}
|
|
|
|
fn publish_arguments(with_note: bool) -> BTreeMap<String, Value> {
|
|
let mut arguments = BTreeMap::from([(
|
|
"request".into(),
|
|
PublishRequest {
|
|
title: "example".into(),
|
|
}
|
|
.encode()
|
|
.unwrap(),
|
|
)]);
|
|
if with_note {
|
|
arguments.insert("note".into(), Value::String("audit".into()));
|
|
}
|
|
arguments
|
|
}
|
|
|
|
#[test]
|
|
fn descriptor_is_canonical_and_generated_only_from_the_trait_declaration() {
|
|
let first = CatalogDefinition::descriptor();
|
|
let second = CatalogDefinition::descriptor();
|
|
assert_eq!(first, second);
|
|
first.validate().unwrap();
|
|
|
|
assert_eq!(first.format, INTERFACE_FORMAT_V1);
|
|
assert_eq!(
|
|
first.documentation.as_ref().unwrap().summary,
|
|
"Catalog editing capability."
|
|
);
|
|
assert_eq!(
|
|
first.documentation.as_ref().unwrap().details.as_deref(),
|
|
Some("The shape is implementation-independent.")
|
|
);
|
|
assert_eq!(first.operations.len(), 3);
|
|
|
|
let publish = &first.operations[0];
|
|
assert_eq!(publish.name, "publish");
|
|
assert_eq!(publish.parameters[0].name, "request");
|
|
assert_eq!(
|
|
publish.parameters[0]
|
|
.documentation
|
|
.as_ref()
|
|
.unwrap()
|
|
.summary,
|
|
"Item to publish."
|
|
);
|
|
assert!(publish.parameters[0].required);
|
|
assert!(!publish.parameters[1].required);
|
|
assert_eq!(
|
|
publish.returns.documentation.as_ref().unwrap().summary,
|
|
"The application publication outcome."
|
|
);
|
|
assert_eq!(
|
|
publish.returns.r#type,
|
|
TypeExpr::Named {
|
|
name: "PublishOutcome".into(),
|
|
}
|
|
);
|
|
assert_eq!(first.operations[1].returns.r#type, TypeExpr::Unit);
|
|
assert!(
|
|
first
|
|
.types
|
|
.iter()
|
|
.any(|declaration| matches!(declaration.definition, TypeExpr::Record { .. }))
|
|
);
|
|
assert!(
|
|
first
|
|
.types
|
|
.iter()
|
|
.any(|declaration| matches!(declaration.definition, TypeExpr::Union { .. }))
|
|
);
|
|
|
|
// Both implementation types satisfy one immutable generated definition;
|
|
// implementation selection cannot contribute fields to the descriptor.
|
|
fn accepts_definition<T: Catalog>(_: &T) {}
|
|
accepts_definition(&AcceptingCatalog);
|
|
accepts_definition(&RejectingCatalog);
|
|
assert_eq!(CatalogDefinition::descriptor(), first);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_argument_decoder_and_result_encoder_use_raw_values() {
|
|
let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap();
|
|
assert_eq!(decoded.request.title, "example");
|
|
assert_eq!(decoded.note, None);
|
|
|
|
let encoded =
|
|
CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())).unwrap();
|
|
assert_eq!(
|
|
PublishOutcome::decode(encoded).unwrap(),
|
|
PublishOutcome::Rejected("domain".into())
|
|
);
|
|
assert_eq!(
|
|
CatalogPingArguments::encode_result(()).unwrap(),
|
|
Value::Unit
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() {
|
|
let value =
|
|
CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)).unwrap();
|
|
assert_eq!(
|
|
PublishOutcome::decode(value).unwrap(),
|
|
PublishOutcome::Published(true)
|
|
);
|
|
|
|
assert_eq!(
|
|
CatalogDefinition::dispatch(&AcceptingCatalog, "ping", BTreeMap::new()).unwrap(),
|
|
Value::Unit
|
|
);
|
|
|
|
let invalid =
|
|
CatalogDefinition::dispatch(&AcceptingCatalog, "publish", BTreeMap::new()).unwrap_err();
|
|
assert!(matches!(invalid, DispatchError::InvalidArguments(_)));
|
|
assert_eq!(invalid.phase(), DispatchPhase::BeforeInvocation);
|
|
assert_eq!(invalid.effect_guarantee(), None);
|
|
|
|
let host =
|
|
CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", BTreeMap::new()).unwrap_err();
|
|
assert!(
|
|
matches!(&host, DispatchError::Host(error) if error.message() == "backend unavailable")
|
|
);
|
|
assert_eq!(host.phase(), DispatchPhase::AfterInvocation);
|
|
assert_eq!(host.effect_guarantee(), Some(EffectGuarantee::Unknown));
|
|
|
|
let panic =
|
|
CatalogDefinition::dispatch(&PanickingCatalog, "host_value", BTreeMap::new()).unwrap_err();
|
|
assert!(matches!(panic, DispatchError::Panic));
|
|
assert_eq!(panic.phase(), DispatchPhase::AfterInvocation);
|
|
assert_eq!(panic.effect_guarantee(), Some(EffectGuarantee::Unknown));
|
|
assert!(!panic.to_string().contains("private panic payload"));
|
|
|
|
let unknown =
|
|
CatalogDefinition::dispatch(&AcceptingCatalog, "missing", BTreeMap::new()).unwrap_err();
|
|
assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn host_errors_require_a_typed_kind_and_effect_guarantee() {
|
|
let internal = HostError::internal("x".repeat(4_096), EffectGuarantee::NotCommitted);
|
|
assert_eq!(internal.kind(), HostErrorKind::Internal);
|
|
assert_eq!(internal.effect_guarantee(), EffectGuarantee::NotCommitted);
|
|
assert!(internal.message().len() <= 512);
|
|
|
|
let resource = HostError::resource_limit("deadline", EffectGuarantee::Unknown);
|
|
assert_eq!(resource.kind(), HostErrorKind::ResourceLimitExceeded);
|
|
assert_eq!(resource.effect_guarantee(), EffectGuarantee::Unknown);
|
|
}
|
|
|
|
/// Operations whose distinct source names normalize to the same PascalCase.
|
|
#[interface]
|
|
pub trait DistinctOperations {
|
|
/// Returns the first item marker.
|
|
fn item(&self) -> OperationResult<String>;
|
|
|
|
/// Returns the underscored item marker.
|
|
fn item_(&self) -> OperationResult<String>;
|
|
}
|
|
|
|
struct DistinctImplementation;
|
|
|
|
impl DistinctOperations for DistinctImplementation {
|
|
fn item(&self) -> OperationResult<String> {
|
|
Ok("item".into())
|
|
}
|
|
|
|
fn item_(&self) -> OperationResult<String> {
|
|
Ok("item_".into())
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn distinct_operation_names_always_generate_distinct_public_items() {
|
|
let descriptor = DistinctOperationsDefinition::descriptor();
|
|
assert_eq!(descriptor.operations[0].name, "item");
|
|
assert_eq!(descriptor.operations[1].name, "item_");
|
|
|
|
let first =
|
|
DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item", BTreeMap::new())
|
|
.unwrap();
|
|
let second =
|
|
DistinctOperationsDefinition::dispatch(&DistinctImplementation, "item_", BTreeMap::new())
|
|
.unwrap();
|
|
assert_eq!(first, Value::String("item".into()));
|
|
assert_eq!(second, Value::String("item_".into()));
|
|
}
|