1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct ControlRequest {
23 pub url: String,
25 #[serde(default = "default_method")]
27 pub method: String,
28 #[serde(default)]
30 pub headers: BTreeMap<String, String>,
31 #[serde(default)]
33 pub body: Option<String>,
34 #[serde(default)]
37 pub stream: bool,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub target: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub allow_origin: Option<String>,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub credentials: Option<String>,
57}
58
59fn default_method() -> String {
60 "GET".to_string()
61}
62
63#[allow(clippy::trivially_copy_pass_by_ref)]
67fn is_false(b: &bool) -> bool {
68 !*b
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct Command {
76 pub id: u64,
78 pub url: String,
80 pub method: String,
82 pub headers: BTreeMap<String, String>,
84 pub body: Option<String>,
86 #[serde(default, skip_serializing_if = "is_false")]
89 pub stream: bool,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub credentials: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101pub struct CancelCommand {
102 pub id: u64,
104 pub cancel: bool,
106}
107
108impl CancelCommand {
109 #[must_use]
111 pub fn new(id: u64) -> Self {
112 Self { id, cancel: true }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct BrowserReply {
124 pub id: u64,
126 #[serde(skip_serializing_if = "Option::is_none", default)]
128 pub status: Option<u16>,
129 #[serde(skip_serializing_if = "Option::is_none", default)]
131 pub headers: Option<BTreeMap<String, String>>,
132 #[serde(skip_serializing_if = "Option::is_none", default)]
134 pub body: Option<String>,
135 #[serde(skip_serializing_if = "Option::is_none", default)]
139 pub encoding: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none", default)]
142 pub error: Option<String>,
143}
144
145pub enum ReplyOutcome {
147 Success {
149 status: u16,
151 headers: BTreeMap<String, String>,
153 body: String,
155 encoding: Option<String>,
157 },
158 Error(String),
160}
161
162impl BrowserReply {
163 pub fn outcome(self) -> ReplyOutcome {
168 match self.error {
169 Some(error) => ReplyOutcome::Error(error),
170 None => ReplyOutcome::Success {
171 status: self.status.unwrap_or(0),
172 headers: self.headers.unwrap_or_default(),
173 body: self.body.unwrap_or_default(),
174 encoding: self.encoding,
175 },
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct BrowserFrame {
189 pub id: u64,
191 #[serde(skip_serializing_if = "Option::is_none", default)]
193 pub status: Option<u16>,
194 #[serde(skip_serializing_if = "Option::is_none", default)]
196 pub headers: Option<BTreeMap<String, String>>,
197 #[serde(skip_serializing_if = "Option::is_none", default)]
199 pub body: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none", default)]
202 pub encoding: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub error: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
209 pub stream: Option<bool>,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
212 pub chunk: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none", default)]
215 pub seq: Option<u64>,
216 #[serde(skip_serializing_if = "Option::is_none", default)]
218 pub done: Option<bool>,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
224pub enum StreamItem {
225 Head {
227 status: u16,
229 headers: BTreeMap<String, String>,
231 },
232 Chunk {
234 seq: u64,
236 data: String,
238 },
239 End,
241 Error(String),
243}
244
245impl BrowserFrame {
246 #[must_use]
249 pub fn into_reply(self) -> BrowserReply {
250 BrowserReply {
251 id: self.id,
252 status: self.status,
253 headers: self.headers,
254 body: self.body,
255 encoding: self.encoding,
256 error: self.error,
257 }
258 }
259
260 #[must_use]
264 pub fn stream_item(self) -> StreamItem {
265 if let Some(error) = self.error {
266 StreamItem::Error(error)
267 } else if self.done == Some(true) {
268 StreamItem::End
269 } else if let Some(data) = self.chunk {
270 StreamItem::Chunk {
271 seq: self.seq.unwrap_or(0),
272 data,
273 }
274 } else {
275 StreamItem::Head {
276 status: self.status.unwrap_or(0),
277 headers: self.headers.unwrap_or_default(),
278 }
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(untagged)]
290pub enum StreamLine {
291 Head {
293 status: u16,
295 headers: BTreeMap<String, String>,
297 },
298 Chunk {
300 seq: u64,
302 chunk: String,
304 },
305 Done {
307 done: bool,
309 },
310 Error {
312 error: String,
314 },
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
320pub struct ResponseEnvelope {
321 pub id: u64,
323 pub status: u16,
325 pub headers: BTreeMap<String, String>,
327 pub body: String,
330 #[serde(skip_serializing_if = "Option::is_none", default)]
333 pub encoding: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338pub struct TabInfo {
339 pub id: u64,
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub origin: Option<String>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
348pub struct StatusResponse {
349 pub connected: bool,
351 #[serde(skip_serializing_if = "Option::is_none")]
355 pub browser_origin: Option<String>,
356 pub tabs: Vec<TabInfo>,
358 pub pending: usize,
360}
361
362#[cfg(test)]
363#[allow(clippy::unwrap_used, clippy::expect_used)]
364mod tests {
365 use super::*;
366
367 #[test]
368 fn control_request_defaults_method_and_body() {
369 let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
370 assert_eq!(req.method, "GET");
371 assert!(req.body.is_none());
372 assert!(req.headers.is_empty());
373 assert!(req.allow_origin.is_none());
375 }
376
377 #[test]
378 fn control_request_omits_allow_origin_when_absent() {
379 let req = ControlRequest {
380 url: "/x".to_string(),
381 method: "GET".to_string(),
382 headers: BTreeMap::new(),
383 body: None,
384 stream: false,
385 target: None,
386 allow_origin: None,
387 credentials: None,
388 };
389 let json = serde_json::to_string(&req).unwrap();
392 assert!(!json.contains("allow_origin"));
393
394 let with = ControlRequest {
396 allow_origin: Some("https://ok.test".to_string()),
397 ..req
398 };
399 let json = serde_json::to_string(&with).unwrap();
400 let back: ControlRequest = serde_json::from_str(&json).unwrap();
401 assert_eq!(back.allow_origin.as_deref(), Some("https://ok.test"));
402 }
403
404 #[test]
405 fn command_round_trips_and_is_newline_free() {
406 let cmd = Command {
407 id: 7,
408 url: "/loki/api/v1/labels".to_string(),
409 method: "GET".to_string(),
410 headers: BTreeMap::new(),
411 body: None,
412 stream: false,
413 credentials: None,
414 };
415 let json = serde_json::to_string(&cmd).unwrap();
416 assert!(!json.contains('\n'));
417 assert!(!json.contains("stream"));
419 let back: Command = serde_json::from_str(&json).unwrap();
420 assert_eq!(cmd, back);
421 }
422
423 #[test]
424 fn streaming_command_serialises_stream_flag() {
425 let cmd = Command {
426 id: 1,
427 url: "/sse".to_string(),
428 method: "GET".to_string(),
429 headers: BTreeMap::new(),
430 body: None,
431 stream: true,
432 credentials: None,
433 };
434 let json = serde_json::to_string(&cmd).unwrap();
435 assert!(json.contains("\"stream\":true"));
436 }
437
438 #[test]
439 fn cancel_command_serialises_with_cancel_true() {
440 let json = serde_json::to_string(&CancelCommand::new(9)).unwrap();
441 assert_eq!(json, r#"{"id":9,"cancel":true}"#);
442 }
443
444 #[test]
445 fn frame_classifies_stream_head_chunk_and_end() {
446 let head: BrowserFrame =
447 serde_json::from_str(r#"{"id":1,"status":200,"headers":{"a":"b"},"stream":true}"#)
448 .unwrap();
449 assert!(matches!(
450 head.stream_item(),
451 StreamItem::Head { status: 200, headers } if headers.get("a").map(String::as_str) == Some("b")
452 ));
453
454 let chunk: BrowserFrame =
455 serde_json::from_str(r#"{"id":1,"seq":3,"chunk":"aGk="}"#).unwrap();
456 assert!(matches!(
457 chunk.stream_item(),
458 StreamItem::Chunk { seq: 3, data } if data == "aGk="
459 ));
460
461 let end: BrowserFrame = serde_json::from_str(r#"{"id":1,"done":true}"#).unwrap();
462 assert_eq!(end.stream_item(), StreamItem::End);
463
464 let err: BrowserFrame = serde_json::from_str(r#"{"id":1,"error":"boom"}"#).unwrap();
465 assert_eq!(err.stream_item(), StreamItem::Error("boom".into()));
466 }
467
468 #[test]
469 fn frame_into_reply_preserves_buffered_fields() {
470 let frame: BrowserFrame = serde_json::from_str(
471 r#"{"id":2,"status":200,"headers":{},"body":"hi","encoding":"base64"}"#,
472 )
473 .unwrap();
474 let reply = frame.into_reply();
475 assert_eq!(reply.id, 2);
476 assert!(matches!(
477 reply.outcome(),
478 ReplyOutcome::Success { body, encoding, .. }
479 if body == "hi" && encoding.as_deref() == Some("base64")
480 ));
481 }
482
483 #[test]
484 fn stream_lines_round_trip_untagged() {
485 for (line, json) in [
486 (
487 StreamLine::Head {
488 status: 200,
489 headers: BTreeMap::new(),
490 },
491 r#"{"status":200,"headers":{}}"#,
492 ),
493 (
494 StreamLine::Chunk {
495 seq: 0,
496 chunk: "aGk=".into(),
497 },
498 r#"{"seq":0,"chunk":"aGk="}"#,
499 ),
500 (StreamLine::Done { done: true }, r#"{"done":true}"#),
501 (
502 StreamLine::Error {
503 error: "boom".into(),
504 },
505 r#"{"error":"boom"}"#,
506 ),
507 ] {
508 let serialised = serde_json::to_string(&line).unwrap();
509 assert_eq!(serialised, json);
510 assert!(!serialised.contains('\n'));
511 let back: StreamLine = serde_json::from_str(json).unwrap();
512 assert_eq!(back, line);
513 }
514 }
515
516 #[test]
517 fn control_request_defaults_credentials_to_none() {
518 let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
519 assert!(req.credentials.is_none());
520 }
521
522 #[test]
523 fn control_request_omits_credentials_when_absent() {
524 let req = ControlRequest {
525 url: "/x".to_string(),
526 method: "GET".to_string(),
527 headers: BTreeMap::new(),
528 body: None,
529 stream: false,
530 target: None,
531 allow_origin: None,
532 credentials: None,
533 };
534 let json = serde_json::to_string(&req).unwrap();
535 assert!(!json.contains("credentials"));
536 }
537
538 #[test]
539 fn command_serializes_credentials_when_present() {
540 let cmd = Command {
541 id: 1,
542 url: "/x".to_string(),
543 method: "GET".to_string(),
544 headers: BTreeMap::new(),
545 body: None,
546 stream: false,
547 credentials: Some("omit".to_string()),
548 };
549 let json = serde_json::to_string(&cmd).unwrap();
550 assert!(json.contains(r#""credentials":"omit""#));
551 let back: Command = serde_json::from_str(&json).unwrap();
552 assert_eq!(cmd, back);
553 }
554
555 #[test]
556 fn success_reply_classifies_as_success() {
557 let reply: BrowserReply =
558 serde_json::from_str(r#"{"id":7,"status":200,"headers":{"a":"b"},"body":"hi"}"#)
559 .unwrap();
560 assert_eq!(reply.id, 7);
561 assert!(
564 matches!(reply.outcome(),
565 ReplyOutcome::Success { status, headers, body, encoding }
566 if status == 200
567 && headers.get("a").map(String::as_str) == Some("b")
568 && body == "hi"
569 && encoding.is_none()),
570 "success reply must classify as Success with the expected fields"
571 );
572 }
573
574 #[test]
575 fn base64_reply_carries_encoding_through_outcome() {
576 let reply: BrowserReply = serde_json::from_str(
577 r#"{"id":7,"status":200,"headers":{},"body":"iVBOR=","encoding":"base64"}"#,
578 )
579 .unwrap();
580 assert!(
583 matches!(reply.outcome(),
584 ReplyOutcome::Success { body, encoding, .. }
585 if body == "iVBOR=" && encoding.as_deref() == Some("base64")),
586 "base64 reply must classify as Success with its encoding preserved"
587 );
588 }
589
590 #[test]
591 fn text_reply_omits_encoding_on_serialise() {
592 let reply = BrowserReply {
593 id: 1,
594 status: Some(200),
595 headers: None,
596 body: Some("hi".into()),
597 encoding: None,
598 error: None,
599 };
600 let json = serde_json::to_string(&reply).unwrap();
601 assert!(!json.contains("encoding"));
602 }
603
604 #[test]
605 fn envelope_omits_encoding_when_text() {
606 let env = ResponseEnvelope {
607 id: 1,
608 status: 200,
609 headers: BTreeMap::new(),
610 body: "hi".into(),
611 encoding: None,
612 };
613 let json = serde_json::to_string(&env).unwrap();
614 assert!(!json.contains("encoding"));
615 }
616
617 #[test]
618 fn error_reply_classifies_as_error() {
619 let reply: BrowserReply =
620 serde_json::from_str(r#"{"id":7,"error":"Failed to fetch"}"#).unwrap();
621 match reply.outcome() {
622 ReplyOutcome::Error(msg) => assert_eq!(msg, "Failed to fetch"),
623 ReplyOutcome::Success { .. } => panic!("expected error"),
624 }
625 }
626
627 #[test]
628 fn status_response_omits_origin_when_absent() {
629 let s = StatusResponse {
630 connected: false,
631 browser_origin: None,
632 tabs: Vec::new(),
633 pending: 0,
634 };
635 let json = serde_json::to_string(&s).unwrap();
636 assert!(!json.contains("browser_origin"));
637 }
638}