Skip to main content

krabka_sdk_conformance/
protocol.rs

1//! JSON-lines protocol shared by the conformance harness and SDK adapters.
2
3use serde::{Deserialize, Serialize};
4
5/// Major version for the v1 SDK conformance contract.
6pub const CONTRACT_MAJOR: u16 = 1;
7
8/// Minor version for adapters that only implement the original v1.0 contract.
9pub const CONTRACT_MINOR_V1_0: u16 = 0;
10
11/// Minor version for adapters that implement queue RPC shapes.
12pub const CONTRACT_MINOR_QUEUE_RPC: u16 = 1;
13
14/// A command sent from the conformance harness to a language adapter.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16#[serde(tag = "cmd", rename_all = "snake_case")]
17pub enum Command {
18    /// Negotiate the contract major and adapter language.
19    Hello,
20    /// Configure the SDK endpoint and optional bearer token.
21    Configure {
22        /// Gateway endpoint URL.
23        endpoint: String,
24        /// Optional bearer token to forward through the SDK.
25        bearer: Option<String>,
26    },
27    /// Publish a raw record.
28    Publish {
29        /// Topic name.
30        topic: String,
31        /// Base64-encoded record value.
32        value_b64: String,
33        /// Binary headers in publish order.
34        headers: Vec<Header>,
35    },
36    /// Publish a `CloudEvent` in binary mode.
37    PublishEvent {
38        /// Topic name.
39        topic: String,
40        /// `CloudEvent` payload and attributes.
41        event: CloudEvent,
42    },
43    /// Open or replace a subscription.
44    Subscribe {
45        /// Topics to subscribe to.
46        topics: Vec<String>,
47        /// Consumer group id.
48        group: String,
49        /// Optional structured equality filter.
50        filter: Option<Filter>,
51    },
52    /// Pull the next message from the active subscription.
53    NextMessage {
54        /// Maximum wait time in milliseconds.
55        timeout_ms: u64,
56    },
57    /// Queue acquire stub command.
58    QueueAcquire {
59        /// Topic/queue name.
60        topic: String,
61        /// Worker group id.
62        group: String,
63        /// Maximum number of messages.
64        max: u32,
65        /// Lock duration in milliseconds.
66        lock_duration_ms: u64,
67        /// Optional session id returned by a prior acquire.
68        #[serde(default)]
69        session_id: String,
70    },
71    /// Queue ack stub command kept in the v1.0 stub-vector group.
72    QueueAck {
73        /// Message id to ack.
74        message_id: String,
75    },
76    /// Queue acknowledge command for the v1.1 queue RPC contract group.
77    QueueAcknowledge {
78        /// Session id returned by queue acquire.
79        session_id: String,
80        /// Entries to acknowledge.
81        entries: Vec<QueueAckEntry>,
82    },
83    /// Queue renew command for the v1.1 queue RPC contract group.
84    QueueRenew {
85        /// Session id returned by queue acquire.
86        session_id: String,
87        /// Entries to renew.
88        entries: Vec<QueueRenewEntry>,
89    },
90    /// Database connect stub command.
91    DbConnect {
92        /// Database name.
93        name: String,
94    },
95    /// Auth sign-in placeholder command.
96    AuthSignIn {
97        /// Username.
98        username: String,
99        /// Password.
100        password: String,
101    },
102    /// Blob put stub command.
103    BlobPut {
104        /// Blob key.
105        key: String,
106        /// Base64-encoded blob body.
107        value_b64: String,
108    },
109    /// Blob get stub command.
110    BlobGet {
111        /// Blob key.
112        key: String,
113    },
114}
115
116/// A response sent by an adapter for a single [`Command`].
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum Response {
120    /// Contract negotiation response.
121    Hello {
122        /// Adapter contract major.
123        contract_major: u16,
124        /// Adapter contract minor.
125        contract_minor: u16,
126        /// Adapter language name.
127        language: String,
128    },
129    /// Successful command response.
130    Ok(serde_json::Value),
131    /// Message delivered by a subscription.
132    Message(Message),
133    /// Adapter or SDK error.
134    Error(AdapterError),
135}
136
137/// Wire error taxonomy shared across language SDKs.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum ErrorKind {
141    /// Transport or endpoint reachability failure.
142    Transport,
143    /// Authentication failed or credentials were absent.
144    Unauthenticated,
145    /// Caller supplied an invalid argument.
146    InvalidArgument,
147    /// Target resource was not found.
148    NotFound,
149    /// Server-side failure outside the narrower classes.
150    ServerError,
151    /// Module is intentionally gated on later work.
152    Unimplemented,
153}
154
155/// Error body returned by adapters.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct AdapterError {
158    /// Taxonomy kind.
159    pub kind: ErrorKind,
160    /// Optional SDK module name.
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub module: Option<String>,
163    /// Optional plan/spec slug gating an unimplemented module.
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub gated_on: Option<String>,
166    /// Optional diagnostic message.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub message: Option<String>,
169}
170
171impl AdapterError {
172    /// Build a pinned unimplemented-module error.
173    #[must_use]
174    pub fn unimplemented(module: &str, gated_on: &str) -> Self {
175        Self {
176            kind: ErrorKind::Unimplemented,
177            module: Some(module.to_string()),
178            gated_on: Some(gated_on.to_string()),
179            message: None,
180        }
181    }
182
183    /// Build an error with only a kind and message.
184    #[must_use]
185    pub fn with_message(kind: ErrorKind, message: impl Into<String>) -> Self {
186        Self {
187            kind,
188            module: None,
189            gated_on: None,
190            message: Some(message.into()),
191        }
192    }
193}
194
195/// Binary record header.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct Header {
198    /// Header name.
199    pub name: String,
200    /// Base64-encoded header value, or null for a Kafka null header value.
201    pub value_b64: Option<String>,
202}
203
204/// Message response body.
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206pub struct Message {
207    /// Topic name.
208    pub topic: String,
209    /// Partition index.
210    pub partition: i32,
211    /// Record offset.
212    pub offset: i64,
213    /// Base64-encoded record value.
214    pub value_b64: String,
215    /// Binary headers in delivery order.
216    pub headers: Vec<Header>,
217}
218
219/// Queue acknowledgement type.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
221#[serde(rename_all = "snake_case")]
222pub enum QueueAckType {
223    /// Accept and remove the queue message.
224    Accept,
225    /// Release the queue message for redelivery.
226    Release,
227    /// Reject the queue message.
228    Reject,
229}
230
231/// Queue acknowledgement entry.
232#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
233pub struct QueueAckEntry {
234    /// Message id returned by queue acquire.
235    pub message_id: String,
236    /// Ack verdict.
237    pub ack_type: QueueAckType,
238}
239
240/// Queue renew entry.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct QueueRenewEntry {
243    /// Message id returned by queue acquire.
244    pub message_id: String,
245}
246
247/// Minimal `CloudEvents` v1 binary-mode payload.
248#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
249pub struct CloudEvent {
250    /// Event id.
251    pub id: String,
252    /// Event source.
253    pub source: String,
254    /// Event type.
255    #[serde(rename = "type")]
256    pub type_: String,
257    /// `CloudEvents` specversion.
258    pub specversion: String,
259    /// Optional data content type.
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub datacontenttype: Option<String>,
262    /// Base64-encoded event data.
263    pub data_b64: String,
264}
265
266/// Structured subscription filter.
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
268pub struct Filter {
269    /// JSON field path. v1 mock vectors support `$.field`.
270    pub path: String,
271    /// Predicate operation.
272    pub op: FilterOp,
273    /// Expected scalar JSON value.
274    pub value: serde_json::Value,
275}
276
277/// Supported filter operation.
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "snake_case")]
280pub enum FilterOp {
281    /// Equality comparison.
282    Equals,
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn protocol_round_trips() {
291        let cmd = Command::Publish {
292            topic: "t".into(),
293            value_b64: "aGk=".into(),
294            headers: vec![],
295        };
296        let line = serde_json::to_string(&cmd).unwrap();
297        assert_eq!(serde_json::from_str::<Command>(&line).unwrap(), cmd);
298
299        let err: Response = serde_json::from_str(
300            r#"{"error":{"kind":"unimplemented","module":"queues","gated_on":"gateway-sharegroup-rpc"}}"#,
301        )
302        .unwrap();
303        assert2::assert!(let Response::Error(e) = err);
304        assert_eq!(e.kind, ErrorKind::Unimplemented);
305        assert_eq!(e.gated_on.as_deref(), Some("gateway-sharegroup-rpc"));
306    }
307}