use crate::__compat::wire::{WireSchema, render_list, render_string};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ContractSurface {
records: Vec<ContractRecord>,
}
impl ContractSurface {
#[must_use]
pub fn new(records: impl IntoIterator<Item = ContractRecord>) -> Self {
let mut records = records.into_iter().collect::<Vec<_>>();
records.sort_by(|left, right| left.sort_key().cmp(&right.sort_key()));
Self { records }
}
#[must_use]
pub fn records(&self) -> &[ContractRecord] {
&self.records
}
#[must_use]
pub fn canonical_json(&self) -> String {
let mut out = String::from(r#"{"records":"#);
render_list(&self.records, &mut out, ContractRecord::render);
out.push('}');
out
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ContractRecord {
Endpoint {
family: String,
path: String,
kind: String,
delivery: String,
payload: Option<WireSchema>,
request: Option<WireSchema>,
response: Option<WireSchema>,
},
Document {
name: String,
tag: String,
body: WireSchema,
},
Envelope {
name: String,
body: WireSchema,
},
Identifier {
name: String,
value: String,
},
Launch {
arguments: Vec<LaunchArgument>,
},
}
impl ContractRecord {
#[must_use]
pub fn topic(
family: impl Into<String>,
path: impl Into<String>,
kind: impl Into<String>,
delivery: impl Into<String>,
payload: WireSchema,
) -> Self {
Self::Endpoint {
family: family.into(),
path: path.into(),
kind: kind.into(),
delivery: delivery.into(),
payload: Some(payload),
request: None,
response: None,
}
}
#[must_use]
pub fn query(
family: impl Into<String>,
path: impl Into<String>,
kind: impl Into<String>,
delivery: impl Into<String>,
request: WireSchema,
response: WireSchema,
) -> Self {
Self::Endpoint {
family: family.into(),
path: path.into(),
kind: kind.into(),
delivery: delivery.into(),
payload: None,
request: Some(request),
response: Some(response),
}
}
#[must_use]
pub fn document(name: impl Into<String>, tag: impl Into<String>, body: WireSchema) -> Self {
Self::Document {
name: name.into(),
tag: tag.into(),
body,
}
}
#[must_use]
pub fn envelope(name: impl Into<String>, body: WireSchema) -> Self {
Self::Envelope {
name: name.into(),
body,
}
}
#[must_use]
pub fn identifier(name: impl Into<String>, value: impl Into<String>) -> Self {
Self::Identifier {
name: name.into(),
value: value.into(),
}
}
#[must_use]
pub fn launch(arguments: impl IntoIterator<Item = LaunchArgument>) -> Self {
Self::Launch {
arguments: arguments.into_iter().collect(),
}
}
fn sort_key(&self) -> (&'static str, &str, &str) {
match self {
Self::Endpoint { family, path, .. } => ("endpoint", family, path),
Self::Document { name, tag, .. } => ("document", name, tag),
Self::Envelope { name, .. } => ("envelope", name, ""),
Self::Identifier { name, .. } => ("identifier", name, ""),
Self::Launch { .. } => ("launch", "", ""),
}
}
fn render(&self, out: &mut String) {
match self {
Self::Endpoint {
family,
path,
kind,
delivery,
payload,
request,
response,
} => {
out.push_str(r#"{"delivery":"#);
render_string(delivery, out);
out.push_str(r#","family":"#);
render_string(family, out);
out.push_str(r#","kind":"#);
render_string(kind, out);
out.push_str(r#","path":"#);
render_string(path, out);
out.push_str(r#","payload":"#);
render_optional(payload.as_ref(), out);
out.push_str(r#","record":"endpoint","request":"#);
render_optional(request.as_ref(), out);
out.push_str(r#","response":"#);
render_optional(response.as_ref(), out);
out.push('}');
}
Self::Document { name, tag, body } => {
out.push_str(r#"{"body":"#);
body.render(out);
out.push_str(r#","name":"#);
render_string(name, out);
out.push_str(r#","record":"document","tag":"#);
render_string(tag, out);
out.push('}');
}
Self::Envelope { name, body } => {
out.push_str(r#"{"body":"#);
body.render(out);
out.push_str(r#","name":"#);
render_string(name, out);
out.push_str(r#","record":"envelope"}"#);
}
Self::Identifier { name, value } => {
out.push_str(r#"{"name":"#);
render_string(name, out);
out.push_str(r#","record":"identifier","value":"#);
render_string(value, out);
out.push('}');
}
Self::Launch { arguments } => {
out.push_str(r#"{"arguments":"#);
render_list(arguments, out, LaunchArgument::render);
out.push_str(r#","record":"launch"}"#);
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LaunchArgument {
pub name: String,
pub required: bool,
pub repeated: bool,
pub value: LaunchValueShape,
}
impl LaunchArgument {
#[must_use]
pub fn new(
name: impl Into<String>,
required: bool,
repeated: bool,
value: LaunchValueShape,
) -> Self {
Self {
name: name.into(),
required,
repeated,
value,
}
}
fn render(&self, out: &mut String) {
out.push_str(r#"{"name":"#);
render_string(&self.name, out);
out.push_str(r#","repeated":"#);
out.push_str(if self.repeated { "true" } else { "false" });
out.push_str(r#","required":"#);
out.push_str(if self.required { "true" } else { "false" });
out.push_str(r#","value":""#);
out.push_str(self.value.token());
out.push_str("\"}");
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LaunchValueShape {
Flag,
Text,
}
impl LaunchValueShape {
const fn token(self) -> &'static str {
match self {
Self::Flag => "flag",
Self::Text => "text",
}
}
}
fn render_optional(schema: Option<&WireSchema>, out: &mut String) {
match schema {
Some(schema) => schema.render(out),
None => out.push_str("null"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::__compat::wire::{WireField, WireSchema};
fn body() -> WireSchema {
WireSchema::structure([WireField::required("value", WireSchema::U8)])
}
#[test]
fn authoring_order_does_not_change_the_canonical_bytes() {
let declared = [
ContractRecord::identifier("encoding", "phoxal/v0;codec=1"),
ContractRecord::topic("robot", "robot/drive/state", "state", "state", body()),
ContractRecord::document("Doc", "phoxal/example/v0", body()),
];
let mut reversed = declared.clone();
reversed.reverse();
assert_eq!(
ContractSurface::new(declared.clone()).canonical_json(),
ContractSurface::new(reversed).canonical_json()
);
assert_eq!(
ContractSurface::new(declared.clone()),
ContractSurface::new(declared)
);
}
#[test]
fn records_sort_by_kind_and_then_by_identity() {
let surface = ContractSurface::new([
ContractRecord::launch([LaunchArgument::new(
"only",
true,
false,
LaunchValueShape::Text,
)]),
ContractRecord::identifier("root", "phoxal"),
ContractRecord::topic("robot", "robot/b", "state", "state", body()),
ContractRecord::topic("robot", "robot/a", "state", "state", body()),
ContractRecord::envelope("Envelope", body()),
ContractRecord::document("Doc", "phoxal/example/v0", body()),
]);
let kinds = surface
.records()
.iter()
.map(ContractRecord::sort_key)
.collect::<Vec<_>>();
assert_eq!(
kinds,
[
("document", "Doc", "phoxal/example/v0"),
("endpoint", "robot", "robot/a"),
("endpoint", "robot", "robot/b"),
("envelope", "Envelope", ""),
("identifier", "root", ""),
("launch", "", ""),
]
);
}
#[test]
fn every_record_renders_as_canonical_json() {
let surface = ContractSurface::new([
ContractRecord::topic("robot", "robot/drive/state", "state", "state", body()),
ContractRecord::query(
"supervisor",
"supervisor/connect",
"query",
"query",
body(),
body(),
),
ContractRecord::document("Doc", "phoxal/example/v0", body()),
ContractRecord::envelope("Envelope", body()),
ContractRecord::identifier("encoding", "phoxal/v0;codec=1"),
ContractRecord::launch([
LaunchArgument::new("execution-id", true, false, LaunchValueShape::Text),
LaunchArgument::new("verbose", false, false, LaunchValueShape::Flag),
]),
]);
let rendered = surface.canonical_json();
assert!(!rendered.contains(' '), "{rendered}");
let parsed = serde_json::from_str::<serde_json::Value>(&rendered)
.expect("the canonical rendering is JSON");
assert_eq!(
parsed["records"]
.as_array()
.map(|records| records.len())
.unwrap_or_default(),
6
);
assert_eq!(surface.canonical_json(), rendered);
}
#[test]
fn an_endpoint_record_states_whether_it_is_a_topic_or_a_query() {
let topic = ContractSurface::new([ContractRecord::topic(
"robot",
"robot/drive/state",
"state",
"state",
body(),
)])
.canonical_json();
assert!(
topic.contains(r#""request":null,"response":null"#),
"{topic}"
);
let query = ContractSurface::new([ContractRecord::query(
"robot",
"robot/frame/lookup",
"query",
"query",
body(),
body(),
)])
.canonical_json();
assert!(query.contains(r#""payload":null"#), "{query}");
}
#[test]
fn launch_arguments_keep_the_parsers_declared_order() {
let rendered = ContractSurface::new([ContractRecord::launch([
LaunchArgument::new("zulu", true, false, LaunchValueShape::Text),
LaunchArgument::new("alpha", false, true, LaunchValueShape::Text),
])])
.canonical_json();
let zulu = rendered.find("zulu").expect("the first argument renders");
let alpha = rendered.find("alpha").expect("the second argument renders");
assert!(zulu < alpha, "{rendered}");
}
#[test]
fn a_name_with_json_metacharacters_is_escaped() {
let rendered = ContractSurface::new([ContractRecord::identifier("quoted", "a\"b\\c")])
.canonical_json();
assert!(rendered.contains(r#""a\"b\\c""#), "{rendered}");
serde_json::from_str::<serde_json::Value>(&rendered).expect("still valid JSON");
}
}