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 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub encoding: Option<String>,
63}
64
65fn default_method() -> String {
66 "GET".to_string()
67}
68
69#[allow(clippy::trivially_copy_pass_by_ref)]
73fn is_false(b: &bool) -> bool {
74 !*b
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct Command {
82 pub id: u64,
84 pub url: String,
86 pub method: String,
88 pub headers: BTreeMap<String, String>,
90 pub body: Option<String>,
92 #[serde(default, skip_serializing_if = "is_false")]
95 pub stream: bool,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub credentials: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub encoding: Option<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct CancelCommand {
114 pub id: u64,
116 pub cancel: bool,
118}
119
120impl CancelCommand {
121 #[must_use]
123 pub fn new(id: u64) -> Self {
124 Self { id, cancel: true }
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct BrowserReply {
136 pub id: u64,
138 #[serde(skip_serializing_if = "Option::is_none", default)]
140 pub status: Option<u16>,
141 #[serde(skip_serializing_if = "Option::is_none", default)]
143 pub headers: Option<BTreeMap<String, String>>,
144 #[serde(skip_serializing_if = "Option::is_none", default)]
146 pub body: Option<String>,
147 #[serde(skip_serializing_if = "Option::is_none", default)]
151 pub encoding: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub error: Option<String>,
155}
156
157pub enum ReplyOutcome {
159 Success {
161 status: u16,
163 headers: BTreeMap<String, String>,
165 body: String,
167 encoding: Option<String>,
169 },
170 Error(String),
172}
173
174impl BrowserReply {
175 pub fn outcome(self) -> ReplyOutcome {
180 match self.error {
181 Some(error) => ReplyOutcome::Error(error),
182 None => ReplyOutcome::Success {
183 status: self.status.unwrap_or(0),
184 headers: self.headers.unwrap_or_default(),
185 body: self.body.unwrap_or_default(),
186 encoding: self.encoding,
187 },
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
200pub struct BrowserFrame {
201 pub id: u64,
203 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub status: Option<u16>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
208 pub headers: Option<BTreeMap<String, String>>,
209 #[serde(skip_serializing_if = "Option::is_none", default)]
211 pub body: Option<String>,
212 #[serde(skip_serializing_if = "Option::is_none", default)]
214 pub encoding: Option<String>,
215 #[serde(skip_serializing_if = "Option::is_none", default)]
217 pub error: Option<String>,
218 #[serde(skip_serializing_if = "Option::is_none", default)]
221 pub stream: Option<bool>,
222 #[serde(skip_serializing_if = "Option::is_none", default)]
224 pub chunk: Option<String>,
225 #[serde(skip_serializing_if = "Option::is_none", default)]
227 pub seq: Option<u64>,
228 #[serde(skip_serializing_if = "Option::is_none", default)]
230 pub done: Option<bool>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum StreamItem {
237 Head {
239 status: u16,
241 headers: BTreeMap<String, String>,
243 },
244 Chunk {
246 seq: u64,
248 data: String,
250 },
251 End,
253 Error(String),
255}
256
257impl BrowserFrame {
258 #[must_use]
261 pub fn into_reply(self) -> BrowserReply {
262 BrowserReply {
263 id: self.id,
264 status: self.status,
265 headers: self.headers,
266 body: self.body,
267 encoding: self.encoding,
268 error: self.error,
269 }
270 }
271
272 #[must_use]
276 pub fn stream_item(self) -> StreamItem {
277 if let Some(error) = self.error {
278 StreamItem::Error(error)
279 } else if self.done == Some(true) {
280 StreamItem::End
281 } else if let Some(data) = self.chunk {
282 StreamItem::Chunk {
283 seq: self.seq.unwrap_or(0),
284 data,
285 }
286 } else {
287 StreamItem::Head {
288 status: self.status.unwrap_or(0),
289 headers: self.headers.unwrap_or_default(),
290 }
291 }
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301#[serde(untagged)]
302pub enum StreamLine {
303 Head {
305 status: u16,
307 headers: BTreeMap<String, String>,
309 },
310 Chunk {
312 seq: u64,
314 chunk: String,
316 },
317 Done {
319 done: bool,
321 },
322 Error {
324 error: String,
326 },
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332pub struct ResponseEnvelope {
333 pub id: u64,
335 pub status: u16,
337 pub headers: BTreeMap<String, String>,
339 pub body: String,
342 #[serde(skip_serializing_if = "Option::is_none", default)]
345 pub encoding: Option<String>,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
350pub struct TabInfo {
351 pub id: u64,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub origin: Option<String>,
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
360pub struct StatusResponse {
361 pub connected: bool,
363 #[serde(skip_serializing_if = "Option::is_none")]
367 pub browser_origin: Option<String>,
368 pub tabs: Vec<TabInfo>,
370 pub pending: usize,
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used, clippy::expect_used)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn control_request_defaults_method_and_body() {
381 let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
382 assert_eq!(req.method, "GET");
383 assert!(req.body.is_none());
384 assert!(req.headers.is_empty());
385 assert!(req.allow_origin.is_none());
387 }
388
389 #[test]
390 fn control_request_omits_allow_origin_when_absent() {
391 let req = ControlRequest {
392 url: "/x".to_string(),
393 method: "GET".to_string(),
394 headers: BTreeMap::new(),
395 body: None,
396 stream: false,
397 target: None,
398 allow_origin: None,
399 credentials: None,
400 encoding: None,
401 };
402 let json = serde_json::to_string(&req).unwrap();
405 assert!(!json.contains("allow_origin"));
406
407 let with = ControlRequest {
409 allow_origin: Some("https://ok.test".to_string()),
410 ..req
411 };
412 let json = serde_json::to_string(&with).unwrap();
413 let back: ControlRequest = serde_json::from_str(&json).unwrap();
414 assert_eq!(back.allow_origin.as_deref(), Some("https://ok.test"));
415 }
416
417 #[test]
418 fn command_round_trips_and_is_newline_free() {
419 let cmd = Command {
420 id: 7,
421 url: "/loki/api/v1/labels".to_string(),
422 method: "GET".to_string(),
423 headers: BTreeMap::new(),
424 body: None,
425 stream: false,
426 credentials: None,
427 encoding: None,
428 };
429 let json = serde_json::to_string(&cmd).unwrap();
430 assert!(!json.contains('\n'));
431 assert!(!json.contains("stream"));
433 let back: Command = serde_json::from_str(&json).unwrap();
434 assert_eq!(cmd, back);
435 }
436
437 #[test]
438 fn streaming_command_serialises_stream_flag() {
439 let cmd = Command {
440 id: 1,
441 url: "/sse".to_string(),
442 method: "GET".to_string(),
443 headers: BTreeMap::new(),
444 body: None,
445 stream: true,
446 credentials: None,
447 encoding: None,
448 };
449 let json = serde_json::to_string(&cmd).unwrap();
450 assert!(json.contains("\"stream\":true"));
451 }
452
453 #[test]
454 fn cancel_command_serialises_with_cancel_true() {
455 let json = serde_json::to_string(&CancelCommand::new(9)).unwrap();
456 assert_eq!(json, r#"{"id":9,"cancel":true}"#);
457 }
458
459 #[test]
460 fn frame_classifies_stream_head_chunk_and_end() {
461 let head: BrowserFrame =
462 serde_json::from_str(r#"{"id":1,"status":200,"headers":{"a":"b"},"stream":true}"#)
463 .unwrap();
464 assert!(matches!(
465 head.stream_item(),
466 StreamItem::Head { status: 200, headers } if headers.get("a").map(String::as_str) == Some("b")
467 ));
468
469 let chunk: BrowserFrame =
470 serde_json::from_str(r#"{"id":1,"seq":3,"chunk":"aGk="}"#).unwrap();
471 assert!(matches!(
472 chunk.stream_item(),
473 StreamItem::Chunk { seq: 3, data } if data == "aGk="
474 ));
475
476 let end: BrowserFrame = serde_json::from_str(r#"{"id":1,"done":true}"#).unwrap();
477 assert_eq!(end.stream_item(), StreamItem::End);
478
479 let err: BrowserFrame = serde_json::from_str(r#"{"id":1,"error":"boom"}"#).unwrap();
480 assert_eq!(err.stream_item(), StreamItem::Error("boom".into()));
481 }
482
483 #[test]
484 fn frame_into_reply_preserves_buffered_fields() {
485 let frame: BrowserFrame = serde_json::from_str(
486 r#"{"id":2,"status":200,"headers":{},"body":"hi","encoding":"base64"}"#,
487 )
488 .unwrap();
489 let reply = frame.into_reply();
490 assert_eq!(reply.id, 2);
491 assert!(matches!(
492 reply.outcome(),
493 ReplyOutcome::Success { body, encoding, .. }
494 if body == "hi" && encoding.as_deref() == Some("base64")
495 ));
496 }
497
498 #[test]
499 fn stream_lines_round_trip_untagged() {
500 for (line, json) in [
501 (
502 StreamLine::Head {
503 status: 200,
504 headers: BTreeMap::new(),
505 },
506 r#"{"status":200,"headers":{}}"#,
507 ),
508 (
509 StreamLine::Chunk {
510 seq: 0,
511 chunk: "aGk=".into(),
512 },
513 r#"{"seq":0,"chunk":"aGk="}"#,
514 ),
515 (StreamLine::Done { done: true }, r#"{"done":true}"#),
516 (
517 StreamLine::Error {
518 error: "boom".into(),
519 },
520 r#"{"error":"boom"}"#,
521 ),
522 ] {
523 let serialised = serde_json::to_string(&line).unwrap();
524 assert_eq!(serialised, json);
525 assert!(!serialised.contains('\n'));
526 let back: StreamLine = serde_json::from_str(json).unwrap();
527 assert_eq!(back, line);
528 }
529 }
530
531 #[test]
532 fn control_request_defaults_credentials_to_none() {
533 let req: ControlRequest = serde_json::from_str(r#"{"url":"/x"}"#).unwrap();
534 assert!(req.credentials.is_none());
535 }
536
537 #[test]
538 fn control_request_omits_credentials_when_absent() {
539 let req = ControlRequest {
540 url: "/x".to_string(),
541 method: "GET".to_string(),
542 headers: BTreeMap::new(),
543 body: None,
544 stream: false,
545 target: None,
546 allow_origin: None,
547 credentials: None,
548 encoding: None,
549 };
550 let json = serde_json::to_string(&req).unwrap();
551 assert!(!json.contains("credentials"));
552 }
553
554 #[test]
555 fn command_serializes_credentials_when_present() {
556 let cmd = Command {
557 id: 1,
558 url: "/x".to_string(),
559 method: "GET".to_string(),
560 headers: BTreeMap::new(),
561 body: None,
562 stream: false,
563 credentials: Some("omit".to_string()),
564 encoding: None,
565 };
566 let json = serde_json::to_string(&cmd).unwrap();
567 assert!(json.contains(r#""credentials":"omit""#));
568 let back: Command = serde_json::from_str(&json).unwrap();
569 assert_eq!(cmd, back);
570 }
571
572 #[test]
573 fn request_encoding_omitted_when_absent_and_round_trips_when_present() {
574 let cmd = Command {
577 id: 3,
578 url: "/upload".to_string(),
579 method: "POST".to_string(),
580 headers: BTreeMap::new(),
581 body: Some("plain".to_string()),
582 stream: false,
583 credentials: None,
584 encoding: None,
585 };
586 assert!(!serde_json::to_string(&cmd).unwrap().contains("encoding"));
587
588 let req = ControlRequest {
590 url: "/upload".to_string(),
591 method: "POST".to_string(),
592 headers: BTreeMap::new(),
593 body: Some("aGk=".to_string()),
594 stream: false,
595 target: None,
596 allow_origin: None,
597 credentials: None,
598 encoding: Some("base64".to_string()),
599 };
600 let back: ControlRequest =
601 serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
602 assert_eq!(back.encoding.as_deref(), Some("base64"));
603
604 let cmd = Command {
605 encoding: Some("base64".to_string()),
606 ..cmd
607 };
608 let json = serde_json::to_string(&cmd).unwrap();
609 assert!(json.contains(r#""encoding":"base64""#));
610 let back: Command = serde_json::from_str(&json).unwrap();
611 assert_eq!(cmd, back);
612 }
613
614 #[test]
615 fn success_reply_classifies_as_success() {
616 let reply: BrowserReply =
617 serde_json::from_str(r#"{"id":7,"status":200,"headers":{"a":"b"},"body":"hi"}"#)
618 .unwrap();
619 assert_eq!(reply.id, 7);
620 assert!(
623 matches!(reply.outcome(),
624 ReplyOutcome::Success { status, headers, body, encoding }
625 if status == 200
626 && headers.get("a").map(String::as_str) == Some("b")
627 && body == "hi"
628 && encoding.is_none()),
629 "success reply must classify as Success with the expected fields"
630 );
631 }
632
633 #[test]
634 fn base64_reply_carries_encoding_through_outcome() {
635 let reply: BrowserReply = serde_json::from_str(
636 r#"{"id":7,"status":200,"headers":{},"body":"iVBOR=","encoding":"base64"}"#,
637 )
638 .unwrap();
639 assert!(
642 matches!(reply.outcome(),
643 ReplyOutcome::Success { body, encoding, .. }
644 if body == "iVBOR=" && encoding.as_deref() == Some("base64")),
645 "base64 reply must classify as Success with its encoding preserved"
646 );
647 }
648
649 #[test]
650 fn text_reply_omits_encoding_on_serialise() {
651 let reply = BrowserReply {
652 id: 1,
653 status: Some(200),
654 headers: None,
655 body: Some("hi".into()),
656 encoding: None,
657 error: None,
658 };
659 let json = serde_json::to_string(&reply).unwrap();
660 assert!(!json.contains("encoding"));
661 }
662
663 #[test]
664 fn envelope_omits_encoding_when_text() {
665 let env = ResponseEnvelope {
666 id: 1,
667 status: 200,
668 headers: BTreeMap::new(),
669 body: "hi".into(),
670 encoding: None,
671 };
672 let json = serde_json::to_string(&env).unwrap();
673 assert!(!json.contains("encoding"));
674 }
675
676 #[test]
677 fn error_reply_classifies_as_error() {
678 let reply: BrowserReply =
679 serde_json::from_str(r#"{"id":7,"error":"Failed to fetch"}"#).unwrap();
680 match reply.outcome() {
681 ReplyOutcome::Error(msg) => assert_eq!(msg, "Failed to fetch"),
682 ReplyOutcome::Success { .. } => panic!("expected error"),
683 }
684 }
685
686 #[test]
687 fn status_response_omits_origin_when_absent() {
688 let s = StatusResponse {
689 connected: false,
690 browser_origin: None,
691 tabs: Vec::new(),
692 pending: 0,
693 };
694 let json = serde_json::to_string(&s).unwrap();
695 assert!(!json.contains("browser_origin"));
696 }
697}