Skip to main content

boxology_contract/
contract.rs

1//! Outward, importless contract and capability descriptors.
2
3use std::error::Error;
4use std::fmt;
5
6use crate::{BoxId, CapabilityId, CapabilityName, ContractRevision, Deprecation, TypeDescriptor};
7
8/// A capability's declared interaction shape.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum CapabilityShape {
11    /// One request produces one response.
12    Unary,
13    /// One request produces a stream of responses.
14    ServerStreaming,
15    /// A stream of requests produces one response.
16    ClientStreaming,
17    /// Request and response streams proceed independently.
18    BidirectionalStreaming,
19    /// A request subscribes to a stream of events.
20    EventSubscription,
21}
22
23/// The greatest exposure a capability permits.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum ExposureLevel {
26    /// Calls remain inside code composition.
27    CodeOnly,
28    /// Calls may cross an internal service boundary.
29    Internal,
30    /// Calls may cross an external service boundary.
31    External,
32}
33
34/// A capability's declared idempotency property.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum Idempotency {
37    /// No idempotency property is declared.
38    None,
39    /// Repeating the operation has the same effect as performing it once.
40    Inherent,
41}
42
43/// The complete outward description of one capability.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct CapabilityDescriptor {
46    id: CapabilityId,
47    input: TypeDescriptor,
48    output: TypeDescriptor,
49    error: TypeDescriptor,
50    shape: CapabilityShape,
51    max_exposure: ExposureLevel,
52    idempotency: Idempotency,
53    deprecation: Option<Deprecation>,
54}
55
56impl CapabilityDescriptor {
57    /// Constructs an owned capability descriptor.
58    #[allow(clippy::too_many_arguments)]
59    pub fn new(
60        id: CapabilityId,
61        input: TypeDescriptor,
62        output: TypeDescriptor,
63        error: TypeDescriptor,
64        shape: CapabilityShape,
65        max_exposure: ExposureLevel,
66        idempotency: Idempotency,
67        deprecation: Option<Deprecation>,
68    ) -> Self {
69        Self {
70            id,
71            input,
72            output,
73            error,
74            shape,
75            max_exposure,
76            idempotency,
77            deprecation,
78        }
79    }
80
81    /// Returns the box-local name from the qualified identity.
82    pub fn name(&self) -> &CapabilityName {
83        self.id.name()
84    }
85
86    /// Returns the box-qualified identity.
87    pub fn id(&self) -> &CapabilityId {
88        &self.id
89    }
90
91    /// Returns the input type slot.
92    pub fn input(&self) -> &TypeDescriptor {
93        &self.input
94    }
95
96    /// Returns the output type slot.
97    pub fn output(&self) -> &TypeDescriptor {
98        &self.output
99    }
100
101    /// Returns the structured error type slot.
102    pub fn error(&self) -> &TypeDescriptor {
103        &self.error
104    }
105
106    /// Returns the interaction shape.
107    pub fn shape(&self) -> CapabilityShape {
108        self.shape
109    }
110
111    /// Returns the maximum permitted exposure.
112    pub fn max_exposure(&self) -> ExposureLevel {
113        self.max_exposure
114    }
115
116    /// Returns the declared idempotency property.
117    pub fn idempotency(&self) -> Idempotency {
118        self.idempotency
119    }
120
121    /// Returns the optional deprecation metadata.
122    pub fn deprecation(&self) -> Option<&Deprecation> {
123        self.deprecation.as_ref()
124    }
125}
126
127/// One box's ordered, outward contract without implementation imports.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ContractDescriptor {
130    box_id: BoxId,
131    capabilities: Vec<CapabilityDescriptor>,
132    revision: ContractRevision,
133}
134
135impl ContractDescriptor {
136    /// Constructs a contract after validating capability ownership and uniqueness.
137    pub fn new(
138        box_id: BoxId,
139        capabilities: impl IntoIterator<Item = CapabilityDescriptor>,
140        revision: ContractRevision,
141    ) -> Result<Self, ContractDescriptorError> {
142        let mut accepted: Vec<CapabilityDescriptor> = Vec::new();
143        for capability in capabilities {
144            if capability.id().box_id() != &box_id {
145                return Err(ContractDescriptorError::CapabilityBoxMismatch {
146                    contract_box: box_id,
147                    capability: capability.id,
148                });
149            }
150            if accepted.iter().any(|known| known.id() == capability.id()) {
151                return Err(ContractDescriptorError::DuplicateCapability {
152                    capability: capability.id,
153                });
154            }
155            accepted.push(capability);
156        }
157        Ok(Self {
158            box_id,
159            capabilities: accepted,
160            revision,
161        })
162    }
163
164    /// Returns the box identity.
165    pub fn box_id(&self) -> &BoxId {
166        &self.box_id
167    }
168
169    /// Returns capabilities in declared order.
170    pub fn capabilities(&self) -> &[CapabilityDescriptor] {
171        &self.capabilities
172    }
173
174    /// Returns the opaque contract revision.
175    pub fn revision(&self) -> &ContractRevision {
176        &self.revision
177    }
178}
179
180/// A failure to construct an outward contract descriptor.
181#[derive(Debug, Clone, PartialEq, Eq)]
182#[non_exhaustive]
183pub enum ContractDescriptorError {
184    /// A capability belonged to a different box.
185    CapabilityBoxMismatch {
186        /// The box whose contract was being constructed.
187        contract_box: BoxId,
188        /// The mismatched qualified capability identity.
189        capability: CapabilityId,
190    },
191    /// A qualified capability identity appeared more than once.
192    DuplicateCapability {
193        /// The repeated capability identity.
194        capability: CapabilityId,
195    },
196}
197
198impl fmt::Display for ContractDescriptorError {
199    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
200        match self {
201            Self::CapabilityBoxMismatch {
202                contract_box,
203                capability,
204            } => write!(
205                formatter,
206                "capability {capability} does not belong to contract box {contract_box}"
207            ),
208            Self::DuplicateCapability { capability } => {
209                write!(formatter, "duplicate capability: {capability}")
210            }
211        }
212    }
213}
214
215impl Error for ContractDescriptorError {}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::DescriptorRef;
221
222    fn id(box_name: &str, capability: &str) -> CapabilityId {
223        CapabilityId::new(
224            BoxId::new(box_name).unwrap(),
225            CapabilityName::new(capability).unwrap(),
226        )
227    }
228
229    fn capability(
230        id: CapabilityId,
231        shape: CapabilityShape,
232        idempotency: Idempotency,
233    ) -> CapabilityDescriptor {
234        CapabilityDescriptor::new(
235            id,
236            TypeDescriptor::string(),
237            TypeDescriptor::u64(),
238            TypeDescriptor::bool(),
239            shape,
240            ExposureLevel::Internal,
241            idempotency,
242            (idempotency == Idempotency::None)
243                .then(|| Deprecation::new(Some("use replacement".into()))),
244        )
245    }
246
247    #[test]
248    fn complete_outward_view_preserves_capability_order() {
249        let box_id = BoxId::new("billing").unwrap();
250        let first = capability(
251            id("billing", "quote"),
252            CapabilityShape::Unary,
253            Idempotency::None,
254        );
255        let second = capability(
256            id("billing", "invoice"),
257            CapabilityShape::ServerStreaming,
258            Idempotency::Inherent,
259        );
260        let revision = ContractRevision::new("sha256:123").unwrap();
261        let contract = ContractDescriptor::new(
262            box_id.clone(),
263            [first.clone(), second.clone()],
264            revision.clone(),
265        )
266        .unwrap();
267
268        assert_eq!(contract.box_id(), &box_id);
269        assert_eq!(contract.revision(), &revision);
270        assert_eq!(contract.capabilities(), &[first, second]);
271        let viewed = &contract.capabilities()[0];
272        assert_eq!(viewed.name().as_str(), "quote");
273        assert_eq!(viewed.id(), &id("billing", "quote"));
274        assert_eq!(viewed.input().view(), DescriptorRef::String);
275        assert_eq!(viewed.output().view(), DescriptorRef::U64);
276        assert_eq!(viewed.error().view(), DescriptorRef::Bool);
277        assert_eq!(viewed.shape(), CapabilityShape::Unary);
278        assert_eq!(viewed.max_exposure(), ExposureLevel::Internal);
279        assert_eq!(viewed.idempotency(), Idempotency::None);
280        assert_eq!(
281            viewed.deprecation().unwrap().note(),
282            Some("use replacement")
283        );
284        assert_eq!(contract.capabilities()[1].deprecation(), None);
285    }
286
287    #[test]
288    fn outward_contract_construction_requires_no_implementation_data() {
289        let contract = ContractDescriptor::new(
290            BoxId::new("empty").unwrap(),
291            [],
292            ContractRevision::new("r1").unwrap(),
293        )
294        .unwrap();
295        assert!(contract.capabilities().is_empty());
296    }
297
298    #[test]
299    fn every_interaction_shape_and_idempotency_variant_is_constructible() {
300        let shapes = [
301            CapabilityShape::Unary,
302            CapabilityShape::ServerStreaming,
303            CapabilityShape::ClientStreaming,
304            CapabilityShape::BidirectionalStreaming,
305            CapabilityShape::EventSubscription,
306        ];
307        for (index, shape) in shapes.into_iter().enumerate() {
308            let idempotency = if index % 2 == 0 {
309                Idempotency::None
310            } else {
311                Idempotency::Inherent
312            };
313            let descriptor = capability(id("box", &format!("cap_{index}")), shape, idempotency);
314            assert_eq!(descriptor.shape(), shape);
315            assert_eq!(descriptor.idempotency(), idempotency);
316        }
317    }
318
319    #[test]
320    fn exposure_levels_form_the_declared_lattice() {
321        assert!(ExposureLevel::CodeOnly < ExposureLevel::Internal);
322        assert!(ExposureLevel::Internal < ExposureLevel::External);
323    }
324
325    #[test]
326    fn contract_rejects_identity_mismatch_exactly() {
327        let error = ContractDescriptor::new(
328            BoxId::new("expected").unwrap(),
329            [capability(
330                id("other", "run"),
331                CapabilityShape::Unary,
332                Idempotency::None,
333            )],
334            ContractRevision::new("r1").unwrap(),
335        )
336        .unwrap_err();
337        assert_eq!(
338            error,
339            ContractDescriptorError::CapabilityBoxMismatch {
340                contract_box: BoxId::new("expected").unwrap(),
341                capability: id("other", "run"),
342            }
343        );
344        assert_eq!(
345            error.to_string(),
346            "capability other.run does not belong to contract box expected"
347        );
348    }
349
350    #[test]
351    fn contract_rejects_duplicate_capabilities_exactly() {
352        let duplicate = capability(id("box", "run"), CapabilityShape::Unary, Idempotency::None);
353        let error = ContractDescriptor::new(
354            BoxId::new("box").unwrap(),
355            [duplicate.clone(), duplicate],
356            ContractRevision::new("r1").unwrap(),
357        )
358        .unwrap_err();
359        assert_eq!(
360            error,
361            ContractDescriptorError::DuplicateCapability {
362                capability: id("box", "run"),
363            }
364        );
365        assert_eq!(error.to_string(), "duplicate capability: box.run");
366    }
367
368    #[test]
369    fn descriptors_are_structurally_equal_owned_plain_data() {
370        fn assert_bounds<T: Send + Sync + 'static>() {}
371
372        let build = || {
373            ContractDescriptor::new(
374                BoxId::new("box").unwrap(),
375                [capability(
376                    id("box", "run"),
377                    CapabilityShape::Unary,
378                    Idempotency::None,
379                )],
380                ContractRevision::new("r1").unwrap(),
381            )
382            .unwrap()
383        };
384        assert_eq!(build(), build());
385        assert_bounds::<CapabilityShape>();
386        assert_bounds::<ExposureLevel>();
387        assert_bounds::<Idempotency>();
388        assert_bounds::<CapabilityDescriptor>();
389        assert_bounds::<ContractDescriptor>();
390        assert_bounds::<ContractDescriptorError>();
391    }
392}