Skip to main content

tango/resources/
metrics.rs

1//! Metrics endpoints — rolling-window metrics keyed by NAICS code,
2//! PSC code, or entity UEI.
3//!
4//! Three owner-scoped paths share a shape:
5//!   - `GET /api/naics/{code}/metrics/{months}/{period_grouping}/`
6//!   - `GET /api/psc/{code}/metrics/{months}/{period_grouping}/`
7//!   - `GET /api/entities/{uei}/metrics/{months}/{period_grouping}/`
8//!
9//! The entity flavour is defined in [`super::entity_subresources`]; this
10//! module provides the NAICS and PSC flavours plus a [`Client::list_metrics`]
11//! dispatcher that routes to whichever one the caller wants.
12
13use crate::client::Client;
14use crate::error::{Error, Result};
15use crate::resources::agencies::urlencoding;
16use crate::Record;
17
18/// Owner-type discriminant for [`ListMetricsOptions::owner_type`].
19pub const METRICS_OWNER_NAICS: &str = "naics";
20/// Owner-type discriminant for [`ListMetricsOptions::owner_type`].
21pub const METRICS_OWNER_PSC: &str = "psc";
22/// Owner-type discriminant for [`ListMetricsOptions::owner_type`].
23pub const METRICS_OWNER_ENTITY: &str = "entity";
24
25/// Options for the [`Client::list_metrics`] dispatcher.
26///
27/// `owner_type` must be one of [`METRICS_OWNER_NAICS`], [`METRICS_OWNER_PSC`],
28/// or [`METRICS_OWNER_ENTITY`]. `owner_id` is the NAICS code, PSC code, or
29/// entity UEI; `months` is the rolling-window length (must be > 0);
30/// `period_grouping` is the aggregation granularity (typically `"month"`,
31/// `"quarter"`, or `"year"`).
32#[derive(Debug, Clone, Default, PartialEq, Eq)]
33#[non_exhaustive]
34pub struct ListMetricsOptions {
35    /// One of [`METRICS_OWNER_NAICS`], [`METRICS_OWNER_PSC`], or
36    /// [`METRICS_OWNER_ENTITY`].
37    pub owner_type: String,
38    /// The NAICS code, PSC code, or entity UEI.
39    pub owner_id: String,
40    /// Rolling-window length in months. Must be > 0.
41    pub months: u32,
42    /// Aggregation granularity (e.g. `"month"`, `"quarter"`, `"year"`).
43    pub period_grouping: String,
44}
45
46impl ListMetricsOptions {
47    /// Convenience constructor.
48    #[must_use]
49    pub fn new(
50        owner_type: impl Into<String>,
51        owner_id: impl Into<String>,
52        months: u32,
53        period_grouping: impl Into<String>,
54    ) -> Self {
55        Self {
56            owner_type: owner_type.into(),
57            owner_id: owner_id.into(),
58            months,
59            period_grouping: period_grouping.into(),
60        }
61    }
62}
63
64impl Client {
65    /// `GET /api/naics/{code}/metrics/{months}/{period_grouping}/`
66    ///
67    /// Rolling-window metrics keyed by NAICS code.
68    pub async fn get_naics_metrics(
69        &self,
70        code: &str,
71        months: u32,
72        period_grouping: &str,
73    ) -> Result<Record> {
74        if code.is_empty() {
75            return Err(Error::Validation {
76                message: "get_naics_metrics: NAICS code is required".into(),
77                response: None,
78            });
79        }
80        if months == 0 {
81            return Err(Error::Validation {
82                message: "get_naics_metrics: months must be > 0".into(),
83                response: None,
84            });
85        }
86        if period_grouping.is_empty() {
87            return Err(Error::Validation {
88                message: "get_naics_metrics: period_grouping is required".into(),
89                response: None,
90            });
91        }
92        let path = format!(
93            "/api/naics/{}/metrics/{}/{}/",
94            urlencoding(code),
95            months,
96            urlencoding(period_grouping),
97        );
98        self.get_json::<Record>(&path, &[]).await
99    }
100
101    /// `GET /api/psc/{code}/metrics/{months}/{period_grouping}/`
102    ///
103    /// Rolling-window metrics keyed by PSC code.
104    pub async fn get_psc_metrics(
105        &self,
106        code: &str,
107        months: u32,
108        period_grouping: &str,
109    ) -> Result<Record> {
110        if code.is_empty() {
111            return Err(Error::Validation {
112                message: "get_psc_metrics: PSC code is required".into(),
113                response: None,
114            });
115        }
116        if months == 0 {
117            return Err(Error::Validation {
118                message: "get_psc_metrics: months must be > 0".into(),
119                response: None,
120            });
121        }
122        if period_grouping.is_empty() {
123            return Err(Error::Validation {
124                message: "get_psc_metrics: period_grouping is required".into(),
125                response: None,
126            });
127        }
128        let path = format!(
129            "/api/psc/{}/metrics/{}/{}/",
130            urlencoding(code),
131            months,
132            urlencoding(period_grouping),
133        );
134        self.get_json::<Record>(&path, &[]).await
135    }
136
137    /// Convenience dispatcher: route to NAICS / PSC / entity metrics based
138    /// on [`ListMetricsOptions::owner_type`].
139    ///
140    /// Returns [`Error::Validation`] when:
141    /// - `owner_id` is empty,
142    /// - `months == 0`,
143    /// - `period_grouping` is empty, or
144    /// - `owner_type` isn't one of the three known discriminants.
145    ///
146    /// The entity flavour fetches `/api/entities/{uei}/metrics/{months}/{period_grouping}/`
147    /// directly here; once Wave A's `entity_subresources` lands the typed
148    /// `Client::get_entity_metrics` helper, this dispatcher will collapse to
149    /// delegate to it (no public-surface change).
150    pub async fn list_metrics(&self, opts: ListMetricsOptions) -> Result<Record> {
151        if opts.owner_id.is_empty() {
152            return Err(Error::Validation {
153                message: "list_metrics: owner_id is required".into(),
154                response: None,
155            });
156        }
157        if opts.months == 0 {
158            return Err(Error::Validation {
159                message: "list_metrics: months must be > 0".into(),
160                response: None,
161            });
162        }
163        if opts.period_grouping.is_empty() {
164            return Err(Error::Validation {
165                message: "list_metrics: period_grouping is required".into(),
166                response: None,
167            });
168        }
169        match opts.owner_type.as_str() {
170            METRICS_OWNER_NAICS => {
171                self.get_naics_metrics(&opts.owner_id, opts.months, &opts.period_grouping)
172                    .await
173            }
174            METRICS_OWNER_PSC => {
175                self.get_psc_metrics(&opts.owner_id, opts.months, &opts.period_grouping)
176                    .await
177            }
178            METRICS_OWNER_ENTITY => {
179                self.get_entity_metrics(&opts.owner_id, opts.months, &opts.period_grouping)
180                    .await
181            }
182            _ => Err(Error::Validation {
183                message: "list_metrics: owner_type must be one of: naics, psc, entity".into(),
184                response: None,
185            }),
186        }
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[tokio::test]
195    async fn naics_metrics_empty_code_is_validation() {
196        let c = Client::builder().api_key("k").build().expect("client");
197        let err = c
198            .get_naics_metrics("", 12, "month")
199            .await
200            .expect_err("must error");
201        assert!(matches!(err, Error::Validation { .. }));
202    }
203
204    #[tokio::test]
205    async fn naics_metrics_zero_months_is_validation() {
206        let c = Client::builder().api_key("k").build().expect("client");
207        let err = c
208            .get_naics_metrics("541512", 0, "month")
209            .await
210            .expect_err("must error");
211        assert!(matches!(err, Error::Validation { .. }));
212    }
213
214    #[tokio::test]
215    async fn naics_metrics_empty_grouping_is_validation() {
216        let c = Client::builder().api_key("k").build().expect("client");
217        let err = c
218            .get_naics_metrics("541512", 12, "")
219            .await
220            .expect_err("must error");
221        assert!(matches!(err, Error::Validation { .. }));
222    }
223
224    #[tokio::test]
225    async fn psc_metrics_empty_code_is_validation() {
226        let c = Client::builder().api_key("k").build().expect("client");
227        let err = c
228            .get_psc_metrics("", 12, "month")
229            .await
230            .expect_err("must error");
231        assert!(matches!(err, Error::Validation { .. }));
232    }
233
234    #[tokio::test]
235    async fn psc_metrics_zero_months_is_validation() {
236        let c = Client::builder().api_key("k").build().expect("client");
237        let err = c
238            .get_psc_metrics("D302", 0, "month")
239            .await
240            .expect_err("must error");
241        assert!(matches!(err, Error::Validation { .. }));
242    }
243
244    #[tokio::test]
245    async fn list_metrics_empty_owner_id_is_validation() {
246        let c = Client::builder().api_key("k").build().expect("client");
247        let opts = ListMetricsOptions::new(METRICS_OWNER_NAICS, "", 12, "month");
248        let err = c.list_metrics(opts).await.expect_err("must error");
249        let msg = match &err {
250            Error::Validation { message, .. } => message.clone(),
251            other => panic!("expected Validation, got {other:?}"),
252        };
253        assert!(msg.contains("owner_id"), "got: {msg}");
254    }
255
256    #[tokio::test]
257    async fn list_metrics_zero_months_is_validation() {
258        let c = Client::builder().api_key("k").build().expect("client");
259        let opts = ListMetricsOptions::new(METRICS_OWNER_NAICS, "541512", 0, "month");
260        let err = c.list_metrics(opts).await.expect_err("must error");
261        let msg = match &err {
262            Error::Validation { message, .. } => message.clone(),
263            other => panic!("expected Validation, got {other:?}"),
264        };
265        assert!(msg.contains("months"), "got: {msg}");
266    }
267
268    #[tokio::test]
269    async fn list_metrics_empty_grouping_is_validation() {
270        let c = Client::builder().api_key("k").build().expect("client");
271        let opts = ListMetricsOptions::new(METRICS_OWNER_NAICS, "541512", 12, "");
272        let err = c.list_metrics(opts).await.expect_err("must error");
273        let msg = match &err {
274            Error::Validation { message, .. } => message.clone(),
275            other => panic!("expected Validation, got {other:?}"),
276        };
277        assert!(msg.contains("period_grouping"), "got: {msg}");
278    }
279
280    #[tokio::test]
281    async fn list_metrics_unknown_owner_is_validation() {
282        let c = Client::builder().api_key("k").build().expect("client");
283        let opts = ListMetricsOptions::new("bogus", "541512", 12, "month");
284        let err = c.list_metrics(opts).await.expect_err("must error");
285        let msg = match &err {
286            Error::Validation { message, .. } => message.clone(),
287            other => panic!("expected Validation, got {other:?}"),
288        };
289        assert!(msg.contains("owner_type"), "got: {msg}");
290    }
291}