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    ReadContext,
218    CaptureArtifact,
219    SubscribeEvents,
220    SubmitJob,
221    ReadJob,
222    CancelJob,
223    IssuePrincipal,
224    RevokePrincipal,
225}
226
227impl InterfaceOperation {
228    pub const fn required(self) -> &'static [Capability] {
229        match self {
230            Self::RuntimeInfo => &[Capability::SessionRead],
231            Self::CreateSession => &[Capability::SessionWrite],
232            Self::ReadSession => &[Capability::SessionRead],
233            Self::DeleteSession => &[Capability::SessionWrite],
234            Self::OpenPage => &[Capability::PageWrite],
235            Self::ReadPage => &[Capability::PageRead],
236            Self::ClosePage => &[Capability::PageWrite],
237            Self::SubmitCommand => &[Capability::BrowserMutate],
238            Self::CreateCheckpoint => &[Capability::RecoveryWrite],
239            Self::ReadCheckpoint => &[Capability::RecoveryRead],
240            Self::RecoverWorkflow => &[Capability::RecoveryWrite],
241            Self::ReadArtifact => &[Capability::ArtifactRead],
242            Self::ReadContext => &[Capability::ContextRead],
243            Self::CaptureArtifact => &[Capability::ArtifactCapture],
244            Self::SubscribeEvents => &[Capability::SessionRead],
245            Self::SubmitJob => &[Capability::JobSubmit],
246            Self::ReadJob => &[Capability::JobRead],
247            Self::CancelJob => &[Capability::JobCancel],
248            Self::IssuePrincipal => &[Capability::AuthorityAdmin],
249            Self::RevokePrincipal => &[Capability::AuthorityAdmin],
250        }
251    }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256#[serde(tag = "kind", rename_all = "camelCase")]
257pub enum InterfaceEvent {
258    CommandOutcome {
259        cursor: EventCursor,
260        command_id: CommandId,
261        outcome: CommandOutcome,
262    },
263    ArtifactCaptured {
264        cursor: EventCursor,
265        artifact_id: ArtifactId,
266    },
267    EventGap {
268        earliest_available_cursor: EventCursor,
269    },
270}
271
272#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274#[serde(rename_all = "camelCase")]
275pub enum InterfaceErrorCode {
276    InvalidRequest,
277    UnsupportedInterfaceVersion,
278    InvalidIdempotencyKey,
279    IdempotencyConflict,
280    DeadlineExceeded,
281    AuthenticationFailed,
282    TokenExpired,
283    MissingCapability,
284    MalformedScope,
285    ArtifactDenied,
286    UnsupportedOperation,
287    NotFound,
288    ResourceExhausted,
289    Internal,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
294#[serde(rename_all = "camelCase")]
295pub struct InterfaceError {
296    pub code: InterfaceErrorCode,
297    pub layer: ErrorLayer,
298    pub message: String,
299    pub correlation_id: CorrelationId,
300    pub command_id: Option<CommandId>,
301    pub retryable: bool,
302    pub retry_after_ms: Option<u64>,
303    pub reconciliation_required: bool,
304    pub required_capability: Option<Capability>,
305}
306
307#[derive(Debug, Clone, Error, Eq, PartialEq)]
308pub enum InterfaceValidationError {
309    #[error("unsupported interface version: {0}")]
310    UnsupportedInterfaceVersion(String),
311    #[error("idempotency key must contain 1-128 printable ASCII characters")]
312    InvalidIdempotencyKey,
313    #[error("request deadline must be after dispatch time")]
314    ExpiredDeadline,
315}