Skip to main content

bobby_browser_client/
interface.rs

1//! Interface versioning, correlation, errors, and operation authority.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use thiserror::Error;
6use uuid::Uuid;
7
8use crate::{
9    ArtifactId, Capability, CapabilitySet, CommandId, CommandOutcome, ErrorLayer, PrincipalId,
10};
11
12/// Value of the `x-interface-version` header for this release.
13pub const CURRENT_INTERFACE_VERSION: &str = "2026-07-23";
14
15/// Marker for the sole supported interface version ([`CURRENT_INTERFACE_VERSION`]).
16#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct InterfaceVersion;
19
20impl InterfaceVersion {
21    pub const CURRENT: Self = Self;
22}
23
24impl TryFrom<&str> for InterfaceVersion {
25    type Error = InterfaceValidationError;
26
27    fn try_from(value: &str) -> Result<Self, Self::Error> {
28        if value == CURRENT_INTERFACE_VERSION {
29            Ok(Self)
30        } else {
31            Err(InterfaceValidationError::UnsupportedInterfaceVersion(
32                value.to_owned(),
33            ))
34        }
35    }
36}
37
38impl TryFrom<String> for InterfaceVersion {
39    type Error = InterfaceValidationError;
40
41    fn try_from(value: String) -> Result<Self, Self::Error> {
42        Self::try_from(value.as_str())
43    }
44}
45
46impl Serialize for InterfaceVersion {
47    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
48    where
49        S: Serializer,
50    {
51        serializer.serialize_str(CURRENT_INTERFACE_VERSION)
52    }
53}
54
55impl<'de> Deserialize<'de> for InterfaceVersion {
56    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
57    where
58        D: Deserializer<'de>,
59    {
60        let value = String::deserialize(deserializer)?;
61        Self::try_from(value).map_err(serde::de::Error::custom)
62    }
63}
64
65/// Correlation id for a single request (`x-correlation-id`).
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
68#[serde(transparent)]
69pub struct CorrelationId(Uuid);
70
71impl CorrelationId {
72    pub fn new() -> Self {
73        Self(Uuid::new_v4())
74    }
75
76    pub fn from_uuid(value: Uuid) -> Self {
77        Self(value)
78    }
79
80    pub fn as_uuid(&self) -> Uuid {
81        self.0
82    }
83}
84
85impl Default for CorrelationId {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91/// Caller-provided key that identifies a repeated request (`idempotency-key`).
92#[derive(Debug, Clone, PartialEq, Eq, Hash)]
93#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
94pub struct IdempotencyKey(String);
95
96impl IdempotencyKey {
97    pub fn as_str(&self) -> &str {
98        &self.0
99    }
100}
101
102impl TryFrom<&str> for IdempotencyKey {
103    type Error = InterfaceValidationError;
104
105    fn try_from(value: &str) -> Result<Self, Self::Error> {
106        if !(1..=128).contains(&value.len())
107            || !value
108                .as_bytes()
109                .iter()
110                .all(|byte| (0x20..=0x7e).contains(byte))
111        {
112            return Err(InterfaceValidationError::InvalidIdempotencyKey);
113        }
114        Ok(Self(value.to_owned()))
115    }
116}
117
118impl TryFrom<String> for IdempotencyKey {
119    type Error = InterfaceValidationError;
120
121    fn try_from(value: String) -> Result<Self, Self::Error> {
122        Self::try_from(value.as_str())
123    }
124}
125
126impl Serialize for IdempotencyKey {
127    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
128    where
129        S: Serializer,
130    {
131        serializer.serialize_str(&self.0)
132    }
133}
134
135impl<'de> Deserialize<'de> for IdempotencyKey {
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        let value = String::deserialize(deserializer)?;
141        Self::try_from(value).map_err(serde::de::Error::custom)
142    }
143}
144
145#[derive(
146    Debug, Clone, Copy, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize,
147)]
148#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
149#[serde(transparent)]
150pub struct EventCursor(pub u64);
151
152impl EventCursor {
153    pub const ZERO: Self = Self(0);
154}
155
156impl From<u64> for EventCursor {
157    fn from(value: u64) -> Self {
158        Self(value)
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
164#[serde(rename_all = "camelCase")]
165pub struct RequestContext {
166    pub interface_version: InterfaceVersion,
167    pub correlation_id: CorrelationId,
168    pub principal_id: PrincipalId,
169    pub capabilities: CapabilitySet,
170    pub deadline: DateTime<Utc>,
171    pub idempotency_key: Option<IdempotencyKey>,
172}
173
174impl RequestContext {
175    pub fn new_for_test(
176        principal_id: PrincipalId,
177        capabilities: impl IntoIterator<Item = Capability>,
178        deadline: DateTime<Utc>,
179    ) -> Self {
180        Self {
181            interface_version: InterfaceVersion::CURRENT,
182            correlation_id: CorrelationId::new(),
183            principal_id,
184            capabilities: CapabilitySet::new(capabilities),
185            deadline,
186            idempotency_key: None,
187        }
188    }
189
190    /// Checks values whose validity depends on the instant an adapter dispatches the request.
191    pub fn validate_at(
192        &self,
193        dispatch_time: DateTime<Utc>,
194    ) -> Result<(), InterfaceValidationError> {
195        if self.deadline <= dispatch_time {
196            return Err(InterfaceValidationError::ExpiredDeadline);
197        }
198        Ok(())
199    }
200}
201
202/// Transport-neutral interface operations and their required capabilities.
203#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
204pub enum InterfaceOperation {
205    RuntimeInfo,
206    CreateSession,
207    ReadSession,
208    DeleteSession,
209    OpenPage,
210    ReadPage,
211    ClosePage,
212    SubmitCommand,
213    CreateCheckpoint,
214    ReadCheckpoint,
215    RecoverWorkflow,
216    ReadArtifact,
217    CaptureArtifact,
218    SubscribeEvents,
219    SubmitJob,
220    ReadJob,
221    CancelJob,
222    IssuePrincipal,
223    RevokePrincipal,
224}
225
226impl InterfaceOperation {
227    pub const fn required(self) -> &'static [Capability] {
228        match self {
229            Self::RuntimeInfo => &[Capability::SessionRead],
230            Self::CreateSession => &[Capability::SessionWrite],
231            Self::ReadSession => &[Capability::SessionRead],
232            Self::DeleteSession => &[Capability::SessionWrite],
233            Self::OpenPage => &[Capability::PageWrite],
234            Self::ReadPage => &[Capability::PageRead],
235            Self::ClosePage => &[Capability::PageWrite],
236            Self::SubmitCommand => &[Capability::BrowserMutate],
237            Self::CreateCheckpoint => &[Capability::RecoveryWrite],
238            Self::ReadCheckpoint => &[Capability::RecoveryRead],
239            Self::RecoverWorkflow => &[Capability::RecoveryWrite],
240            Self::ReadArtifact => &[Capability::ArtifactRead],
241            Self::CaptureArtifact => &[Capability::ArtifactCapture],
242            Self::SubscribeEvents => &[Capability::SessionRead],
243            Self::SubmitJob => &[Capability::JobSubmit],
244            Self::ReadJob => &[Capability::JobRead],
245            Self::CancelJob => &[Capability::JobCancel],
246            Self::IssuePrincipal => &[Capability::AuthorityAdmin],
247            Self::RevokePrincipal => &[Capability::AuthorityAdmin],
248        }
249    }
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
254#[serde(tag = "kind", rename_all = "camelCase")]
255pub enum InterfaceEvent {
256    CommandOutcome {
257        cursor: EventCursor,
258        command_id: CommandId,
259        outcome: CommandOutcome,
260    },
261    ArtifactCaptured {
262        cursor: EventCursor,
263        artifact_id: ArtifactId,
264    },
265    EventGap {
266        earliest_available_cursor: EventCursor,
267    },
268}
269
270#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272#[serde(rename_all = "camelCase")]
273pub enum InterfaceErrorCode {
274    InvalidRequest,
275    UnsupportedInterfaceVersion,
276    InvalidIdempotencyKey,
277    IdempotencyConflict,
278    DeadlineExceeded,
279    AuthenticationFailed,
280    TokenExpired,
281    MissingCapability,
282    MalformedScope,
283    ArtifactDenied,
284    UnsupportedOperation,
285    NotFound,
286    ResourceExhausted,
287    Internal,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
292#[serde(rename_all = "camelCase")]
293pub struct InterfaceError {
294    pub code: InterfaceErrorCode,
295    pub layer: ErrorLayer,
296    pub message: String,
297    pub correlation_id: CorrelationId,
298    pub command_id: Option<CommandId>,
299    pub retryable: bool,
300    pub retry_after_ms: Option<u64>,
301    pub reconciliation_required: bool,
302    pub required_capability: Option<Capability>,
303}
304
305#[derive(Debug, Clone, Error, Eq, PartialEq)]
306pub enum InterfaceValidationError {
307    #[error("unsupported interface version: {0}")]
308    UnsupportedInterfaceVersion(String),
309    #[error("idempotency key must contain 1-128 printable ASCII characters")]
310    InvalidIdempotencyKey,
311    #[error("request deadline must be after dispatch time")]
312    ExpiredDeadline,
313}