use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ControlRequest {
pub url: String,
#[serde(default = "default_method")]
pub method: String,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials: Option<String>,
}
fn default_method() -> String {
"GET".to_string()
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Command {
pub id: u64,
pub url: String,
pub method: String,
pub headers: BTreeMap<String, String>,
pub body: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub credentials: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CancelCommand {
pub id: u64,
pub cancel: bool,
}
impl CancelCommand {
#[must_use]
pub fn new(id: u64) -> Self {
Self { id, cancel: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserReply {
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub headers: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error: Option<String>,
}
pub enum ReplyOutcome {
Success {
status: u16,
headers: BTreeMap<String, String>,
body: String,
encoding: Option<String>,
},
Error(String),
}
impl BrowserReply {
pub fn outcome(self) -> ReplyOutcome {
match self.error {
Some(error) => ReplyOutcome::Error(error),
None => ReplyOutcome::Success {
status: self.status.unwrap_or(0),
headers: self.headers.unwrap_or_default(),
body: self.body.unwrap_or_default(),
encoding: self.encoding,
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BrowserFrame {
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub status: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub headers: Option<BTreeMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub body: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub chunk: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub seq: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub done: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamItem {
Head {
status: u16,
headers: BTreeMap<String, String>,
},
Chunk {
seq: u64,
data: String,
},
End,
Error(String),
}
impl BrowserFrame {
#[must_use]
pub fn into_reply(self) -> BrowserReply {
BrowserReply {
id: self.id,
status: self.status,
headers: self.headers,
body: self.body,
encoding: self.encoding,
error: self.error,
}
}
#[must_use]
pub fn stream_item(self) -> StreamItem {
if let Some(error) = self.error {
StreamItem::Error(error)
} else if self.done == Some(true) {
StreamItem::End
} else if let Some(data) = self.chunk {
StreamItem::Chunk {
seq: self.seq.unwrap_or(0),
data,
}
} else {
StreamItem::Head {
status: self.status.unwrap_or(0),
headers: self.headers.unwrap_or_default(),
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum StreamLine {
Head {
status: u16,
headers: BTreeMap<String, String>,
},
Chunk {
seq: u64,
chunk: String,
},
Done {
done: bool,
},
Error {
error: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResponseEnvelope {
pub id: u64,
pub status: u16,
pub headers: BTreeMap<String, String>,
pub body: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub encoding: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TabInfo {
pub id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StatusResponse {
pub connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_origin: Option<String>,
pub tabs: Vec<TabInfo>,
pub pending: usize,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn control_request_defaults_method_and_body() {
let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
assert_eq!(req.method, "GET");
assert!(req.body.is_none());
assert!(req.headers.is_empty());
assert!(req.allow_origin.is_none());
}
#[test]
fn control_request_omits_allow_origin_when_absent() {
let req = ControlRequest {
url: "/x".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
target: None,
allow_origin: None,
credentials: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("allow_origin"));
let with = ControlRequest {
allow_origin: Some("https://ok.test".to_string()),
..req
};
let json = serde_json::to_string(&with).unwrap();
let back: ControlRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.allow_origin.as_deref(), Some("https://ok.test"));
}
#[test]
fn command_round_trips_and_is_newline_free() {
let cmd = Command {
id: 7,
url: "/loki/api/v1/labels".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
credentials: None,
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(!json.contains('\n'));
assert!(!json.contains("stream"));
let back: Command = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, back);
}
#[test]
fn streaming_command_serialises_stream_flag() {
let cmd = Command {
id: 1,
url: "/sse".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: true,
credentials: None,
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains("\"stream\":true"));
}
#[test]
fn cancel_command_serialises_with_cancel_true() {
let json = serde_json::to_string(&CancelCommand::new(9)).unwrap();
assert_eq!(json, r#"{"id":9,"cancel":true}"#);
}
#[test]
fn frame_classifies_stream_head_chunk_and_end() {
let head: BrowserFrame =
serde_json::from_str(r#"{"id":1,"status":200,"headers":{"a":"b"},"stream":true}"#)
.unwrap();
assert!(matches!(
head.stream_item(),
StreamItem::Head { status: 200, headers } if headers.get("a").map(String::as_str) == Some("b")
));
let chunk: BrowserFrame =
serde_json::from_str(r#"{"id":1,"seq":3,"chunk":"aGk="}"#).unwrap();
assert!(matches!(
chunk.stream_item(),
StreamItem::Chunk { seq: 3, data } if data == "aGk="
));
let end: BrowserFrame = serde_json::from_str(r#"{"id":1,"done":true}"#).unwrap();
assert_eq!(end.stream_item(), StreamItem::End);
let err: BrowserFrame = serde_json::from_str(r#"{"id":1,"error":"boom"}"#).unwrap();
assert_eq!(err.stream_item(), StreamItem::Error("boom".into()));
}
#[test]
fn frame_into_reply_preserves_buffered_fields() {
let frame: BrowserFrame = serde_json::from_str(
r#"{"id":2,"status":200,"headers":{},"body":"hi","encoding":"base64"}"#,
)
.unwrap();
let reply = frame.into_reply();
assert_eq!(reply.id, 2);
assert!(matches!(
reply.outcome(),
ReplyOutcome::Success { body, encoding, .. }
if body == "hi" && encoding.as_deref() == Some("base64")
));
}
#[test]
fn stream_lines_round_trip_untagged() {
for (line, json) in [
(
StreamLine::Head {
status: 200,
headers: BTreeMap::new(),
},
r#"{"status":200,"headers":{}}"#,
),
(
StreamLine::Chunk {
seq: 0,
chunk: "aGk=".into(),
},
r#"{"seq":0,"chunk":"aGk="}"#,
),
(StreamLine::Done { done: true }, r#"{"done":true}"#),
(
StreamLine::Error {
error: "boom".into(),
},
r#"{"error":"boom"}"#,
),
] {
let serialised = serde_json::to_string(&line).unwrap();
assert_eq!(serialised, json);
assert!(!serialised.contains('\n'));
let back: StreamLine = serde_json::from_str(json).unwrap();
assert_eq!(back, line);
}
}
#[test]
fn control_request_defaults_credentials_to_none() {
let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
assert!(req.credentials.is_none());
}
#[test]
fn control_request_omits_credentials_when_absent() {
let req = ControlRequest {
url: "/x".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
target: None,
allow_origin: None,
credentials: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(!json.contains("credentials"));
}
#[test]
fn command_serializes_credentials_when_present() {
let cmd = Command {
id: 1,
url: "/x".to_string(),
method: "GET".to_string(),
headers: BTreeMap::new(),
body: None,
stream: false,
credentials: Some("omit".to_string()),
};
let json = serde_json::to_string(&cmd).unwrap();
assert!(json.contains(r#""credentials":"omit""#));
let back: Command = serde_json::from_str(&json).unwrap();
assert_eq!(cmd, back);
}
#[test]
fn success_reply_classifies_as_success() {
let reply: BrowserReply =
serde_json::from_str(r#"{"id":7,"status":200,"headers":{"a":"b"},"body":"hi"}"#)
.unwrap();
assert_eq!(reply.id, 7);
assert!(
matches!(reply.outcome(),
ReplyOutcome::Success { status, headers, body, encoding }
if status == 200
&& headers.get("a").map(String::as_str) == Some("b")
&& body == "hi"
&& encoding.is_none()),
"success reply must classify as Success with the expected fields"
);
}
#[test]
fn base64_reply_carries_encoding_through_outcome() {
let reply: BrowserReply = serde_json::from_str(
r#"{"id":7,"status":200,"headers":{},"body":"iVBOR=","encoding":"base64"}"#,
)
.unwrap();
assert!(
matches!(reply.outcome(),
ReplyOutcome::Success { body, encoding, .. }
if body == "iVBOR=" && encoding.as_deref() == Some("base64")),
"base64 reply must classify as Success with its encoding preserved"
);
}
#[test]
fn text_reply_omits_encoding_on_serialise() {
let reply = BrowserReply {
id: 1,
status: Some(200),
headers: None,
body: Some("hi".into()),
encoding: None,
error: None,
};
let json = serde_json::to_string(&reply).unwrap();
assert!(!json.contains("encoding"));
}
#[test]
fn envelope_omits_encoding_when_text() {
let env = ResponseEnvelope {
id: 1,
status: 200,
headers: BTreeMap::new(),
body: "hi".into(),
encoding: None,
};
let json = serde_json::to_string(&env).unwrap();
assert!(!json.contains("encoding"));
}
#[test]
fn error_reply_classifies_as_error() {
let reply: BrowserReply =
serde_json::from_str(r#"{"id":7,"error":"Failed to fetch"}"#).unwrap();
match reply.outcome() {
ReplyOutcome::Error(msg) => assert_eq!(msg, "Failed to fetch"),
ReplyOutcome::Success { .. } => panic!("expected error"),
}
}
#[test]
fn status_response_omits_origin_when_absent() {
let s = StatusResponse {
connected: false,
browser_origin: None,
tabs: Vec::new(),
pending: 0,
};
let json = serde_json::to_string(&s).unwrap();
assert!(!json.contains("browser_origin"));
}
}