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 mode: String,
403    pub state: String,
404    pub drain_state: String,
405    pub started_at: Option<String>,
406    pub memory_used: u64,
407    pub memory_limit: u64,
408    pub memory_messages: u64,
409    pub disk_messages: u64,
410    pub disk_free: u64,
411    pub quorum: bool,
412    pub channels: u64,
413    pub syncing_blocks: u64,
414    pub below_replication: u64,
415    pub below_quorum: u64,
416    pub frozen_nodes: u64,
417    pub active_requests: f64,
418    pub rate_limited: f64,
419    pub rtt_p95: Option<f64>,
420}
421
422impl NodeStatus {
423    /// Memory in use, from 0 to 1 (0 without a limit).
424    pub fn memory_ratio(&self) -> f64 {
425        if self.memory_limit == 0 {
426            0.0
427        } else {
428            (self.memory_used as f64 / self.memory_limit as f64).clamp(0.0, 1.0)
429        }
430    }
431}
432
433#[derive(Debug, Clone, PartialEq, Default)]
434pub struct ClusterStatus {
435    pub collected_at: String,
436    pub partial: bool,
437    pub missing: Vec<Missing>,
438    pub worst: String,
439    pub conditions: Vec<Condition>,
440    pub members: Vec<(String, u64)>,
441    pub members_version: u64,
442    pub quorum: bool,
443    pub syncing_blocks: u64,
444    /// Every node that answered is in drain: the cluster is stopped (a planned stop, or all drained by hand).
445    pub stopped: bool,
446    /// The S3 stop list exists: the stop was `POST /cluster/stop`.
447    pub planned_stop: bool,
448    pub stopped_at: Option<String>,
449    pub nodes: Vec<NodeStatus>,
450}
451
452fn parse_node_status(node_id: &str, reachable: bool, raw: Option<&str>) -> NodeStatus {
453    let mut node = NodeStatus {
454        node_id: node_id.to_owned(),
455        reachable,
456        ..NodeStatus::default()
457    };
458    let Some(f) = raw.and_then(|r| Fields::parse(r).ok()) else {
459        return node;
460    };
461    node.cluster_name = f.string("clusterName");
462    node.version = f.string("version").unwrap_or_default();
463    node.mode = f.string("mode").unwrap_or_default();
464    node.state = f.string("state").unwrap_or_default();
465    node.drain_state = f.string("drainState").unwrap_or_default();
466    node.started_at = f.string("startedAt");
467    if let Some(s) = f.raw("storage").and_then(|r| Fields::parse(r).ok()) {
468        node.memory_used = whole(&s, "memoryUsedBytes").unwrap_or(0);
469        node.memory_limit = whole(&s, "memoryLimitBytes").unwrap_or(0);
470        node.memory_messages = whole(&s, "memoryMessages").unwrap_or(0);
471        node.disk_messages = whole(&s, "diskMessages").unwrap_or(0);
472        node.disk_free = whole(&s, "diskFreeBytes").unwrap_or(0);
473    }
474    if let Some(s) = f.raw("signals").and_then(|r| Fields::parse(r).ok()) {
475        node.quorum = s.boolean("quorumAvailable").unwrap_or(false);
476        node.channels = count_of(&s, "channels");
477        node.syncing_blocks = whole(&s, "syncingBlocks").unwrap_or(0);
478        node.below_replication = whole(&s, "blocksBelowReplication").unwrap_or(0);
479        node.below_quorum = whole(&s, "blocksBelowQuorum").unwrap_or(0);
480        node.frozen_nodes = whole(&s, "frozenNodes").unwrap_or(0);
481    }
482    if let Some(r) = f.raw("runtime").and_then(|r| Fields::parse(r).ok()) {
483        node.active_requests = num(&r, "activeRequests").unwrap_or(0.0);
484        node.rate_limited = num(&r, "rateLimitRejected").unwrap_or(0.0);
485        node.rtt_p95 = num(&r, "channelRttP95Seconds");
486    }
487    node
488}
489
490pub fn parse_cluster_status(text: &str) -> Result<ClusterStatus, String> {
491    let f = fields(text)?;
492    let conditions = items(&f, "conditions")
493        .into_iter()
494        .filter_map(|raw| {
495            let c = Fields::parse(raw).ok()?;
496            Some(Condition {
497                name: c.string("condition")?,
498                severity: Severity::parse(&c.string("severity").unwrap_or_default()),
499            })
500        })
501        .collect();
502    let mut status = ClusterStatus {
503        collected_at: f.string("collectedAt").unwrap_or_default(),
504        partial: f.boolean("partial").unwrap_or(false),
505        missing: parse_missing(&f),
506        worst: f
507            .string("worstCondition")
508            .unwrap_or_else(|| "ok".to_owned()),
509        conditions,
510        ..ClusterStatus::default()
511    };
512    if let Some(s) = f.raw("summary").and_then(|r| Fields::parse(r).ok()) {
513        status.members_version = whole(&s, "membersVersion").unwrap_or(0);
514        status.quorum = s.boolean("quorumAvailable").unwrap_or(false);
515        status.syncing_blocks = whole(&s, "syncingBlocks").unwrap_or(0);
516        status.stopped = s.boolean("stopped").unwrap_or(false);
517        status.planned_stop = s.boolean("plannedStop").unwrap_or(false);
518        status.stopped_at = s.string("stoppedAt");
519        if let Some(m) = s.raw("members").and_then(|r| json::object_fields(r).ok()) {
520            status.members = m
521                .into_iter()
522                .map(|(state, n)| (state, n.parse().unwrap_or(0)))
523                .collect();
524        }
525    }
526    status.nodes = items(&f, "nodes")
527        .into_iter()
528        .filter_map(|raw| {
529            let n = Fields::parse(raw).ok()?;
530            let id = n.string("nodeId")?;
531            let reachable = n.boolean("reachable").unwrap_or(false);
532            Some(parse_node_status(&id, reachable, n.raw("status")))
533        })
534        .collect();
535    Ok(status)
536}
537
538/// `GET`, `POST` and `DELETE /nodes/{id}/drain`.
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct Drain {
541    pub state: String,
542    pub pending: u64,
543}
544
545impl Drain {
546    pub fn is_active(&self) -> bool {
547        self.state == "active"
548    }
549
550    pub fn is_done(&self) -> bool {
551        self.state == "drained"
552    }
553}
554
555pub fn parse_drain(text: &str) -> Result<Drain, String> {
556    let f = fields(text)?;
557    Ok(Drain {
558        state: f.string("drainState").unwrap_or_default(),
559        pending: whole(&f, "pendingMessages").unwrap_or(0),
560    })
561}
562
563/// The S3 backup of a node (`GET /nodes/{id}/backup`).
564#[derive(Debug, Clone, PartialEq, Eq, Default)]
565pub struct Backup {
566    pub reachable: Option<bool>,
567    pub pending: Option<u64>,
568    pub last_success: Option<String>,
569    pub segments: Option<u64>,
570}
571
572pub fn parse_backup(text: &str) -> Result<Backup, String> {
573    let f = fields(text)?;
574    Ok(Backup {
575        reachable: f.boolean("s3Reachable").or_else(|| f.boolean("reachable")),
576        pending: whole(&f, "outboxPending").or_else(|| whole(&f, "pending")),
577        last_success: f
578            .string("lastSuccessAt")
579            .or_else(|| f.string("lastSuccess")),
580        segments: whole(&f, "segments").or_else(|| whole(&f, "segmentsUploaded")),
581    })
582}
583
584/// What one node reported on a backup flush.
585#[derive(Debug, Clone, PartialEq, Eq, Default)]
586pub struct FlushNode {
587    pub node_id: Option<String>,
588    pub complete: bool,
589    pub pending: bool,
590    pub timed_out: bool,
591    pub outbox_pending: u64,
592    pub memory_remaining: u64,
593    pub error: Option<String>,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq, Default)]
597pub struct FlushReport {
598    pub complete: bool,
599    pub nodes: Vec<FlushNode>,
600}
601
602fn flush_node(f: &Fields<'_>) -> FlushNode {
603    FlushNode {
604        node_id: f.string("nodeId"),
605        complete: f.boolean("complete").unwrap_or(false),
606        pending: f.boolean("pending").unwrap_or(false),
607        timed_out: f.boolean("timedOut").unwrap_or(false),
608        outbox_pending: whole(f, "outboxPending").unwrap_or(0),
609        memory_remaining: whole(f, "memoryRemaining").unwrap_or(0),
610        error: f.string("error"),
611    }
612}
613
614/// The reply of a flush of one node (the report itself) or of the cluster (a `nodes` list).
615pub fn parse_flush(text: &str) -> Result<FlushReport, String> {
616    let f = fields(text)?;
617    let complete = f.boolean("complete").unwrap_or(false);
618    let nodes = if f.raw("nodes").is_some() {
619        items(&f, "nodes")
620            .into_iter()
621            .filter_map(|raw| Fields::parse(raw).ok().map(|n| flush_node(&n)))
622            .collect()
623    } else {
624        vec![flush_node(&f)]
625    };
626    Ok(FlushReport { complete, nodes })
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Default)]
630pub struct StopReport {
631    pub stopped: Vec<String>,
632    pub stop_list: bool,
633    pub warnings: Vec<String>,
634    pub nodes: Vec<(String, bool)>,
635}
636
637pub fn parse_stop(text: &str) -> Result<StopReport, String> {
638    let f = fields(text)?;
639    let nodes = items(&f, "nodes")
640        .into_iter()
641        .filter_map(|raw| {
642            let n = Fields::parse(raw).ok()?;
643            Some((n.string("nodeId")?, n.boolean("drained").unwrap_or(false)))
644        })
645        .collect();
646    Ok(StopReport {
647        stopped: texts(&f, "stopped"),
648        stop_list: f.boolean("stopList").unwrap_or(false),
649        warnings: texts(&f, "warnings"),
650        nodes,
651    })
652}
653
654/// A token of `GET /tokens` (never with the secret).
655#[derive(Debug, Clone, PartialEq, Eq, Default)]
656pub struct Token {
657    pub id: String,
658    pub name: String,
659    pub permissions: Vec<String>,
660    pub prefixes: Vec<String>,
661    pub created_at: Option<String>,
662    pub expires_at: Option<String>,
663}
664
665fn token_of(f: &Fields<'_>) -> Option<Token> {
666    Some(Token {
667        id: f.string("id")?,
668        name: f.string("name").unwrap_or_default(),
669        permissions: texts(f, "permissions"),
670        prefixes: texts(f, "topicPrefixes"),
671        created_at: f.string("createdAt"),
672        expires_at: f.string("expiresAt"),
673    })
674}
675
676/// A page of tokens and where the next one starts.
677#[derive(Debug, Clone, PartialEq, Eq, Default)]
678pub struct TokenPage {
679    pub items: Vec<Token>,
680    pub next_cursor: Option<String>,
681}
682
683pub fn parse_tokens(text: &str) -> Result<TokenPage, String> {
684    let f = fields(text)?;
685    let list = items(&f, "items")
686        .into_iter()
687        .filter_map(|raw| token_of(&Fields::parse(raw).ok()?))
688        .collect();
689    Ok(TokenPage {
690        items: list,
691        next_cursor: f.string("nextCursor"),
692    })
693}
694
695/// The reply of `POST /tokens`: the token and its secret, which exists only here.
696pub fn parse_token_created(text: &str) -> Result<(Token, String), String> {
697    let f = fields(text)?;
698    let token = token_of(&f).ok_or_else(|| "the reply has no token id".to_owned())?;
699    let secret = f
700        .string("token")
701        .ok_or_else(|| "the reply has no secret".to_owned())?;
702    Ok((token, secret))
703}
704
705/// A topic of `GET /topics` (doc 16): its definition, as the server keeps it.
706#[derive(Debug, Clone, PartialEq, Eq, Default)]
707pub struct Topic {
708    pub id: String,
709    pub name: String,
710    /// `queue` or `distributor`.
711    pub kind: String,
712    pub fifo: bool,
713    pub volatile: bool,
714    pub tags: Vec<(String, String)>,
715    pub prefixes: Vec<String>,
716    pub idle_timeout: Option<u64>,
717    /// What was set on the topic itself; `None` is the node's default.
718    pub max_message_bytes: Option<u64>,
719    pub ttl: Option<u64>,
720    pub max_deliveries: Option<u64>,
721    /// The values that apply (`effective`): the topic's own, or the node's default.
722    pub effective_max_message_bytes: Option<u64>,
723    pub effective_ttl: Option<u64>,
724    pub effective_max_deliveries: Option<u64>,
725    pub dead_letter: bool,
726    /// The queue a dead-letter queue belongs to.
727    pub source: Option<String>,
728    pub created_at: Option<String>,
729}
730
731impl Topic {
732    pub fn is_distributor(&self) -> bool {
733        self.kind == "distributor"
734    }
735}
736
737fn pairs_of(f: &Fields<'_>, name: &str) -> Vec<(String, String)> {
738    f.raw(name)
739        .and_then(|raw| json::object_fields(raw).ok())
740        .unwrap_or_default()
741        .into_iter()
742        .filter_map(|(key, value)| Some((key, json::decode_string(value).ok()?)))
743        .collect()
744}
745
746fn topic_of(f: &Fields<'_>) -> Option<Topic> {
747    let effective = f.raw("effective").and_then(|r| Fields::parse(r).ok());
748    let own = |name: &str| effective.as_ref().and_then(|e| whole(e, name));
749    Some(Topic {
750        id: f.string("id").unwrap_or_default(),
751        name: f.string("name")?,
752        kind: f.string("type").unwrap_or_else(|| "queue".to_owned()),
753        fifo: f.boolean("fifo").unwrap_or(false),
754        volatile: f.boolean("volatile").unwrap_or(false),
755        tags: pairs_of(f, "tags"),
756        prefixes: texts(f, "prefixes"),
757        idle_timeout: whole(f, "idleTimeout"),
758        max_message_bytes: whole(f, "maxMessageBytes"),
759        ttl: whole(f, "ttl"),
760        max_deliveries: whole(f, "maxDeliveries"),
761        effective_max_message_bytes: own("maxMessageBytes"),
762        effective_ttl: own("ttl"),
763        effective_max_deliveries: own("maxDeliveries"),
764        dead_letter: f.boolean("deadLetter").unwrap_or(false),
765        source: f.string("source"),
766        created_at: f.string("createdAt"),
767    })
768}
769
770/// A page of topics and where the next one starts.
771#[derive(Debug, Clone, PartialEq, Eq, Default)]
772pub struct TopicPage {
773    pub items: Vec<Topic>,
774    pub next_cursor: Option<String>,
775}
776
777pub fn parse_topics(text: &str) -> Result<TopicPage, String> {
778    let f = fields(text)?;
779    let list = items(&f, "items")
780        .into_iter()
781        .filter_map(|raw| topic_of(&Fields::parse(raw).ok()?))
782        .collect();
783    Ok(TopicPage {
784        items: list,
785        next_cursor: f.string("nextCursor"),
786    })
787}
788
789/// The reply of `POST /topics` and `PATCH /topics/{name}`: the topic as it is now.
790pub fn parse_topic(text: &str) -> Result<Topic, String> {
791    topic_of(&fields(text)?).ok_or_else(|| "the reply has no topic name".to_owned())
792}
793
794/// The numbers of one topic in the list (`GET /stats/topics`). A distributor has no queue, so no numbers.
795#[derive(Debug, Clone, PartialEq, Default)]
796pub struct TopicNumbers {
797    pub depth: Option<u64>,
798    pub publish_rate: Option<f64>,
799    pub consume_rate: Option<f64>,
800    /// Seconds; absent when the topic has no messages.
801    pub oldest_age: Option<f64>,
802    /// A condition name (`ok`, `backlog`, `stalled`...), not translated.
803    pub worst: String,
804    pub severity: Severity,
805}
806
807/// A page of statistics and where the next one starts.
808#[derive(Debug, Clone, PartialEq, Default)]
809pub struct TopicStatsPage {
810    pub items: Vec<(String, TopicNumbers)>,
811    pub next_cursor: Option<String>,
812    pub partial: bool,
813    pub missing: Vec<Missing>,
814}
815
816pub fn parse_topic_stats(text: &str) -> Result<TopicStatsPage, String> {
817    let f = fields(text)?;
818    let list = items(&f, "items")
819        .into_iter()
820        .filter_map(|raw| {
821            let t = Fields::parse(raw).ok()?;
822            let severity = items(&t, "conditions")
823                .first()
824                .and_then(|c| Fields::parse(c).ok())
825                .and_then(|c| c.string("severity"))
826                .map_or(Severity::Ok, |s| Severity::parse(&s));
827            Some((
828                t.string("name")?,
829                TopicNumbers {
830                    depth: whole(&t, "depth"),
831                    publish_rate: num(&t, "publishRate"),
832                    consume_rate: num(&t, "consumeRate"),
833                    oldest_age: num(&t, "oldestAge"),
834                    worst: t
835                        .string("worstCondition")
836                        .unwrap_or_else(|| "ok".to_owned()),
837                    severity,
838                },
839            ))
840        })
841        .collect();
842    Ok(TopicStatsPage {
843        items: list,
844        next_cursor: f.string("nextCursor"),
845        partial: f.boolean("partial").unwrap_or(false),
846        missing: parse_missing(&f),
847    })
848}
849
850/// One reading of a queue for its charts (`GET /stats/topics/{name}`): the counts, the rates, the age of the oldest
851/// message and that of the largest groups.
852#[derive(Debug, Clone, PartialEq, Default)]
853pub struct TopicSample {
854    pub visible: u64,
855    pub invisible: u64,
856    /// Messages published, delivered and acknowledged (deleted) per second.
857    pub publish_rate: f64,
858    pub consume_rate: f64,
859    pub ack_rate: f64,
860    /// Seconds; absent when the queue has no messages.
861    pub oldest_age: Option<f64>,
862    /// The largest groups with messages, largest first: the group and the age of its oldest message.
863    pub groups: Vec<(String, f64)>,
864    pub partial: bool,
865}
866
867pub fn parse_topic_sample(text: &str) -> Result<TopicSample, String> {
868    let f = fields(text)?;
869    let groups = items(&f, "topGroups")
870        .into_iter()
871        .filter_map(|raw| {
872            let g = Fields::parse(raw).ok()?;
873            Some((g.string("groupId")?, num(&g, "oldestAge")?))
874        })
875        .collect();
876    Ok(TopicSample {
877        visible: whole(&f, "visibleMessages").unwrap_or(0),
878        invisible: whole(&f, "invisibleMessages").unwrap_or(0),
879        publish_rate: num(&f, "publishRate").unwrap_or(0.0),
880        consume_rate: num(&f, "consumeRate").unwrap_or(0.0),
881        ack_rate: num(&f, "ackRate").unwrap_or(0.0),
882        oldest_age: num(&f, "oldestAge"),
883        groups,
884        partial: f.boolean("partial").unwrap_or(false),
885    })
886}
887
888/// The messages of a reading (`GET /topics/{name}/messages`).
889pub fn parse_received(text: &str) -> Result<Vec<taba_client::Message>, String> {
890    let f = fields(text)?;
891    items(&f, "messages")
892        .into_iter()
893        .map(|raw| taba_client::Message::parse(raw).map_err(|e| e.to_string()))
894        .collect()
895}
896
897/// What a publication answered: the id (queue), whether it was a duplicate (FIFO) and how many queues matched
898/// (distributor).
899#[derive(Debug, Clone, PartialEq, Eq, Default)]
900pub struct Published {
901    pub id: Option<String>,
902    pub duplicate: bool,
903    pub matched: Option<u64>,
904}
905
906pub fn parse_published(text: &str) -> Result<Published, String> {
907    let f = fields(text)?;
908    Ok(Published {
909        id: f.string("id"),
910        duplicate: f.boolean("duplicate").unwrap_or(false),
911        matched: whole(&f, "matched"),
912    })
913}
914
915/// How many messages a prune removed.
916pub fn parse_pruned(text: &str) -> Result<u64, String> {
917    let f = fields(text)?;
918    Ok(whole(&f, "removed").unwrap_or(0))
919}
920
921#[cfg(test)]
922mod tests;