#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const CAPSULE_FORMAT_VERSION: u32 = 4;
pub const OLDEST_READABLE_FORMAT_VERSION: u32 = 3;
#[derive(Debug)]
pub enum CapsuleError {
Io(std::io::Error),
Malformed(serde_json::Error),
VersionMismatch {
found: u32,
expected: u32,
},
}
impl std::fmt::Display for CapsuleError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "failed to read capsule: {error}"),
Self::Malformed(error) => write!(f, "capsule is not a valid capsule document: {error}"),
Self::VersionMismatch { found, expected } => {
let direction = if found < expected {
"older than"
} else {
"newer than"
};
write!(
f,
"capsule format version {found} is {direction} the version this build \
understands ({expected}), so replaying it would judge the handler against \
effects the document cannot describe. Re-record the capsule with this \
build, or replay it with the Autumn version that wrote it — see the \
\"Compatibility across Autumn versions\" section of the failure-capsule \
guide."
)
}
}
}
}
impl std::error::Error for CapsuleError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capsule {
pub format_version: u32,
pub id: String,
pub captured_at: DateTime<Utc>,
pub autumn_version: String,
#[serde(default)]
pub app: AppInfo,
pub request: CapsuleRequest,
pub outcome: CapsuleOutcome,
#[serde(default)]
pub clock: Vec<DateTime<Utc>>,
#[serde(default)]
pub clock_monotonic_us: Vec<u64>,
#[serde(default)]
pub db: Option<CapsuleDb>,
#[serde(default)]
pub db_roles: Vec<String>,
#[serde(default)]
pub truncated: bool,
#[serde(default)]
pub notes: Vec<String>,
#[serde(default)]
pub effects: CapsuleEffects,
#[serde(default)]
pub job: Option<CapsuleJob>,
}
impl Capsule {
pub fn from_json(json: &str) -> Result<Self, CapsuleError> {
#[derive(Deserialize)]
struct VersionProbe {
format_version: u32,
}
let probe: VersionProbe = serde_json::from_str(json).map_err(CapsuleError::Malformed)?;
if !(OLDEST_READABLE_FORMAT_VERSION..=CAPSULE_FORMAT_VERSION)
.contains(&probe.format_version)
{
return Err(CapsuleError::VersionMismatch {
found: probe.format_version,
expected: CAPSULE_FORMAT_VERSION,
});
}
serde_json::from_str(json).map_err(CapsuleError::Malformed)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppInfo {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub debug_assertions: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleRequest {
pub method: String,
pub uri: String,
#[serde(default)]
pub route: Option<String>,
pub http_version: String,
pub headers: Vec<(String, String)>,
#[serde(default)]
pub binary_headers: Vec<(String, String)>,
pub body: CapsuleBody,
#[serde(default)]
pub redacted_keys: Vec<String>,
#[serde(default)]
pub peer_addr: Option<std::net::SocketAddr>,
#[serde(default)]
pub client_addr: Option<std::net::IpAddr>,
#[serde(default)]
pub client_host: Option<String>,
#[serde(default)]
pub client_scheme: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleBody {
Absent,
Text(String),
Base64(String),
Skipped {
#[serde(default)]
declared_len: Option<usize>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CapsuleOutcome {
Status {
code: u16,
message: String,
#[serde(default)]
problem_type: Option<String>,
},
Panic {
status: u16,
payload: String,
#[serde(default)]
backtrace: Option<String>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleDb {
pub connections: Vec<ConnectionTape>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConnectionTape {
pub id: u64,
#[serde(default = "default_tape_role")]
pub role: String,
#[serde(default)]
pub prologue: Vec<Exchange>,
#[serde(default)]
pub statements: Vec<Exchange>,
#[serde(default)]
pub catalog: Vec<Exchange>,
#[serde(default)]
pub exchanges: Vec<Exchange>,
}
fn default_tape_role() -> String {
"primary".to_owned()
}
pub const TAPE_ROLE_REPLICA: &str = "replica";
pub const TAPE_ROLE_PRIMARY: &str = "primary";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExchangeProtocol {
Simple,
Extended,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exchange {
pub protocol: ExchangeProtocol,
pub sql: String,
#[serde(default)]
pub binds: Vec<BindValue>,
#[serde(default, with = "b64")]
pub response: Vec<u8>,
#[serde(default)]
pub row_count: usize,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BindValue {
Null,
Value(#[serde(with = "b64")] Vec<u8>),
Masked,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleEffects {
#[serde(default)]
pub http: Vec<HttpEffect>,
#[serde(default)]
pub jobs: Vec<JobEffect>,
#[serde(default)]
pub cache: Vec<CacheEffect>,
#[serde(default)]
pub mail: Vec<MailEffect>,
#[serde(default)]
pub tenant: Option<TenantEffect>,
#[serde(default)]
pub random: Vec<RandomEffect>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub state_cache: bool,
}
impl CapsuleEffects {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.http.is_empty()
&& self.jobs.is_empty()
&& self.cache.is_empty()
&& self.mail.is_empty()
&& self.tenant.is_none()
&& self.random.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpEffect {
pub method: String,
pub url: String,
#[serde(default)]
pub request_headers: Vec<(String, String)>,
#[serde(default = "absent_body")]
pub request_body: CapsuleBody,
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_url: Option<String>,
#[serde(default)]
pub response_headers: Vec<(String, String)>,
#[serde(default = "absent_body")]
pub response_body: CapsuleBody,
#[serde(default)]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_kind: Option<HttpErrorKind>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HttpErrorKind {
Transport,
Json,
NoMock,
CircuitBreakerOpen,
ThrottledLocally,
SsrfBlocked,
TooManyRedirects,
RedirectRejected,
InvalidUrl,
FaultInjected,
}
impl Default for HttpEffect {
fn default() -> Self {
Self {
method: String::new(),
url: String::new(),
request_headers: Vec::new(),
request_body: CapsuleBody::Absent,
status: 0,
final_url: None,
response_headers: Vec::new(),
response_body: CapsuleBody::Absent,
error: None,
error_kind: None,
}
}
}
impl HttpEffect {
#[must_use]
pub fn pending() -> Self {
Self {
method: String::new(),
url: String::new(),
request_headers: Vec::new(),
request_body: CapsuleBody::Absent,
status: 0,
final_url: None,
response_headers: Vec::new(),
response_body: CapsuleBody::Absent,
error: Some(PENDING_EFFECT.to_owned()),
error_kind: None,
}
}
}
pub const PENDING_EFFECT: &str =
"the recording ended before this effect completed; the capsule is incomplete";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JobEffect {
pub name: String,
pub payload: serde_json::Value,
#[serde(default)]
pub delay_secs: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub requested_due_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_status: Option<u16>,
}
impl Default for JobEffect {
fn default() -> Self {
Self {
name: String::new(),
payload: serde_json::Value::Null,
delay_secs: None,
due_at: None,
requested_due_at: None,
error: None,
error_status: None,
}
}
}
impl JobEffect {
#[must_use]
pub fn pending() -> Self {
Self {
name: String::new(),
payload: serde_json::Value::Null,
delay_secs: None,
due_at: None,
requested_due_at: None,
error: Some(PENDING_EFFECT.to_owned()),
error_status: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapsuleJob {
pub name: String,
pub payload: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "op")]
pub enum CacheEffect {
Get {
key: String,
#[serde(default)]
value: Option<String>,
},
Insert {
key: String,
value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
ttl_secs: Option<u64>,
},
Invalidate {
key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<CacheInvalidationError>,
},
InvalidateNamespace {
namespace: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<CacheInvalidationError>,
},
Clear,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheInvalidationError {
pub attempts: u32,
pub reason: String,
}
impl CacheEffect {
#[must_use]
pub fn key(&self) -> &str {
match self {
Self::Get { key, .. } | Self::Insert { key, .. } | Self::Invalidate { key, .. } => key,
Self::InvalidateNamespace { namespace, .. } => namespace,
Self::Clear => "",
}
}
#[must_use]
pub fn pending() -> Self {
Self::Invalidate {
key: String::new(),
error: Some(CacheInvalidationError {
attempts: 0,
reason: PENDING_EFFECT.to_owned(),
}),
}
}
#[must_use]
pub const fn is_read(&self) -> bool {
matches!(self, Self::Get { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MailEffect {
#[serde(default)]
pub to: Vec<String>,
#[serde(default)]
pub from: Option<String>,
pub subject: String,
#[serde(default = "absent_body")]
pub body: CapsuleBody,
#[serde(default = "absent_body")]
pub alternate_body: CapsuleBody,
#[serde(default)]
pub reply_to: Option<String>,
#[serde(default)]
pub list_unsubscribe: Option<String>,
#[serde(default)]
pub extra_headers: Vec<(String, String)>,
#[serde(default)]
pub attachments: Vec<MailAttachmentEffect>,
#[serde(default)]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_kind: Option<MailErrorKind>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct MailAttachmentEffect {
pub filename: String,
pub content_type: String,
pub len: usize,
pub sha256: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MailErrorKind {
InvalidMessage,
RuntimeUnavailable,
InvalidAddress,
Build,
Smtp,
Io,
AllRecipientsSuppressed,
CssInline,
NoDurableQueueInProduction,
Other,
}
impl Default for MailEffect {
fn default() -> Self {
Self {
to: Vec::new(),
from: None,
subject: String::new(),
body: CapsuleBody::Absent,
alternate_body: CapsuleBody::Absent,
reply_to: None,
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: Vec::new(),
error: None,
error_kind: None,
}
}
}
impl MailEffect {
#[must_use]
pub fn pending() -> Self {
Self {
to: Vec::new(),
from: None,
subject: String::new(),
body: CapsuleBody::Absent,
alternate_body: CapsuleBody::Absent,
reply_to: None,
list_unsubscribe: None,
extra_headers: Vec::new(),
attachments: Vec::new(),
error: Some(PENDING_EFFECT.to_owned()),
error_kind: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TenantEffect {
#[serde(default)]
pub id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RandomEffect {
#[serde(with = "b64")]
pub bytes: Vec<u8>,
}
const fn absent_body() -> CapsuleBody {
CapsuleBody::Absent
}
pub(crate) mod b64 {
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::{Deserialize as _, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&STANDARD.encode(bytes))
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
let encoded = String::deserialize(deserializer)?;
STANDARD
.decode(encoded.as_bytes())
.map_err(serde::de::Error::custom)
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::{
AppInfo, BindValue, CAPSULE_FORMAT_VERSION, Capsule, CapsuleBody, CapsuleDb,
CapsuleEffects, CapsuleOutcome, CapsuleRequest, ConnectionTape, Exchange, ExchangeProtocol,
};
#[must_use]
pub fn exchange(sql: &str, binds: Vec<BindValue>, response: Vec<u8>) -> Exchange {
Exchange {
protocol: ExchangeProtocol::Extended,
sql: sql.to_owned(),
binds,
response,
row_count: 0,
error: None,
}
}
#[must_use]
pub fn simple_exchange(sql: &str, response: Vec<u8>) -> Exchange {
Exchange {
protocol: ExchangeProtocol::Simple,
sql: sql.to_owned(),
binds: Vec::new(),
response,
row_count: 0,
error: None,
}
}
#[must_use]
pub const fn connection_tape(id: u64, exchanges: Vec<Exchange>) -> ConnectionTape {
ConnectionTape {
id,
role: String::new(),
prologue: Vec::new(),
statements: Vec::new(),
catalog: Vec::new(),
exchanges,
}
}
#[must_use]
pub fn request(method: &str, uri: &str) -> CapsuleRequest {
CapsuleRequest {
method: method.to_owned(),
uri: uri.to_owned(),
route: None,
http_version: "HTTP/1.1".to_owned(),
headers: Vec::new(),
binary_headers: Vec::new(),
body: CapsuleBody::Absent,
redacted_keys: Vec::new(),
peer_addr: None,
client_addr: None,
client_host: None,
client_scheme: None,
}
}
#[must_use]
pub fn capsule(request: CapsuleRequest, outcome: CapsuleOutcome) -> Capsule {
Capsule {
format_version: CAPSULE_FORMAT_VERSION,
id: "fixture".to_owned(),
captured_at: crate::time::ambient_now(),
autumn_version: env!("CARGO_PKG_VERSION").to_owned(),
app: AppInfo::default(),
request,
outcome,
clock: Vec::new(),
clock_monotonic_us: Vec::new(),
db: None,
db_roles: Vec::new(),
truncated: false,
notes: Vec::new(),
effects: CapsuleEffects::default(),
job: None,
}
}
#[must_use]
pub fn with_connections(mut capsule: Capsule, connections: Vec<ConnectionTape>) -> Capsule {
capsule.db = Some(CapsuleDb { connections });
capsule
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Capsule {
let mut capsule = test_support::capsule(
test_support::request("POST", "/orders?page=2"),
CapsuleOutcome::Status {
code: 500,
message: "boom".to_owned(),
problem_type: Some("https://autumn.dev/problems/internal".to_owned()),
},
);
capsule.id = "req-1".to_owned();
capsule.request.headers = vec![
("content-type".to_owned(), "application/json".to_owned()),
("authorization".to_owned(), "[FILTERED]".to_owned()),
];
capsule.request.body = CapsuleBody::Text("{\"a\":1}".to_owned());
capsule.request.redacted_keys = vec!["header:authorization".to_owned()];
capsule.clock = vec![Utc::now()];
capsule.notes = vec!["db capture unavailable".to_owned()];
capsule = test_support::with_connections(
capsule,
vec![test_support::connection_tape(
1,
vec![test_support::exchange(
"SELECT 1",
vec![
BindValue::Null,
BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
BindValue::Masked,
],
vec![b'Z', 0, 0, 0, 5, b'I'],
)],
)],
);
capsule
}
#[test]
fn capsule_effects_round_trip() {
let mut capsule = sample();
capsule.effects.http.push(HttpEffect {
method: "POST".to_owned(),
url: "https://payments.example/charge".to_owned(),
request_headers: vec![("authorization".to_owned(), "[FILTERED]".to_owned())],
request_body: CapsuleBody::Text("{\"amount\":10}".to_owned()),
status: 502,
final_url: Some("https://payments.example/charge/final".to_owned()),
response_headers: vec![("content-type".to_owned(), "application/json".to_owned())],
response_body: CapsuleBody::Base64("AAEC".to_owned()),
error: None,
error_kind: Some(HttpErrorKind::CircuitBreakerOpen),
});
capsule.effects.jobs.push(JobEffect {
name: "send_receipt".to_owned(),
payload: serde_json::json!({"order": 7}),
delay_secs: Some(30),
due_at: None,
requested_due_at: None,
error: None,
error_status: None,
});
capsule.effects.cache.push(CacheEffect::Get {
key: "user:7".to_owned(),
value: Some("eyJhIjoxfQ==".to_owned()),
});
capsule.effects.cache.push(CacheEffect::Insert {
key: "user:7".to_owned(),
value: "eyJhIjoxfQ==".to_owned(),
ttl_secs: None,
});
capsule.effects.mail.push(MailEffect {
to: vec!["a@example.com".to_owned()],
from: Some("noreply@example.com".to_owned()),
subject: "Receipt".to_owned(),
body: CapsuleBody::Text("thanks".to_owned()),
alternate_body: CapsuleBody::Text("<p>thanks</p>".to_owned()),
reply_to: Some("support@example.com".to_owned()),
list_unsubscribe: Some("<https://example.com/u>".to_owned()),
extra_headers: vec![("x-campaign".to_owned(), "receipts".to_owned())],
attachments: vec![MailAttachmentEffect {
filename: "invoice.pdf".to_owned(),
content_type: "application/pdf".to_owned(),
len: 4,
sha256: "ab".repeat(32),
}],
error: None,
error_kind: Some(MailErrorKind::AllRecipientsSuppressed),
});
capsule.effects.tenant = Some(TenantEffect {
id: Some("acme".to_owned()),
});
capsule.effects.random.push(RandomEffect {
bytes: vec![0xAA, 0xBB, 0xCC],
});
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let parsed = Capsule::from_json(&json).expect("capsule round-trips");
assert_eq!(parsed.effects, capsule.effects);
assert_eq!(
parsed.effects.random.first().map(|draw| draw.bytes.clone()),
Some(vec![0xAA, 0xBB, 0xCC]),
"random draws are byte-exact after the base64 hop"
);
}
#[test]
fn capsule_job_entry_round_trips() {
let mut capsule = sample();
capsule.job = Some(CapsuleJob {
name: "send_receipt".to_owned(),
payload: serde_json::json!({"order": 7}),
});
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let parsed = Capsule::from_json(&json).expect("capsule round-trips");
assert_eq!(
parsed.job.as_ref().map(|job| job.name.as_str()),
Some("send_receipt")
);
}
#[test]
fn a_capsule_without_the_effect_tape_cannot_be_read_by_this_build() {
let mut json = serde_json::to_value(sample()).expect("capsule serializes");
json["format_version"] = serde_json::json!(2);
if let serde_json::Value::Object(map) = &mut json {
map.remove("effects");
map.remove("job");
}
Capsule::from_json(&json.to_string()).expect_err(
"a document with no effect tape must not load as one that has an empty one",
);
}
#[test]
fn a_pre_effects_capsule_is_refused_by_the_version_gate() {
let mut capsule = sample();
capsule.format_version = 2;
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let error = Capsule::from_json(&json).expect_err("a v2 capsule must be refused");
let message = error.to_string();
assert!(
message.contains("older") || message.contains("re-record"),
"the refusal must be actionable: {message}"
);
}
#[test]
fn a_v3_capsule_still_loads() {
let mut capsule = sample();
capsule.format_version = 3;
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let parsed = Capsule::from_json(&json).expect("a v3 capsule loads");
assert_eq!(parsed.format_version, 3);
}
#[test]
fn a_newer_capsule_is_a_version_mismatch_even_with_unknown_values() {
let mut json = serde_json::to_value(sample()).expect("capsule serializes");
json["format_version"] = serde_json::json!(CAPSULE_FORMAT_VERSION + 1);
json["outcome"] = serde_json::json!("a_value_from_the_future");
match Capsule::from_json(&json.to_string()) {
Err(CapsuleError::VersionMismatch { found, .. }) => {
assert_eq!(found, CAPSULE_FORMAT_VERSION + 1);
}
other => panic!("expected a version mismatch, got {other:?}"),
}
}
#[test]
fn capsule_json_roundtrips_v1() {
let capsule = sample();
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let parsed = Capsule::from_json(&json).expect("capsule round-trips");
assert_eq!(parsed.format_version, CAPSULE_FORMAT_VERSION);
assert_eq!(parsed.id, "req-1");
assert_eq!(parsed.request, capsule.request);
assert_eq!(parsed.outcome, capsule.outcome);
assert_eq!(parsed.clock, capsule.clock);
assert_eq!(parsed.db, capsule.db);
assert_eq!(parsed.notes, capsule.notes);
let db = parsed.db.expect("db tape present");
let exchange = db
.connections
.first()
.and_then(|tape| tape.exchanges.first())
.expect("one exchange");
assert_eq!(exchange.response, vec![b'Z', 0, 0, 0, 5, b'I']);
assert_eq!(
exchange.binds,
vec![
BindValue::Null,
BindValue::Value(vec![0xDE, 0xAD, 0xBE, 0xEF]),
BindValue::Masked,
]
);
}
#[test]
fn capsule_with_unknown_future_field_still_loads() {
let json = serde_json::to_value(sample()).expect("capsule serializes");
let mut object = match json {
serde_json::Value::Object(map) => map,
other => panic!("capsule must serialize to an object, got {other}"),
};
object.insert("future_knob".to_owned(), serde_json::json!({"a": [1, 2]}));
let json = serde_json::Value::Object(object).to_string();
let parsed = Capsule::from_json(&json)
.expect("a capsule carrying an unknown field must still load (forward compatibility)");
assert_eq!(parsed.id, "req-1");
}
#[test]
fn load_rejects_format_version_mismatch() {
let mut capsule = sample();
capsule.format_version = CAPSULE_FORMAT_VERSION + 1;
let json = serde_json::to_string(&capsule).expect("capsule serializes");
let error = Capsule::from_json(&json)
.expect_err("a future format version must be rejected, not silently read");
match error {
CapsuleError::VersionMismatch { found, expected } => {
assert_eq!(found, CAPSULE_FORMAT_VERSION + 1);
assert_eq!(expected, CAPSULE_FORMAT_VERSION);
}
other => panic!("expected a version mismatch, got {other}"),
}
assert!(
CapsuleError::VersionMismatch {
found: 99,
expected: CAPSULE_FORMAT_VERSION,
}
.to_string()
.contains("format version 99"),
"the mismatch message must name the offending version"
);
}
}