Skip to main content

printobserver_types/
contract.rs

1//! This crate's own claim about the types it declares.
2//!
3//! Every type this crate declares as its own is listed in [`declared`], with
4//! the JSON Schema it emits and a canonical value of it. This is what the
5//! `printobserver-types:schemas` graph target generates the checked-in schemas
6//! from, and what this crate's contract tests walk, so that a type cannot fall
7//! out of the schema set or out of the round-trip corpus by being forgotten in
8//! a list somebody maintains by hand.
9//!
10//! Each entry carries two values rather than one: `full`, which carries every
11//! field the type declares, and `minimal`, which carries every optional field
12//! absent. The second is what proves that an absent optional serializes as
13//! absent rather than as `null`, a zero or an empty string.
14
15use std::collections::BTreeMap;
16
17use schemars::{JsonSchema, generate::SchemaSettings};
18use serde::Serialize;
19use serde::de::DeserializeOwned;
20use serde_json::Value;
21
22use crate::event::{EventBody, EventKind, EventPayload, EventRecord, EventSource};
23use crate::file_name::FileName;
24use crate::ids::{EventId, ImageId, PrintId};
25use crate::image::ImageRef;
26use crate::raw::RawBytes;
27use crate::reported::{Range, Reported};
28use crate::timestamp::Timestamp;
29
30/// A canonical value of one type, for the schema set and the round-trip corpus.
31pub trait Sample: Sized {
32    /// A value carrying every field the type declares.
33    fn sample_full() -> Self;
34
35    /// A value carrying every optional field absent.
36    #[must_use]
37    fn sample_minimal() -> Self {
38        Self::sample_full()
39    }
40
41    /// One value per further arm, for a type whose wire form is a union.
42    ///
43    /// A union's arms declare fields the other arms do not, so a corpus of one
44    /// value per type would leave those fields undriven. Every arm a type
45    /// declares beyond the one `sample_full` carries belongs here.
46    #[must_use]
47    fn sample_alternates() -> Vec<Self> {
48        Vec::new()
49    }
50}
51
52/// One type this crate declares, with its schema and its canonical values.
53#[derive(Debug, Clone, Copy)]
54pub struct TypeContract {
55    /// The type's own name, which is also its schema's file name.
56    pub name: &'static str,
57    /// The JSON Schema the type emits.
58    schema: fn() -> Value,
59    /// A value carrying every field the type declares, serialized.
60    full: fn() -> Value,
61    /// A value carrying every optional field absent, serialized.
62    minimal: fn() -> Value,
63    /// Parse a value as this type and serialize it back.
64    round_trip: fn(Value) -> Result<Value, String>,
65    /// One serialized value per further arm of a union.
66    alternates: fn() -> Vec<Value>,
67}
68
69impl TypeContract {
70    /// The contract of one type, under the name its schema is written under.
71    ///
72    /// This is how a crate other than this one declares a type of its own into
73    /// the schema set: its `schemas` test lists its types through this and
74    /// reconciles the files under `schemas/<crate>/` against them, walking the
75    /// same round trip and the same absent-optional rule this crate's own
76    /// contract tests walk.
77    #[must_use]
78    pub fn of<T: Sample + JsonSchema + Serialize + DeserializeOwned>(name: &'static str) -> Self {
79        Self {
80            name,
81            schema: schema_of::<T>,
82            full: || canonical(&T::sample_full()),
83            minimal: || canonical(&T::sample_minimal()),
84            round_trip: round_trip_as::<T>,
85            alternates: || T::sample_alternates().iter().map(canonical).collect(),
86        }
87    }
88
89    /// The JSON Schema this type emits.
90    #[must_use]
91    pub fn schema(&self) -> Value {
92        (self.schema)()
93    }
94
95    /// A serialized value carrying every field the type declares.
96    #[must_use]
97    pub fn full(&self) -> Value {
98        (self.full)()
99    }
100
101    /// A serialized value carrying every optional field absent.
102    #[must_use]
103    pub fn minimal(&self) -> Value {
104        (self.minimal)()
105    }
106
107    /// Every canonical value of this type: the full one, the minimal one, and
108    /// one per further arm of a union.
109    #[must_use]
110    pub fn samples(&self) -> Vec<Value> {
111        let mut values = vec![self.full(), self.minimal()];
112        values.extend((self.alternates)());
113        values
114    }
115
116    /// Parse a value as this type and serialize the result back.
117    ///
118    /// # Errors
119    ///
120    /// Returns the parse or emission failure, as the message it carried.
121    pub fn round_trip(&self, value: Value) -> Result<Value, String> {
122        (self.round_trip)(value)
123    }
124}
125
126/// The JSON Schema one type emits, under this crate's own generator settings.
127///
128/// Sub-schemas are referenced rather than inlined, so that a schema names the
129/// types it is built from rather than flattening them into anonymous objects.
130///
131/// # Panics
132///
133/// Panics if the generated schema is not JSON, which it is by construction.
134#[must_use]
135pub fn schema_of<T: JsonSchema>() -> Value {
136    let generator = SchemaSettings::draft2020_12().into_generator();
137    serde_json::to_value(generator.into_root_schema_for::<T>())
138        .expect("a generated schema is JSON by construction")
139}
140
141/// The member an event payload's schema carries naming the kind it is under.
142pub const EVENT_KIND_MARKER: &str = "x-event-kind";
143
144/// The JSON Schema one event payload emits, marked with the kind it is under.
145///
146/// The schema [`schema_of`] answers for the payload type, plus one top-level
147/// member, `x-event-kind`, whose value is the type's own
148/// [`KIND`](EventPayload::KIND). Nothing writes that member by hand: the crate
149/// that owns the kind writes its schema file through this, and the clients'
150/// kind-to-payload tables are generated from the marker.
151///
152/// # Panics
153///
154/// Panics if the generated schema is not an object, which it is by
155/// construction for a payload type.
156#[must_use]
157pub fn event_schema_of<P: EventPayload>() -> Value {
158    let mut schema = schema_of::<P>();
159    schema
160        .as_object_mut()
161        .expect("a payload's schema is an object")
162        .insert(EVENT_KIND_MARKER.to_owned(), Value::from(P::KIND));
163    schema
164}
165
166/// Serialize a value, reporting a refusal as the message it carried.
167fn emit<T: Serialize>(value: &T) -> Result<Value, String> {
168    serde_json::to_value(value).map_err(|error| error.to_string())
169}
170
171/// Parse a value as `T` and serialize it back.
172fn round_trip_as<T: Serialize + DeserializeOwned>(value: Value) -> Result<Value, String> {
173    let parsed: T = serde_json::from_value(value).map_err(|error| error.to_string())?;
174    emit(&parsed)
175}
176
177/// Serialize a canonical value, which this crate's own types never refuse.
178fn canonical<T: Serialize>(value: &T) -> Value {
179    emit(value).expect("a canonical sample serializes")
180}
181
182/// One type's contract, under its own name or under a name given for it.
183macro_rules! contract_of {
184    ($ty:ty) => {
185        contract_of!($ty, stringify!($ty))
186    };
187    ($ty:ty, $name:expr) => {
188        TypeContract::of::<$ty>($name)
189    };
190}
191
192/// Declare the contract of each named type.
193macro_rules! declare {
194    ($($ty:ty $(=> $name:literal)?),* $(,)?) => {
195        vec![$( contract_of!($ty $(, $name)?) ),*]
196    };
197}
198
199/// Every type this crate declares as its own.
200#[must_use]
201pub fn declared() -> Vec<TypeContract> {
202    declare![
203        EventId,
204        EventBody,
205        EventKind,
206        EventRecord,
207        EventSource,
208        FileName,
209        ImageId,
210        ImageRef,
211        PrintId,
212        Range,
213        RawBytes,
214        Reported<f64> => "Reported",
215        Timestamp,
216    ]
217}
218
219/// A fixed print identifier, so that a sample is the same on every run.
220fn print_id() -> PrintId {
221    "0191f0a0-0000-7000-8000-000000000001"
222        .parse()
223        .expect("a fixed version 7 identifier")
224}
225
226/// A fixed event identifier.
227fn event_id() -> EventId {
228    "0191f0a0-0000-7000-8000-000000000002"
229        .parse()
230        .expect("a fixed version 7 identifier")
231}
232
233/// A fixed image identifier.
234fn image_id() -> ImageId {
235    "0191f0a0-0000-7000-8000-000000000003"
236        .parse()
237        .expect("a fixed version 7 identifier")
238}
239
240/// A fixed instant, so that a sample is the same on every run.
241fn instant() -> Timestamp {
242    "2026-03-01T12:00:00Z"
243        .parse()
244        .expect("a fixed RFC 3339 instant")
245}
246
247impl Sample for PrintId {
248    fn sample_full() -> Self {
249        print_id()
250    }
251}
252
253impl Sample for EventId {
254    fn sample_full() -> Self {
255        event_id()
256    }
257}
258
259impl Sample for ImageId {
260    fn sample_full() -> Self {
261        image_id()
262    }
263}
264
265impl Sample for Timestamp {
266    fn sample_full() -> Self {
267        instant()
268    }
269}
270
271impl Sample for FileName {
272    fn sample_full() -> Self {
273        Self::new("benchy.gcode").expect("a name carrying no forbidden property")
274    }
275}
276
277impl Sample for RawBytes {
278    fn sample_full() -> Self {
279        Self::new(b"{\"event\":{}}".to_vec())
280    }
281}
282
283impl Sample for Range {
284    fn sample_full() -> Self {
285        Self::new(0.5, 1.5)
286    }
287}
288
289impl Sample for Reported<f64> {
290    fn sample_full() -> Self {
291        Self::new(1.0, Range::sample_full())
292    }
293}
294
295impl Sample for ImageRef {
296    fn sample_full() -> Self {
297        Self {
298            id: image_id(),
299            sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_owned(),
300        }
301    }
302}
303
304impl Sample for EventSource {
305    fn sample_full() -> Self {
306        Self::new("sample_source")
307    }
308}
309
310impl Sample for EventKind {
311    fn sample_full() -> Self {
312        Self::new("sample_event").expect("a fixed kind name")
313    }
314}
315
316impl Sample for EventBody {
317    fn sample_full() -> Self {
318        Self {
319            kind: EventKind::sample_full(),
320            payload: serde_json::json!({ "detail": "a payload in the form its kind declares" }),
321        }
322    }
323}
324
325impl Sample for EventRecord {
326    fn sample_full() -> Self {
327        Self {
328            id: event_id(),
329            print_id: Some(print_id()),
330            source: EventSource::sample_full(),
331            received_at: instant(),
332            image: Some(ImageRef::sample_full()),
333            body: EventBody::sample_full(),
334            raw: Some(RawBytes::sample_full()),
335        }
336    }
337
338    fn sample_minimal() -> Self {
339        Self {
340            print_id: None,
341            image: None,
342            raw: None,
343            ..Self::sample_full()
344        }
345    }
346}
347
348/// One field as a generated schema declares it.
349///
350/// This is how the contract tests read a type's fields: off the schema the type
351/// generates rather than off a table maintained beside it.
352#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
353pub struct WireField {
354    /// The field's name on the wire.
355    pub name: String,
356    /// What the field is: a referenced type's name, or a primitive's.
357    pub descriptor: String,
358    /// Whether an instance must carry it.
359    pub required: bool,
360}
361
362/// The name a reference names.
363fn referenced(reference: &str) -> String {
364    reference.rsplit('/').next().unwrap_or(reference).to_owned()
365}
366
367/// The one non-null arm of a union, when there is exactly one.
368fn sole_arm(arms: &[Value]) -> Option<&Value> {
369    let mut kept = arms
370        .iter()
371        .filter(|arm| arm.get("type") != Some(&Value::from("null")));
372    let first = kept.next()?;
373    kept.next().is_none().then_some(first)
374}
375
376/// What one property schema declares the field to be.
377fn descriptor(schema: &Value) -> String {
378    // `schemars` emits the boolean schema `true` for a JSON value of any form,
379    // and, where the value carries a description, the empty object schema.
380    if schema == &Value::Bool(true) {
381        return "any".to_owned();
382    }
383    if let Some(object) = schema.as_object()
384        && object.keys().all(|key| key == "description")
385    {
386        return "any".to_owned();
387    }
388    if let Some(Value::String(reference)) = schema.get("$ref") {
389        return referenced(reference);
390    }
391    for key in ["anyOf", "oneOf"] {
392        if let Some(Value::Array(arms)) = schema.get(key) {
393            return match sole_arm(arms) {
394                Some(arm) => descriptor(arm),
395                None => "enum".to_owned(),
396            };
397        }
398    }
399    let kind = match schema.get("type") {
400        Some(Value::String(name)) => name.clone(),
401        Some(Value::Array(names)) => names
402            .iter()
403            .filter_map(Value::as_str)
404            .find(|name| *name != "null")
405            .unwrap_or("unknown")
406            .to_owned(),
407        _ => "unknown".to_owned(),
408    };
409    match kind.as_str() {
410        "array" => format!(
411            "array:{}",
412            schema
413                .get("items")
414                .map_or_else(|| "unknown".to_owned(), descriptor)
415        ),
416        "object" => match (
417            schema.get("additionalProperties"),
418            schema.get("patternProperties"),
419        ) {
420            (Some(value), _) if value.is_object() => format!("map:{}", descriptor(value)),
421            (_, Some(Value::Object(patterns))) if patterns.len() == 1 => {
422                let value = patterns.values().next().expect("one pattern");
423                format!("map:{}", descriptor(value))
424            }
425            _ => "object".to_owned(),
426        },
427        other => other.to_owned(),
428    }
429}
430
431/// The names one schema object marks required.
432fn required_names(schema: &Value) -> Vec<&str> {
433    schema
434        .get("required")
435        .and_then(Value::as_array)
436        .map(|names| names.iter().filter_map(Value::as_str).collect())
437        .unwrap_or_default()
438}
439
440/// Read the fields off one object schema into a map.
441fn collect(schema: &Value, into: &mut BTreeMap<String, WireField>) {
442    let required = required_names(schema);
443    let Some(Value::Object(properties)) = schema.get("properties") else {
444        return;
445    };
446    for (name, property) in properties {
447        into.insert(
448            name.clone(),
449            WireField {
450                name: name.clone(),
451                descriptor: descriptor(property),
452                required: required.contains(&name.as_str()),
453            },
454        );
455    }
456}
457
458/// Every field one generated schema declares, in name order.
459///
460/// A schema whose type flattens a tagged enum declares some of its fields
461/// inside the union's arms rather than beside them, so this reads both: a field
462/// every arm requires is required, a field no arm declares is absent, and a
463/// field the arms declare at differing types is `union`.
464#[must_use]
465pub fn wire_fields(schema: &Value) -> Vec<WireField> {
466    let mut fields = BTreeMap::new();
467    collect(schema, &mut fields);
468    for key in ["anyOf", "oneOf", "allOf"] {
469        let Some(Value::Array(arms)) = schema.get(key) else {
470            continue;
471        };
472        let mut from_arms: BTreeMap<String, WireField> = BTreeMap::new();
473        for arm in arms {
474            let mut one = BTreeMap::new();
475            collect(arm, &mut one);
476            for (name, field) in one {
477                from_arms
478                    .entry(name)
479                    .and_modify(|held| {
480                        held.required &= field.required;
481                        if held.descriptor != field.descriptor {
482                            "union".clone_into(&mut held.descriptor);
483                        }
484                    })
485                    .or_insert(field);
486            }
487        }
488        fields.extend(from_arms);
489    }
490    fields.into_values().collect()
491}