Skip to main content

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}