ghost_io_api/params/browse.rs
1//! Fluent builder for Ghost API browse endpoint query parameters.
2//!
3//! [`BrowseParams`] covers every standard query parameter accepted by Ghost
4//! browse endpoints (`/posts/`, `/pages/`, `/tags/`, `/authors/`, etc.).
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::params::browse::BrowseParams;
10//!
11//! let params = BrowseParams::new()
12//! .limit(10)
13//! .page(2)
14//! .filter("featured:true")
15//! .order("published_at DESC")
16//! .include("authors,tags")
17//! .fields("id,title,slug")
18//! .formats("html,plaintext");
19//!
20//! let pairs = params.to_query_pairs();
21//! assert_eq!(pairs.len(), 7);
22//! ```
23
24/// Fluent builder for Ghost API browse query parameters.
25///
26/// All setter methods consume and return `self` so calls can be chained.
27/// Fields left unset are omitted from the query string entirely, letting
28/// Ghost apply its own defaults.
29///
30/// # Example
31///
32/// ```
33/// use ghost_io_api::params::browse::BrowseParams;
34///
35/// let params = BrowseParams::new()
36/// .limit(5)
37/// .filter("featured:true")
38/// .order("published_at DESC");
39///
40/// let pairs = params.to_query_pairs();
41/// let map: std::collections::HashMap<_, _> = pairs.into_iter().collect();
42/// assert_eq!(map["limit"], "5");
43/// assert_eq!(map["filter"], "featured:true");
44/// ```
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
46pub struct BrowseParams {
47 /// Maximum number of results to return per page.
48 limit: Option<u32>,
49 /// Page number to retrieve (1-indexed).
50 page: Option<u32>,
51 /// Ghost filter expression, e.g. `"featured:true+status:published"`.
52 filter: Option<String>,
53 /// Sort order, e.g. `"published_at DESC,title ASC"`.
54 order: Option<String>,
55 /// Comma-separated list of relations to include, e.g. `"authors,tags"`.
56 include: Option<String>,
57 /// Comma-separated list of fields to return, e.g. `"id,title,slug"`.
58 fields: Option<String>,
59 /// Comma-separated list of content formats to return, e.g. `"html,plaintext"`.
60 formats: Option<String>,
61}
62
63impl BrowseParams {
64 /// Creates a new `BrowseParams` with no fields set.
65 ///
66 /// # Example
67 ///
68 /// ```
69 /// use ghost_io_api::params::browse::BrowseParams;
70 ///
71 /// let params = BrowseParams::new();
72 /// assert!(params.to_query_pairs().is_empty());
73 /// ```
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 /// Sets the maximum number of results per page.
79 ///
80 /// # Example
81 ///
82 /// ```
83 /// use ghost_io_api::params::browse::BrowseParams;
84 ///
85 /// let params = BrowseParams::new().limit(15);
86 /// let pairs = params.to_query_pairs();
87 /// assert_eq!(pairs[0], ("limit", "15".to_string()));
88 /// ```
89 pub fn limit(mut self, limit: u32) -> Self {
90 self.limit = Some(limit);
91 self
92 }
93
94 /// Sets the page number to retrieve (1-indexed).
95 ///
96 /// # Example
97 ///
98 /// ```
99 /// use ghost_io_api::params::browse::BrowseParams;
100 ///
101 /// let params = BrowseParams::new().page(3);
102 /// let pairs = params.to_query_pairs();
103 /// assert_eq!(pairs[0], ("page", "3".to_string()));
104 /// ```
105 pub fn page(mut self, page: u32) -> Self {
106 self.page = Some(page);
107 self
108 }
109
110 /// Sets the Ghost filter expression.
111 ///
112 /// See the [Ghost filter docs](https://ghost.org/docs/content-api/#filtering)
113 /// for the full syntax. Examples: `"featured:true"`, `"tag:getting-started"`,
114 /// `"published_at:>2020-01-01"`.
115 ///
116 /// # Example
117 ///
118 /// ```
119 /// use ghost_io_api::params::browse::BrowseParams;
120 ///
121 /// let params = BrowseParams::new().filter("featured:true");
122 /// let pairs = params.to_query_pairs();
123 /// assert_eq!(pairs[0], ("filter", "featured:true".to_string()));
124 /// ```
125 pub fn filter(mut self, filter: impl Into<String>) -> Self {
126 self.filter = Some(filter.into());
127 self
128 }
129
130 /// Sets the sort order.
131 ///
132 /// Accepts a comma-separated list of `<field> <direction>` clauses,
133 /// e.g. `"published_at DESC,title ASC"`.
134 ///
135 /// # Example
136 ///
137 /// ```
138 /// use ghost_io_api::params::browse::BrowseParams;
139 ///
140 /// let params = BrowseParams::new().order("published_at DESC");
141 /// let pairs = params.to_query_pairs();
142 /// assert_eq!(pairs[0], ("order", "published_at DESC".to_string()));
143 /// ```
144 pub fn order(mut self, order: impl Into<String>) -> Self {
145 self.order = Some(order.into());
146 self
147 }
148
149 /// Sets the relations to include in the response.
150 ///
151 /// Accepts a comma-separated list of relation names supported by the
152 /// endpoint, e.g. `"authors,tags"`.
153 ///
154 /// # Example
155 ///
156 /// ```
157 /// use ghost_io_api::params::browse::BrowseParams;
158 ///
159 /// let params = BrowseParams::new().include("authors,tags");
160 /// let pairs = params.to_query_pairs();
161 /// assert_eq!(pairs[0], ("include", "authors,tags".to_string()));
162 /// ```
163 pub fn include(mut self, include: impl Into<String>) -> Self {
164 self.include = Some(include.into());
165 self
166 }
167
168 /// Sets the fields to return in each result object.
169 ///
170 /// Accepts a comma-separated list of field names, e.g. `"id,title,slug"`.
171 /// Unlisted fields are omitted from the response.
172 ///
173 /// # Example
174 ///
175 /// ```
176 /// use ghost_io_api::params::browse::BrowseParams;
177 ///
178 /// let params = BrowseParams::new().fields("id,title,slug");
179 /// let pairs = params.to_query_pairs();
180 /// assert_eq!(pairs[0], ("fields", "id,title,slug".to_string()));
181 /// ```
182 pub fn fields(mut self, fields: impl Into<String>) -> Self {
183 self.fields = Some(fields.into());
184 self
185 }
186
187 /// Sets the content formats to return.
188 ///
189 /// Accepts a comma-separated list of format names. Valid values are
190 /// `"html"`, `"mobiledoc"`, `"lexical"`, and `"plaintext"`.
191 ///
192 /// # Example
193 ///
194 /// ```
195 /// use ghost_io_api::params::browse::BrowseParams;
196 ///
197 /// let params = BrowseParams::new().formats("html,plaintext");
198 /// let pairs = params.to_query_pairs();
199 /// assert_eq!(pairs[0], ("formats", "html,plaintext".to_string()));
200 /// ```
201 pub fn formats(mut self, formats: impl Into<String>) -> Self {
202 self.formats = Some(formats.into());
203 self
204 }
205
206 /// Returns the current `limit` value, if set.
207 pub fn get_limit(&self) -> Option<u32> {
208 self.limit
209 }
210
211 /// Returns the current `page` value, if set.
212 pub fn get_page(&self) -> Option<u32> {
213 self.page
214 }
215
216 /// Returns the current `filter` value, if set.
217 pub fn get_filter(&self) -> Option<&str> {
218 self.filter.as_deref()
219 }
220
221 /// Returns the current `order` value, if set.
222 pub fn get_order(&self) -> Option<&str> {
223 self.order.as_deref()
224 }
225
226 /// Returns the current `include` value, if set.
227 pub fn get_include(&self) -> Option<&str> {
228 self.include.as_deref()
229 }
230
231 /// Returns the current `fields` value, if set.
232 pub fn get_fields(&self) -> Option<&str> {
233 self.fields.as_deref()
234 }
235
236 /// Returns the current `formats` value, if set.
237 pub fn get_formats(&self) -> Option<&str> {
238 self.formats.as_deref()
239 }
240
241 /// Serialises the parameters as a `Vec` of `(name, value)` string pairs.
242 ///
243 /// Only fields that have been set are included. The order is stable:
244 /// `limit`, `page`, `filter`, `order`, `include`, `fields`, `formats`.
245 ///
246 /// Suitable for passing directly to `reqwest`'s `.query()` method.
247 ///
248 /// # Example
249 ///
250 /// ```
251 /// use ghost_io_api::params::browse::BrowseParams;
252 ///
253 /// let pairs = BrowseParams::new()
254 /// .limit(5)
255 /// .page(1)
256 /// .to_query_pairs();
257 ///
258 /// assert_eq!(pairs.len(), 2);
259 /// assert_eq!(pairs[0], ("limit", "5".to_string()));
260 /// assert_eq!(pairs[1], ("page", "1".to_string()));
261 /// ```
262 pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
263 let mut pairs = Vec::new();
264 if let Some(limit) = self.limit {
265 pairs.push(("limit", limit.to_string()));
266 }
267 if let Some(page) = self.page {
268 pairs.push(("page", page.to_string()));
269 }
270 if let Some(ref filter) = self.filter {
271 pairs.push(("filter", filter.clone()));
272 }
273 if let Some(ref order) = self.order {
274 pairs.push(("order", order.clone()));
275 }
276 if let Some(ref include) = self.include {
277 pairs.push(("include", include.clone()));
278 }
279 if let Some(ref fields) = self.fields {
280 pairs.push(("fields", fields.clone()));
281 }
282 if let Some(ref formats) = self.formats {
283 pairs.push(("formats", formats.clone()));
284 }
285 pairs
286 }
287
288 /// Serialises the parameters as a URL query string (without leading `?`).
289 ///
290 /// Fields are percent-encoded. Returns an empty string when no fields
291 /// are set.
292 ///
293 /// # Example
294 ///
295 /// ```
296 /// use ghost_io_api::params::browse::BrowseParams;
297 ///
298 /// let qs = BrowseParams::new()
299 /// .limit(10)
300 /// .filter("featured:true")
301 /// .to_query_string();
302 ///
303 /// assert!(qs.contains("limit=10"));
304 /// assert!(qs.contains("filter=featured%3Atrue"));
305 /// ```
306 pub fn to_query_string(&self) -> String {
307 self.to_query_pairs()
308 .into_iter()
309 .map(|(k, v)| format!("{}={}", k, percent_encode(&v)))
310 .collect::<Vec<_>>()
311 .join("&")
312 }
313}
314
315/// Percent-encodes characters that are not unreserved URI characters.
316fn percent_encode(s: &str) -> String {
317 s.chars()
318 .flat_map(|c| {
319 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '~') {
320 vec![c]
321 } else {
322 let mut buf = [0u8; 4];
323 let bytes = c.encode_utf8(&mut buf);
324 bytes
325 .bytes()
326 .flat_map(|b| {
327 let hi = char::from_digit((b >> 4) as u32, 16)
328 .unwrap()
329 .to_ascii_uppercase();
330 let lo = char::from_digit((b & 0xf) as u32, 16)
331 .unwrap()
332 .to_ascii_uppercase();
333 vec!['%', hi, lo]
334 })
335 .collect::<Vec<_>>()
336 }
337 })
338 .collect()
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 // ── construction ──────────────────────────────────────────────────────────
346
347 #[test]
348 fn test_new_produces_empty_params() {
349 let params = BrowseParams::new();
350 assert!(params.to_query_pairs().is_empty());
351 }
352
353 #[test]
354 fn test_default_produces_empty_params() {
355 let params = BrowseParams::default();
356 assert!(params.to_query_pairs().is_empty());
357 }
358
359 // ── individual setters ────────────────────────────────────────────────────
360
361 #[test]
362 fn test_limit() {
363 let params = BrowseParams::new().limit(15);
364 assert_eq!(params.get_limit(), Some(15));
365 let pairs = params.to_query_pairs();
366 assert_eq!(pairs.len(), 1);
367 assert_eq!(pairs[0], ("limit", "15".to_string()));
368 }
369
370 #[test]
371 fn test_page() {
372 let params = BrowseParams::new().page(3);
373 assert_eq!(params.get_page(), Some(3));
374 let pairs = params.to_query_pairs();
375 assert_eq!(pairs.len(), 1);
376 assert_eq!(pairs[0], ("page", "3".to_string()));
377 }
378
379 #[test]
380 fn test_filter() {
381 let params = BrowseParams::new().filter("featured:true");
382 assert_eq!(params.get_filter(), Some("featured:true"));
383 let pairs = params.to_query_pairs();
384 assert_eq!(pairs.len(), 1);
385 assert_eq!(pairs[0], ("filter", "featured:true".to_string()));
386 }
387
388 #[test]
389 fn test_order() {
390 let params = BrowseParams::new().order("published_at DESC");
391 assert_eq!(params.get_order(), Some("published_at DESC"));
392 let pairs = params.to_query_pairs();
393 assert_eq!(pairs.len(), 1);
394 assert_eq!(pairs[0], ("order", "published_at DESC".to_string()));
395 }
396
397 #[test]
398 fn test_include() {
399 let params = BrowseParams::new().include("authors,tags");
400 assert_eq!(params.get_include(), Some("authors,tags"));
401 let pairs = params.to_query_pairs();
402 assert_eq!(pairs.len(), 1);
403 assert_eq!(pairs[0], ("include", "authors,tags".to_string()));
404 }
405
406 #[test]
407 fn test_fields() {
408 let params = BrowseParams::new().fields("id,title,slug");
409 assert_eq!(params.get_fields(), Some("id,title,slug"));
410 let pairs = params.to_query_pairs();
411 assert_eq!(pairs.len(), 1);
412 assert_eq!(pairs[0], ("fields", "id,title,slug".to_string()));
413 }
414
415 #[test]
416 fn test_formats() {
417 let params = BrowseParams::new().formats("html,plaintext");
418 assert_eq!(params.get_formats(), Some("html,plaintext"));
419 let pairs = params.to_query_pairs();
420 assert_eq!(pairs.len(), 1);
421 assert_eq!(pairs[0], ("formats", "html,plaintext".to_string()));
422 }
423
424 // ── chaining ──────────────────────────────────────────────────────────────
425
426 #[test]
427 fn test_all_fields_chained() {
428 let params = BrowseParams::new()
429 .limit(10)
430 .page(2)
431 .filter("featured:true")
432 .order("published_at DESC")
433 .include("authors,tags")
434 .fields("id,title,slug")
435 .formats("html,plaintext");
436
437 let pairs = params.to_query_pairs();
438 assert_eq!(pairs.len(), 7);
439
440 let map: std::collections::HashMap<_, _> = pairs.into_iter().collect();
441 assert_eq!(map["limit"], "10");
442 assert_eq!(map["page"], "2");
443 assert_eq!(map["filter"], "featured:true");
444 assert_eq!(map["order"], "published_at DESC");
445 assert_eq!(map["include"], "authors,tags");
446 assert_eq!(map["fields"], "id,title,slug");
447 assert_eq!(map["formats"], "html,plaintext");
448 }
449
450 #[test]
451 fn test_partial_chain() {
452 let params = BrowseParams::new().limit(5).filter("tag:news");
453 let pairs = params.to_query_pairs();
454 assert_eq!(pairs.len(), 2);
455 }
456
457 // ── stable ordering ───────────────────────────────────────────────────────
458
459 #[test]
460 fn test_query_pairs_order() {
461 let params = BrowseParams::new()
462 .formats("html")
463 .fields("id")
464 .include("tags")
465 .order("title ASC")
466 .filter("featured:false")
467 .page(1)
468 .limit(20);
469
470 let keys: Vec<_> = params
471 .to_query_pairs()
472 .into_iter()
473 .map(|(k, _)| k)
474 .collect();
475 assert_eq!(
476 keys,
477 ["limit", "page", "filter", "order", "include", "fields", "formats"]
478 );
479 }
480
481 // ── overwrite / last-write-wins ───────────────────────────────────────────
482
483 #[test]
484 fn test_setter_overwrites_previous_value() {
485 let params = BrowseParams::new().limit(5).limit(20);
486 assert_eq!(params.get_limit(), Some(20));
487 let pairs = params.to_query_pairs();
488 assert_eq!(pairs.len(), 1);
489 assert_eq!(pairs[0].1, "20");
490 }
491
492 // ── query string serialisation ────────────────────────────────────────────
493
494 #[test]
495 fn test_to_query_string_empty() {
496 assert_eq!(BrowseParams::new().to_query_string(), "");
497 }
498
499 #[test]
500 fn test_to_query_string_simple() {
501 let qs = BrowseParams::new().limit(10).page(1).to_query_string();
502 assert_eq!(qs, "limit=10&page=1");
503 }
504
505 #[test]
506 fn test_to_query_string_encodes_special_chars() {
507 let qs = BrowseParams::new()
508 .filter("featured:true")
509 .to_query_string();
510 assert!(qs.contains("filter=featured%3Atrue"));
511 }
512
513 #[test]
514 fn test_to_query_string_encodes_spaces() {
515 let qs = BrowseParams::new()
516 .order("published_at DESC")
517 .to_query_string();
518 assert!(qs.contains("order=published_at%20DESC"));
519 }
520
521 #[test]
522 fn test_to_query_string_encodes_plus_sign() {
523 let qs = BrowseParams::new()
524 .filter("featured:true+status:published")
525 .to_query_string();
526 assert!(qs.contains("%2B"));
527 }
528
529 // ── getters when unset ────────────────────────────────────────────────────
530
531 #[test]
532 fn test_getters_return_none_when_unset() {
533 let params = BrowseParams::new();
534 assert_eq!(params.get_limit(), None);
535 assert_eq!(params.get_page(), None);
536 assert_eq!(params.get_filter(), None);
537 assert_eq!(params.get_order(), None);
538 assert_eq!(params.get_include(), None);
539 assert_eq!(params.get_fields(), None);
540 assert_eq!(params.get_formats(), None);
541 }
542
543 // ── string-like inputs ────────────────────────────────────────────────────
544
545 #[test]
546 fn test_filter_accepts_string_and_str() {
547 let owned = "featured:true".to_string();
548 let p1 = BrowseParams::new().filter("featured:true");
549 let p2 = BrowseParams::new().filter(owned);
550 assert_eq!(p1, p2);
551 }
552
553 // ── clone & eq ────────────────────────────────────────────────────────────
554
555 #[test]
556 fn test_clone_and_eq() {
557 let params = BrowseParams::new().limit(5).filter("featured:true");
558 let cloned = params.clone();
559 assert_eq!(params, cloned);
560 }
561
562 #[test]
563 fn test_ne() {
564 let a = BrowseParams::new().limit(5);
565 let b = BrowseParams::new().limit(10);
566 assert_ne!(a, b);
567 }
568
569 // ── edge cases ────────────────────────────────────────────────────────────
570
571 #[test]
572 fn test_limit_zero() {
573 let params = BrowseParams::new().limit(0);
574 assert_eq!(params.get_limit(), Some(0));
575 let pairs = params.to_query_pairs();
576 assert_eq!(pairs[0], ("limit", "0".to_string()));
577 }
578
579 #[test]
580 fn test_page_zero() {
581 let params = BrowseParams::new().page(0);
582 assert_eq!(params.get_page(), Some(0));
583 }
584
585 #[test]
586 fn test_empty_string_filter() {
587 let params = BrowseParams::new().filter("");
588 assert_eq!(params.get_filter(), Some(""));
589 assert_eq!(params.to_query_pairs().len(), 1);
590 }
591
592 #[test]
593 fn test_percent_encode_unreserved_chars_unchanged() {
594 let qs = BrowseParams::new()
595 .filter("abc-def_ghi.jkl~mno")
596 .to_query_string();
597 assert_eq!(qs, "filter=abc-def_ghi.jkl~mno");
598 }
599
600 #[test]
601 fn test_percent_encode_alphanumeric_unchanged() {
602 let qs = BrowseParams::new().filter("abc123").to_query_string();
603 assert_eq!(qs, "filter=abc123");
604 }
605}