Skip to main content

taba_cli/tui/api/
mod.rs

1//! The routes the screens call (doc 34, section 3), typed, over `Client::call`: each `ApiCall` becomes a
2//! `taba_client::Call` and each reply is read into a small struct. Nothing is added to `taba-client`.
3
4use std::time::Duration;
5use taba_client::json::{self, Fields};
6
7use taba_client::Call;
8
9/// How long a flush or a stop may take before the call gives up.
10const LONG_ACTION: Duration = Duration::from_secs(180);
11
12/// The columns of the topic list that come from the statistics (the rest comes from `GET /topics`).
13const STATS_FIELDS: &str = "name,depth,publishRate,consumeRate,oldestAge,worstCondition,conditions";
14
15/// What a call is for; it comes back with the reply so `update` knows what to do with it.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ApiCall {
18    Me,
19    Nodes,
20    ClusterStatus,
21    NodeMetrics(String),
22    NodeBackup(String),
23    DrainStatus(String),
24    DrainStart(String),
25    DrainCancel(String),
26    FlushNode(String),
27    FlushCluster,
28    StopCluster,
29    /// One page of `GET /tokens`, from a cursor.
30    Tokens(Option<String>),
31    /// `POST /tokens` with the JSON body of the form.
32    TokenCreate(String),
33    /// `PATCH /tokens/{id}` with the changed fields.
34    TokenUpdate(String, String),
35    TokenRevoke(String),
36    /// One page of `GET /topics`, from a cursor.
37    Topics(Option<String>),
38    /// One page of `GET /stats/topics` (the numbers of the list), from a cursor.
39    TopicStats(Option<String>),
40    /// `POST /topics` with the JSON body of the form.
41    TopicCreate(String),
42    /// `PATCH /topics/{name}` with the changed fields.
43    TopicUpdate(String, String),
44    TopicDelete(String),
45    /// `GET /stats/topics/{name}`: one reading of a queue for its charts.
46    TopicDetail(String),
47    /// `POST /topics/{name}/messages`.
48    Publish(PublishSpec),
49    /// `GET /topics/{name}/messages`: reads up to [`RECEIVE_MAX`] messages and holds them for the given seconds (the form offers [`RECEIVE_HOLD_SECS`]).
50    Receive(String, u32, u32),
51    /// `DELETE /topics/{name}/messages/{id}` with the receipt: topic, id, receipt.
52    Ack(String, String, String),
53    /// `PUT /topics/{name}/messages/{id}/visibility` with `0`: gives the message back to the queue now.
54    /// Changes the time a held message stays invisible; `0` gives it back to the queue now.
55    Visibility(String, String, String, u32),
56    /// `DELETE /topics/{name}/messages`: empties the queue on every node.
57    Prune(String),
58}
59
60/// How many messages one reading takes.
61pub const RECEIVE_MAX: u32 = 10;
62/// How long the messages of a reading stay invisible to the others, in seconds.
63pub const RECEIVE_HOLD_SECS: u32 = 30;
64
65/// What the publish form asks to send.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct PublishSpec {
68    pub topic: String,
69    pub body: Vec<u8>,
70    pub content_type: String,
71    pub group_id: Option<String>,
72    pub deduplication_id: Option<String>,
73}
74
75impl ApiCall {
76    pub fn to_call(&self) -> Call {
77        let mut call = self.route();
78        if matches!(
79            self,
80            ApiCall::FlushNode(_) | ApiCall::FlushCluster | ApiCall::StopCluster
81        ) {
82            // A flush or a stop waits for the nodes to finish, which can take longer than a normal call.
83            call.timeout = Some(LONG_ACTION);
84        }
85        call
86    }
87
88    fn route(&self) -> Call {
89        let node = |id: &str, tail: &str| format!("/nodes/{}{tail}", encode_segment(id));
90        match self {
91            ApiCall::Me => Call::new("GET", "/auth/me"),
92            ApiCall::Nodes => Call::new("GET", "/nodes"),
93            ApiCall::ClusterStatus => Call::new("GET", "/cluster/status"),
94            ApiCall::NodeMetrics(id) => {
95                let mut call = Call::new("GET", node(id, "/metrics"));
96                call.headers
97                    .push(("Accept".to_owned(), "text/plain".to_owned()));
98                call
99            }
100            ApiCall::NodeBackup(id) => Call::new("GET", node(id, "/backup")),
101            ApiCall::DrainStatus(id) => Call::new("GET", node(id, "/drain")),
102            ApiCall::DrainStart(id) => Call::new("POST", node(id, "/drain")),
103            ApiCall::DrainCancel(id) => Call::new("DELETE", node(id, "/drain")),
104            ApiCall::FlushNode(id) => Call::new("POST", node(id, "/backup/flush")),
105            ApiCall::FlushCluster => Call::new("POST", "/cluster/backup/flush"),
106            ApiCall::StopCluster => Call::new("POST", "/cluster/stop"),
107            ApiCall::Tokens(cursor) => {
108                let mut call = Call::new("GET", "/tokens").query("limit", 200);
109                if let Some(cursor) = cursor {
110                    call = call.query("cursor", cursor);
111                }
112                call
113            }
114            ApiCall::TokenCreate(body) => Call::new("POST", "/tokens").json_body(body.clone()),
115            ApiCall::TokenUpdate(id, body) => {
116                Call::new("PATCH", format!("/tokens/{}", encode_segment(id)))
117                    .json_body(body.clone())
118            }
119            ApiCall::TokenRevoke(id) => {
120                Call::new("DELETE", format!("/tokens/{}", encode_segment(id)))
121            }
122            ApiCall::Topics(cursor) => {
123                let mut call = Call::new("GET", "/topics").query("limit", 200);
124                if let Some(cursor) = cursor {
125                    call = call.query("cursor", cursor);
126                }
127                call
128            }
129            ApiCall::TopicStats(cursor) => {
130                let mut call = Call::new("GET", "/stats/topics")
131                    .query("limit", 200)
132                    .query("fields", STATS_FIELDS);
133                if let Some(cursor) = cursor {
134                    call = call.query("cursor", cursor);
135                }
136                call
137            }
138            ApiCall::TopicCreate(body) => Call::new("POST", "/topics").json_body(body.clone()),
139            ApiCall::TopicUpdate(name, body) => {
140                Call::new("PATCH", format!("/topics/{}", encode_segment(name)))
141                    .json_body(body.clone())
142            }
143            ApiCall::TopicDelete(name) => {
144                Call::new("DELETE", format!("/topics/{}", encode_segment(name)))
145            }
146            ApiCall::TopicDetail(name) => {
147                Call::new("GET", format!("/stats/topics/{}", encode_segment(name)))
148            }
149            ApiCall::Publish(spec) => {
150                let mut call = Call::new(
151                    "POST",
152                    format!("/topics/{}/messages", encode_segment(&spec.topic)),
153                );
154                call.body = spec.body.clone();
155                call.content_type = Some(spec.content_type.clone());
156                if let Some(group) = &spec.group_id {
157                    call = call.query("groupId", group);
158                }
159                if let Some(dedup) = &spec.deduplication_id {
160                    call = call.query("deduplicationId", dedup);
161                }
162                call
163            }
164            ApiCall::Receive(name, hold, max) => {
165                Call::new("GET", format!("/topics/{}/messages", encode_segment(name)))
166                    .query("maxMessages", max)
167                    .query("visibilityTimeout", hold)
168            }
169            ApiCall::Ack(topic, id, receipt) => Call::new(
170                "DELETE",
171                format!(
172                    "/topics/{}/messages/{}",
173                    encode_segment(topic),
174                    encode_segment(id)
175                ),
176            )
177            .query("receiptToken", receipt),
178            ApiCall::Visibility(topic, id, receipt, secs) => Call::new(
179                "PUT",
180                format!(
181                    "/topics/{}/messages/{}/visibility",
182                    encode_segment(topic),
183                    encode_segment(id)
184                ),
185            )
186            .query("receiptToken", receipt)
187            .json_body(format!("{{\"visibilityTimeout\":{secs}}}")),
188            ApiCall::Prune(name) => Call::new(
189                "DELETE",
190                format!("/topics/{}/messages", encode_segment(name)),
191            ),
192        }
193    }
194
195    /// Calls that change something: the screen never repeats them by itself.
196    pub fn is_action(&self) -> bool {
197        matches!(
198            self,
199            ApiCall::DrainStart(_)
200                | ApiCall::DrainCancel(_)
201                | ApiCall::FlushNode(_)
202                | ApiCall::FlushCluster
203                | ApiCall::StopCluster
204                | ApiCall::TokenCreate(_)
205                | ApiCall::TokenUpdate(..)
206                | ApiCall::TokenRevoke(_)
207                | ApiCall::TopicCreate(_)
208                | ApiCall::TopicUpdate(..)
209                | ApiCall::TopicDelete(_)
210                | ApiCall::Publish(_)
211                | ApiCall::Receive(..)
212                | ApiCall::Ack(..)
213                | ApiCall::Visibility(..)
214                | ApiCall::Prune(_)
215        )
216    }
217}
218
219/// A node id is a GUID; anything else is percent-encoded so it cannot change the path.
220fn encode_segment(id: &str) -> String {
221    id.bytes()
222        .map(|b| {
223            if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
224                (b as char).to_string()
225            } else {
226                format!("%{b:02X}")
227            }
228        })
229        .collect()
230}
231
232fn fields(text: &str) -> Result<Fields<'_>, String> {
233    Fields::parse(text).map_err(|e| e.to_string())
234}
235
236fn num(f: &Fields<'_>, name: &str) -> Option<f64> {
237    f.raw(name)?.parse().ok()
238}
239
240fn whole(f: &Fields<'_>, name: &str) -> Option<u64> {
241    num(f, name).filter(|n| *n >= 0.0).map(|n| n as u64)
242}
243
244/// A count the server gives either as a number or as a map of entries (the channels of the node): the entries are counted.
245fn count_of(f: &Fields<'_>, name: &str) -> u64 {
246    match f.raw(name) {
247        Some(raw) if raw.trim_start().starts_with('{') => {
248            json::object_fields(raw).map_or(0, |entries| entries.len() as u64)
249        }
250        Some(_) => whole(f, name).unwrap_or(0),
251        None => 0,
252    }
253}
254
255fn items<'a>(f: &Fields<'a>, name: &str) -> Vec<&'a str> {
256    f.raw(name)
257        .and_then(|raw| json::array_items(raw).ok())
258        .unwrap_or_default()
259}
260
261fn texts(f: &Fields<'_>, name: &str) -> Vec<String> {
262    items(f, name)
263        .into_iter()
264        .filter_map(|s| json::decode_string(s).ok())
265        .collect()
266}
267
268/// Who the token is (`GET /auth/me`).
269#[derive(Debug, Clone, PartialEq, Eq, Default)]
270pub struct Me {
271    pub auth_enabled: bool,
272    pub root: bool,
273    pub name: Option<String>,
274    pub permissions: Vec<String>,
275}
276
277impl Me {
278    /// Root, or no authentication at all: the cluster-wide reads are allowed.
279    pub fn is_root(&self) -> bool {
280        self.root || !self.auth_enabled
281    }
282
283    /// Root, no authentication, or a token with the `admin` permission: the cluster and the tokens are open to it.
284    pub fn is_admin(&self) -> bool {
285        self.is_root() || self.permissions.iter().any(|p| p == "admin")
286    }
287
288    /// `manage` includes `create`, and `admin` includes `manage` and `create` (the same rule as the server).
289    pub fn can(&self, permission: &str) -> bool {
290        let has = |name: &str| self.permissions.iter().any(|p| p == name);
291        self.is_root()
292            || has(permission)
293            || (matches!(permission, "create" | "publish" | "consume")
294                && (has("manage") || has("admin")))
295            || (permission == "manage" && has("admin"))
296    }
297}
298
299pub fn parse_me(text: &str) -> Result<Me, String> {
300    let f = fields(text)?;
301    Ok(Me {
302        auth_enabled: f.boolean("authEnabled").unwrap_or(true),
303        root: f.boolean("root").unwrap_or(false),
304        name: f.string("name"),
305        permissions: texts(&f, "permissions"),
306    })
307}
308
309/// A node that did not answer in a consolidated read.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct Missing {
312    pub node_id: String,
313    pub reason: String,
314}
315
316fn parse_missing(f: &Fields<'_>) -> Vec<Missing> {
317    items(f, "missingNodes")
318        .into_iter()
319        .filter_map(|raw| {
320            let m = Fields::parse(raw).ok()?;
321            Some(Missing {
322                node_id: m.string("nodeId")?,
323                reason: m.string("reason").unwrap_or_default(),
324            })
325        })
326        .collect()
327}
328
329/// An item of `GET /nodes`.
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct NodeInfo {
332    pub node_id: String,
333    pub is_self: bool,
334    pub reachable: bool,
335    pub state: String,
336    pub drain_state: String,
337    pub version: String,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Default)]
341pub struct Nodes {
342    pub items: Vec<NodeInfo>,
343    pub partial: bool,
344    pub missing: Vec<Missing>,
345}
346
347pub fn parse_nodes(text: &str) -> Result<Nodes, String> {
348    let f = fields(text)?;
349    let list = items(&f, "items")
350        .into_iter()
351        .filter_map(|raw| {
352            let n = Fields::parse(raw).ok()?;
353            Some(NodeInfo {
354                node_id: n.string("nodeId")?,
355                is_self: n.boolean("self").unwrap_or(false),
356                reachable: n.boolean("reachable").unwrap_or(true),
357                state: n.string("state").unwrap_or_default(),
358                drain_state: n.string("drainState").unwrap_or_default(),
359                version: n.string("version").unwrap_or_default(),
360            })
361        })
362        .collect();
363    Ok(Nodes {
364        items: list,
365        partial: f.boolean("partial").unwrap_or(false),
366        missing: parse_missing(&f),
367    })
368}
369
370/// `warning` or `critical`; the order is the severity.
371#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
372pub enum Severity {
373    #[default]
374    Ok,
375    Warning,
376    Critical,
377}
378
379impl Severity {
380    pub fn parse(text: &str) -> Severity {
381        match text {
382            "critical" => Severity::Critical,
383            "warning" => Severity::Warning,
384            _ => Severity::Ok,
385        }
386    }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
390pub struct Condition {
391    pub name: String,
392    pub severity: Severity,
393}
394
395/// One member of `GET /cluster/status`: the node, and its `NodeStatus` when it answered.
396#[derive(Debug, Clone, PartialEq, Default)]
397pub struct NodeStatus {
398    pub node_id: String,
399    pub reachable: bool,
400    pub cluster_name: Option<String>,
401    pub version: String,
402    pub state: String,
403    pub drain_state: String,
404    pub started_at: Option<String>,
405    pub memory_used: u64,
406    pub memory_limit: u64,
407    pub memory_messages: u64,
408    pub disk_messages: u64,
409    pub disk_free: u64,
410    pub quorum: bool,
411    pub channels: u64,
412    pub syncing_blocks: u64,
413    pub below_replication: u64,
414    pub below_quorum: u64,
415    pub frozen_nodes: u64,
416    pub active_requests: f64,
417    pub rate_limited: f64,
418    pub rtt_p95: Option<f64>,
419}
420
421impl NodeStatus {
422    /// Memory in use, from 0 to 1 (0 without a limit).
423    pub fn memory_ratio(&self) -> f64 {
424        if self.memory_limit == 0 {
425            0.0
426        } else {
427            (self.memory_used as f64 / self.memory_limit as f64).clamp(0.0, 1.0)
428        }
429    }
430}
431
432#[derive(Debug, Clone, PartialEq, Default)]
433pub struct ClusterStatus {
434    pub collected_at: String,
435    pub partial: bool,
436    pub missing: Vec<Missing>,
437    pub worst: String,
438    pub conditions: Vec<Condition>,
439    pub members: Vec<(String, u64)>,
440    pub members_version: u64,
441    pub quorum: bool,
442    pub syncing_blocks: u64,
443    /// Every node that answered is in drain: the cluster is stopped (a planned stop, or all drained by hand).
444    pub stopped: bool,
445    /// The S3 stop list exists: the stop was `POST /cluster/stop`.
446    pub planned_stop: bool,
447    pub stopped_at: Option<String>,
448    pub nodes: Vec<NodeStatus>,
449}
450
451fn parse_node_status(node_id: &str, reachable: bool, raw: Option<&str>) -> NodeStatus {
452    let mut node = NodeStatus {
453        node_id: node_id.to_owned(),
454        reachable,
455        ..NodeStatus::default()
456    };
457    let Some(f) = raw.and_then(|r| Fields::parse(r).ok()) else {
458        return node;
459    };
460    node.cluster_name = f.string("clusterName");
461    node.version = f.string("version").unwrap_or_default();
462    node.state = f.string("state").unwrap_or_default();
463    node.drain_state = f.string("drainState").unwrap_or_default();
464    node.started_at = f.string("startedAt");
465    if let Some(s) = f.raw("storage").and_then(|r| Fields::parse(r).ok()) {
466        node.memory_used = whole(&s, "memoryUsedBytes").unwrap_or(0);
467        node.memory_limit = whole(&s, "memoryLimitBytes").unwrap_or(0);
468        node.memory_messages = whole(&s, "memoryMessages").unwrap_or(0);
469        node.disk_messages = whole(&s, "diskMessages").unwrap_or(0);
470        node.disk_free = whole(&s, "diskFreeBytes").unwrap_or(0);
471    }
472    if let Some(s) = f.raw("signals").and_then(|r| Fields::parse(r).ok()) {
473        node.quorum = s.boolean("quorumAvailable").unwrap_or(false);
474        node.channels = count_of(&s, "channels");
475        node.syncing_blocks = whole(&s, "syncingBlocks").unwrap_or(0);
476        node.below_replication = whole(&s, "blocksBelowReplication").unwrap_or(0);
477        node.below_quorum = whole(&s, "blocksBelowQuorum").unwrap_or(0);
478        node.frozen_nodes = whole(&s, "frozenNodes").unwrap_or(0);
479    }
480    if let Some(r) = f.raw("runtime").and_then(|r| Fields::parse(r).ok()) {
481        node.active_requests = num(&r, "activeRequests").unwrap_or(0.0);
482        node.rate_limited = num(&r, "rateLimitRejected").unwrap_or(0.0);
483        node.rtt_p95 = num(&r, "channelRttP95Seconds");
484    }
485    node
486}
487
488pub fn parse_cluster_status(text: &str) -> Result<ClusterStatus, String> {
489    let f = fields(text)?;
490    let conditions = items(&f, "conditions")
491        .into_iter()
492        .filter_map(|raw| {
493            let c = Fields::parse(raw).ok()?;
494            Some(Condition {
495                name: c.string("condition")?,
496                severity: Severity::parse(&c.string("severity").unwrap_or_default()),
497            })
498        })
499        .collect();
500    let mut status = ClusterStatus {
501        collected_at: f.string("collectedAt").unwrap_or_default(),
502        partial: f.boolean("partial").unwrap_or(false),
503        missing: parse_missing(&f),
504        worst: f
505            .string("worstCondition")
506            .unwrap_or_else(|| "ok".to_owned()),
507        conditions,
508        ..ClusterStatus::default()
509    };
510    if let Some(s) = f.raw("summary").and_then(|r| Fields::parse(r).ok()) {
511        status.members_version = whole(&s, "membersVersion").unwrap_or(0);
512        status.quorum = s.boolean("quorumAvailable").unwrap_or(false);
513        status.syncing_blocks = whole(&s, "syncingBlocks").unwrap_or(0);
514        status.stopped = s.boolean("stopped").unwrap_or(false);
515        status.planned_stop = s.boolean("plannedStop").unwrap_or(false);
516        status.stopped_at = s.string("stoppedAt");
517        if let Some(m) = s.raw("members").and_then(|r| json::object_fields(r).ok()) {
518            status.members = m
519                .into_iter()
520                .map(|(state, n)| (state, n.parse().unwrap_or(0)))
521                .collect();
522        }
523    }
524    status.nodes = items(&f, "nodes")
525        .into_iter()
526        .filter_map(|raw| {
527            let n = Fields::parse(raw).ok()?;
528            let id = n.string("nodeId")?;
529            let reachable = n.boolean("reachable").unwrap_or(false);
530            Some(parse_node_status(&id, reachable, n.raw("status")))
531        })
532        .collect();
533    Ok(status)
534}
535
536/// `GET`, `POST` and `DELETE /nodes/{id}/drain`.
537#[derive(Debug, Clone, PartialEq, Eq)]
538pub struct Drain {
539    pub state: String,
540    pub pending: u64,
541}
542
543impl Drain {
544    pub fn is_active(&self) -> bool {
545        self.state == "active"
546    }
547
548    pub fn is_done(&self) -> bool {
549        self.state == "drained"
550    }
551}
552
553pub fn parse_drain(text: &str) -> Result<Drain, String> {
554    let f = fields(text)?;
555    Ok(Drain {
556        state: f.string("drainState").unwrap_or_default(),
557        pending: whole(&f, "pendingMessages").unwrap_or(0),
558    })
559}
560
561/// The S3 backup of a node (`GET /nodes/{id}/backup`).
562#[derive(Debug, Clone, PartialEq, Eq, Default)]
563pub struct Backup {
564    pub reachable: Option<bool>,
565    pub pending: Option<u64>,
566    pub last_success: Option<String>,
567    pub segments: Option<u64>,
568}
569
570pub fn parse_backup(text: &str) -> Result<Backup, String> {
571    let f = fields(text)?;
572    Ok(Backup {
573        reachable: f.boolean("s3Reachable").or_else(|| f.boolean("reachable")),
574        pending: whole(&f, "outboxPending").or_else(|| whole(&f, "pending")),
575        last_success: f
576            .string("lastSuccessAt")
577            .or_else(|| f.string("lastSuccess")),
578        segments: whole(&f, "segments").or_else(|| whole(&f, "segmentsUploaded")),
579    })
580}
581
582/// What one node reported on a backup flush.
583#[derive(Debug, Clone, PartialEq, Eq, Default)]
584pub struct FlushNode {
585    pub node_id: Option<String>,
586    pub complete: bool,
587    pub pending: bool,
588    pub timed_out: bool,
589    pub outbox_pending: u64,
590    pub memory_remaining: u64,
591    pub error: Option<String>,
592}
593
594#[derive(Debug, Clone, PartialEq, Eq, Default)]
595pub struct FlushReport {
596    pub complete: bool,
597    pub nodes: Vec<FlushNode>,
598}
599
600fn flush_node(f: &Fields<'_>) -> FlushNode {
601    FlushNode {
602        node_id: f.string("nodeId"),
603        complete: f.boolean("complete").unwrap_or(false),
604        pending: f.boolean("pending").unwrap_or(false),
605        timed_out: f.boolean("timedOut").unwrap_or(false),
606        outbox_pending: whole(f, "outboxPending").unwrap_or(0),
607        memory_remaining: whole(f, "memoryRemaining").unwrap_or(0),
608        error: f.string("error"),
609    }
610}
611
612/// The reply of a flush of one node (the report itself) or of the cluster (a `nodes` list).
613pub fn parse_flush(text: &str) -> Result<FlushReport, String> {
614    let f = fields(text)?;
615    let complete = f.boolean("complete").unwrap_or(false);
616    let nodes = if f.raw("nodes").is_some() {
617        items(&f, "nodes")
618            .into_iter()
619            .filter_map(|raw| Fields::parse(raw).ok().map(|n| flush_node(&n)))
620            .collect()
621    } else {
622        vec![flush_node(&f)]
623    };
624    Ok(FlushReport { complete, nodes })
625}
626
627#[derive(Debug, Clone, PartialEq, Eq, Default)]
628pub struct StopReport {
629    pub stopped: Vec<String>,
630    pub stop_list: bool,
631    pub warnings: Vec<String>,
632    pub nodes: Vec<(String, bool)>,
633}
634
635pub fn parse_stop(text: &str) -> Result<StopReport, String> {
636    let f = fields(text)?;
637    let nodes = items(&f, "nodes")
638        .into_iter()
639        .filter_map(|raw| {
640            let n = Fields::parse(raw).ok()?;
641            Some((n.string("nodeId")?, n.boolean("drained").unwrap_or(false)))
642        })
643        .collect();
644    Ok(StopReport {
645        stopped: texts(&f, "stopped"),
646        stop_list: f.boolean("stopList").unwrap_or(false),
647        warnings: texts(&f, "warnings"),
648        nodes,
649    })
650}
651
652/// A token of `GET /tokens` (never with the secret).
653#[derive(Debug, Clone, PartialEq, Eq, Default)]
654pub struct Token {
655    pub id: String,
656    pub name: String,
657    pub permissions: Vec<String>,
658    pub prefixes: Vec<String>,
659    pub created_at: Option<String>,
660    pub expires_at: Option<String>,
661}
662
663fn token_of(f: &Fields<'_>) -> Option<Token> {
664    Some(Token {
665        id: f.string("id")?,
666        name: f.string("name").unwrap_or_default(),
667        permissions: texts(f, "permissions"),
668        prefixes: texts(f, "topicPrefixes"),
669        created_at: f.string("createdAt"),
670        expires_at: f.string("expiresAt"),
671    })
672}
673
674/// A page of tokens and where the next one starts.
675#[derive(Debug, Clone, PartialEq, Eq, Default)]
676pub struct TokenPage {
677    pub items: Vec<Token>,
678    pub next_cursor: Option<String>,
679}
680
681pub fn parse_tokens(text: &str) -> Result<TokenPage, String> {
682    let f = fields(text)?;
683    let list = items(&f, "items")
684        .into_iter()
685        .filter_map(|raw| token_of(&Fields::parse(raw).ok()?))
686        .collect();
687    Ok(TokenPage {
688        items: list,
689        next_cursor: f.string("nextCursor"),
690    })
691}
692
693/// The reply of `POST /tokens`: the token and its secret, which exists only here.
694pub fn parse_token_created(text: &str) -> Result<(Token, String), String> {
695    let f = fields(text)?;
696    let token = token_of(&f).ok_or_else(|| "the reply has no token id".to_owned())?;
697    let secret = f
698        .string("token")
699        .ok_or_else(|| "the reply has no secret".to_owned())?;
700    Ok((token, secret))
701}
702
703/// A topic of `GET /topics` (doc 16): its definition, as the server keeps it.
704#[derive(Debug, Clone, PartialEq, Eq, Default)]
705pub struct Topic {
706    pub id: String,
707    pub name: String,
708    /// `queue` or `distributor`.
709    pub kind: String,
710    pub fifo: bool,
711    pub volatile: bool,
712    pub tags: Vec<(String, String)>,
713    pub prefixes: Vec<String>,
714    pub idle_timeout: Option<u64>,
715    /// What was set on the topic itself; `None` is the node's default.
716    pub max_message_bytes: Option<u64>,
717    pub ttl: Option<u64>,
718    pub max_deliveries: Option<u64>,
719    /// The values that apply (`effective`): the topic's own, or the node's default.
720    pub effective_max_message_bytes: Option<u64>,
721    pub effective_ttl: Option<u64>,
722    pub effective_max_deliveries: Option<u64>,
723    pub dead_letter: bool,
724    /// The queue a dead-letter queue belongs to.
725    pub source: Option<String>,
726    pub created_at: Option<String>,
727}
728
729impl Topic {
730    pub fn is_distributor(&self) -> bool {
731        self.kind == "distributor"
732    }
733}
734
735fn pairs_of(f: &Fields<'_>, name: &str) -> Vec<(String, String)> {
736    f.raw(name)
737        .and_then(|raw| json::object_fields(raw).ok())
738        .unwrap_or_default()
739        .into_iter()
740        .filter_map(|(key, value)| Some((key, json::decode_string(value).ok()?)))
741        .collect()
742}
743
744fn topic_of(f: &Fields<'_>) -> Option<Topic> {
745    let effective = f.raw("effective").and_then(|r| Fields::parse(r).ok());
746    let own = |name: &str| effective.as_ref().and_then(|e| whole(e, name));
747    Some(Topic {
748        id: f.string("id").unwrap_or_default(),
749        name: f.string("name")?,
750        kind: f.string("type").unwrap_or_else(|| "queue".to_owned()),
751        fifo: f.boolean("fifo").unwrap_or(false),
752        volatile: f.boolean("volatile").unwrap_or(false),
753        tags: pairs_of(f, "tags"),
754        prefixes: texts(f, "prefixes"),
755        idle_timeout: whole(f, "idleTimeout"),
756        max_message_bytes: whole(f, "maxMessageBytes"),
757        ttl: whole(f, "ttl"),
758        max_deliveries: whole(f, "maxDeliveries"),
759        effective_max_message_bytes: own("maxMessageBytes"),
760        effective_ttl: own("ttl"),
761        effective_max_deliveries: own("maxDeliveries"),
762        dead_letter: f.boolean("deadLetter").unwrap_or(false),
763        source: f.string("source"),
764        created_at: f.string("createdAt"),
765    })
766}
767
768/// A page of topics and where the next one starts.
769#[derive(Debug, Clone, PartialEq, Eq, Default)]
770pub struct TopicPage {
771    pub items: Vec<Topic>,
772    pub next_cursor: Option<String>,
773}
774
775pub fn parse_topics(text: &str) -> Result<TopicPage, String> {
776    let f = fields(text)?;
777    let list = items(&f, "items")
778        .into_iter()
779        .filter_map(|raw| topic_of(&Fields::parse(raw).ok()?))
780        .collect();
781    Ok(TopicPage {
782        items: list,
783        next_cursor: f.string("nextCursor"),
784    })
785}
786
787/// The reply of `POST /topics` and `PATCH /topics/{name}`: the topic as it is now.
788pub fn parse_topic(text: &str) -> Result<Topic, String> {
789    topic_of(&fields(text)?).ok_or_else(|| "the reply has no topic name".to_owned())
790}
791
792/// The numbers of one topic in the list (`GET /stats/topics`). A distributor has no queue, so no numbers.
793#[derive(Debug, Clone, PartialEq, Default)]
794pub struct TopicNumbers {
795    pub depth: Option<u64>,
796    pub publish_rate: Option<f64>,
797    pub consume_rate: Option<f64>,
798    /// Seconds; absent when the topic has no messages.
799    pub oldest_age: Option<f64>,
800    /// A condition name (`ok`, `backlog`, `stalled`...), not translated.
801    pub worst: String,
802    pub severity: Severity,
803}
804
805/// A page of statistics and where the next one starts.
806#[derive(Debug, Clone, PartialEq, Default)]
807pub struct TopicStatsPage {
808    pub items: Vec<(String, TopicNumbers)>,
809    pub next_cursor: Option<String>,
810    pub partial: bool,
811    pub missing: Vec<Missing>,
812}
813
814pub fn parse_topic_stats(text: &str) -> Result<TopicStatsPage, String> {
815    let f = fields(text)?;
816    let list = items(&f, "items")
817        .into_iter()
818        .filter_map(|raw| {
819            let t = Fields::parse(raw).ok()?;
820            let severity = items(&t, "conditions")
821                .first()
822                .and_then(|c| Fields::parse(c).ok())
823                .and_then(|c| c.string("severity"))
824                .map_or(Severity::Ok, |s| Severity::parse(&s));
825            Some((
826                t.string("name")?,
827                TopicNumbers {
828                    depth: whole(&t, "depth"),
829                    publish_rate: num(&t, "publishRate"),
830                    consume_rate: num(&t, "consumeRate"),
831                    oldest_age: num(&t, "oldestAge"),
832                    worst: t
833                        .string("worstCondition")
834                        .unwrap_or_else(|| "ok".to_owned()),
835                    severity,
836                },
837            ))
838        })
839        .collect();
840    Ok(TopicStatsPage {
841        items: list,
842        next_cursor: f.string("nextCursor"),
843        partial: f.boolean("partial").unwrap_or(false),
844        missing: parse_missing(&f),
845    })
846}
847
848/// One reading of a queue for its charts (`GET /stats/topics/{name}`): the counts, the rates, the age of the oldest
849/// message and that of the largest groups.
850#[derive(Debug, Clone, PartialEq, Default)]
851pub struct TopicSample {
852    pub visible: u64,
853    pub invisible: u64,
854    /// Messages published, delivered and acknowledged (deleted) per second.
855    pub publish_rate: f64,
856    pub consume_rate: f64,
857    pub ack_rate: f64,
858    /// Seconds; absent when the queue has no messages.
859    pub oldest_age: Option<f64>,
860    /// The largest groups with messages, largest first: the group and the age of its oldest message.
861    pub groups: Vec<(String, f64)>,
862    pub partial: bool,
863}
864
865pub fn parse_topic_sample(text: &str) -> Result<TopicSample, String> {
866    let f = fields(text)?;
867    let groups = items(&f, "topGroups")
868        .into_iter()
869        .filter_map(|raw| {
870            let g = Fields::parse(raw).ok()?;
871            Some((g.string("groupId")?, num(&g, "oldestAge")?))
872        })
873        .collect();
874    Ok(TopicSample {
875        visible: whole(&f, "visibleMessages").unwrap_or(0),
876        invisible: whole(&f, "invisibleMessages").unwrap_or(0),
877        publish_rate: num(&f, "publishRate").unwrap_or(0.0),
878        consume_rate: num(&f, "consumeRate").unwrap_or(0.0),
879        ack_rate: num(&f, "ackRate").unwrap_or(0.0),
880        oldest_age: num(&f, "oldestAge"),
881        groups,
882        partial: f.boolean("partial").unwrap_or(false),
883    })
884}
885
886/// The messages of a reading (`GET /topics/{name}/messages`).
887pub fn parse_received(text: &str) -> Result<Vec<taba_client::Message>, String> {
888    let f = fields(text)?;
889    items(&f, "messages")
890        .into_iter()
891        .map(|raw| taba_client::Message::parse(raw).map_err(|e| e.to_string()))
892        .collect()
893}
894
895/// What a publication answered: the id (queue), whether it was a duplicate (FIFO) and how many queues matched
896/// (distributor).
897#[derive(Debug, Clone, PartialEq, Eq, Default)]
898pub struct Published {
899    pub id: Option<String>,
900    pub duplicate: bool,
901    pub matched: Option<u64>,
902}
903
904pub fn parse_published(text: &str) -> Result<Published, String> {
905    let f = fields(text)?;
906    Ok(Published {
907        id: f.string("id"),
908        duplicate: f.boolean("duplicate").unwrap_or(false),
909        matched: whole(&f, "matched"),
910    })
911}
912
913/// How many messages a prune removed.
914pub fn parse_pruned(text: &str) -> Result<u64, String> {
915    let f = fields(text)?;
916    Ok(whole(&f, "removed").unwrap_or(0))
917}
918
919#[cfg(test)]
920mod tests;