1use crate::client::Client;
12use crate::error::{Error, Result};
13use crate::internal::{apply_pagination, push_opt};
14use crate::pagination::{FetchFn, Page, PageStream};
15use crate::resources::agencies::urlencoding;
16use crate::Record;
17use bon::Builder;
18use std::collections::BTreeMap;
19use std::sync::Arc;
20
21#[derive(Debug, Clone, Default, Builder, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct IdvSubresourceOptions {
30 #[builder(into)]
32 pub page: Option<u32>,
33 #[builder(into)]
35 pub limit: Option<u32>,
36 #[builder(into)]
38 pub cursor: Option<String>,
39 #[builder(into)]
41 pub shape: Option<String>,
42 #[builder(default)]
44 pub flat: bool,
45 #[builder(default)]
47 pub flat_lists: bool,
48 #[builder(into)]
50 pub joiner: Option<String>,
51 #[builder(into)]
53 pub ordering: Option<String>,
54 #[builder(into)]
56 pub search: Option<String>,
57 #[builder(default)]
59 pub extra: BTreeMap<String, String>,
60}
61
62impl IdvSubresourceOptions {
63 pub(crate) fn to_query(&self) -> Vec<(String, String)> {
64 let mut q = Vec::new();
65 apply_pagination(
66 &mut q,
67 self.page,
68 self.limit,
69 self.cursor.as_deref(),
70 self.shape.as_deref(),
71 self.flat,
72 self.flat_lists,
73 );
74 if self.flat {
75 if let Some(j) = self.joiner.as_deref().filter(|s| !s.is_empty()) {
76 q.push(("joiner".into(), j.into()));
77 }
78 }
79 push_opt(&mut q, "ordering", self.ordering.as_deref());
80 push_opt(&mut q, "search", self.search.as_deref());
81 for (k, v) in &self.extra {
82 if !v.is_empty() {
83 q.push((k.clone(), v.clone()));
84 }
85 }
86 q
87 }
88}
89
90impl Client {
91 pub async fn list_idv_awards(
93 &self,
94 key: &str,
95 opts: IdvSubresourceOptions,
96 ) -> Result<Page<Record>> {
97 list_idv_subresource(self, key, "awards", opts).await
98 }
99
100 pub fn iterate_idv_awards(&self, key: &str, opts: IdvSubresourceOptions) -> PageStream<Record> {
102 iterate_idv_subresource(self, key.to_string(), "awards", opts)
103 }
104
105 pub async fn list_idv_child_idvs(
107 &self,
108 key: &str,
109 opts: IdvSubresourceOptions,
110 ) -> Result<Page<Record>> {
111 list_idv_subresource(self, key, "idvs", opts).await
112 }
113
114 pub fn iterate_idv_child_idvs(
116 &self,
117 key: &str,
118 opts: IdvSubresourceOptions,
119 ) -> PageStream<Record> {
120 iterate_idv_subresource(self, key.to_string(), "idvs", opts)
121 }
122
123 pub async fn list_idv_transactions(
126 &self,
127 key: &str,
128 opts: IdvSubresourceOptions,
129 ) -> Result<Page<Record>> {
130 list_idv_subresource(self, key, "transactions", opts).await
131 }
132
133 pub fn iterate_idv_transactions(
135 &self,
136 key: &str,
137 opts: IdvSubresourceOptions,
138 ) -> PageStream<Record> {
139 iterate_idv_subresource(self, key.to_string(), "transactions", opts)
140 }
141
142 pub async fn list_idv_lcats(
144 &self,
145 key: &str,
146 opts: IdvSubresourceOptions,
147 ) -> Result<Page<Record>> {
148 list_idv_subresource(self, key, "lcats", opts).await
149 }
150
151 pub fn iterate_idv_lcats(&self, key: &str, opts: IdvSubresourceOptions) -> PageStream<Record> {
153 iterate_idv_subresource(self, key.to_string(), "lcats", opts)
154 }
155}
156
157async fn list_idv_subresource(
158 client: &Client,
159 key: &str,
160 segment: &str,
161 opts: IdvSubresourceOptions,
162) -> Result<Page<Record>> {
163 if key.is_empty() {
164 return Err(Error::Validation {
165 message: "IDV sub-resource: key is required".into(),
166 response: None,
167 });
168 }
169 let q = opts.to_query();
170 let path = format!("/api/idvs/{}/{segment}/", urlencoding(key));
171 client.get_page(&path, &q).await
172}
173
174fn iterate_idv_subresource(
175 client: &Client,
176 key: String,
177 segment: &'static str,
178 opts: IdvSubresourceOptions,
179) -> PageStream<Record> {
180 let opts = Arc::new(opts);
181 let key = Arc::new(key);
182 let fetch: FetchFn<Record> = Box::new(move |client, page, cursor| {
183 let mut next = (*opts).clone();
184 next.page = page;
185 next.cursor = cursor;
186 let key = key.clone();
187 Box::pin(async move { list_idv_subresource(&client, &key, segment, next).await })
188 });
189 PageStream::new(client.clone(), fetch)
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn get_q(q: &[(String, String)], k: &str) -> Option<String> {
197 q.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone())
198 }
199
200 #[test]
201 fn options_emit_pagination_and_search() {
202 let opts = IdvSubresourceOptions::builder()
203 .limit(10u32)
204 .ordering("-award_date")
205 .search("software")
206 .build();
207 let q = opts.to_query();
208 assert_eq!(get_q(&q, "limit").as_deref(), Some("10"));
209 assert_eq!(get_q(&q, "ordering").as_deref(), Some("-award_date"));
210 assert_eq!(get_q(&q, "search").as_deref(), Some("software"));
211 }
212
213 #[test]
214 fn joiner_only_when_flat() {
215 let opts = IdvSubresourceOptions::builder()
216 .joiner("__".to_string())
217 .build();
218 let q = opts.to_query();
219 assert!(!q.iter().any(|(k, _)| k == "joiner"));
220
221 let opts = IdvSubresourceOptions::builder()
222 .flat(true)
223 .joiner("__".to_string())
224 .build();
225 let q = opts.to_query();
226 assert!(q.contains(&("joiner".into(), "__".into())));
227 }
228
229 #[test]
230 fn extra_forwards_arbitrary_params() {
231 let mut extra = BTreeMap::new();
232 extra.insert("region".to_string(), "west".to_string());
233 let opts = IdvSubresourceOptions::builder().extra(extra).build();
234 let q = opts.to_query();
235 assert!(q.contains(&("region".into(), "west".into())));
236 }
237
238 #[tokio::test]
239 async fn list_idv_awards_empty_key_returns_validation() {
240 let client = Client::builder().api_key("x").build().expect("build");
241 let err = client
242 .list_idv_awards("", IdvSubresourceOptions::default())
243 .await
244 .expect_err("must error");
245 match err {
246 Error::Validation { message, .. } => assert!(message.contains("key")),
247 other => panic!("expected Validation, got {other:?}"),
248 }
249 }
250
251 #[tokio::test]
252 async fn list_idv_lcats_empty_key_returns_validation() {
253 let client = Client::builder().api_key("x").build().expect("build");
254 let err = client
255 .list_idv_lcats("", IdvSubresourceOptions::default())
256 .await
257 .expect_err("must error");
258 match err {
259 Error::Validation { message, .. } => assert!(message.contains("key")),
260 other => panic!("expected Validation, got {other:?}"),
261 }
262 }
263}