steel-rs 0.1.3

Steel API client
Documentation
use crate::client::{Error, RequestOptions, Steel};
use futures::stream::Stream;
use serde::de::DeserializeOwned;

pub(crate) type NextCursor<T> = fn(&[T], Option<&str>) -> Option<String>;

pub struct Page<T> {
    client: Steel,
    path: String,
    params: Vec<(String, String)>,
    next_cursor: NextCursor<T>,
    items_field: &'static str,
    cursor_field: Option<&'static str>,
    has_more_field: Option<&'static str>,
    cursor_param: &'static str,
    options: RequestOptions,
    pub data: Vec<T>,
    pub has_more: bool,
    cursor: Option<String>,
}

impl<T: DeserializeOwned> Page<T> {
    #[allow(clippy::too_many_arguments)]
    pub(crate) async fn fetch(
        client: Steel,
        path: String,
        params: Vec<(String, String)>,
        next_cursor: NextCursor<T>,
        items_field: &'static str,
        cursor_field: Option<&'static str>,
        has_more_field: Option<&'static str>,
        cursor_param: &'static str,
        options: RequestOptions,
    ) -> Result<Self, Error> {
        let body: serde_json::Value = client
            .request_with(
                reqwest::Method::GET,
                &path,
                Some(&params),
                None::<&()>,
                &options,
            )
            .await?;
        let (data, cursor, has_more) = parse_page(
            &body,
            next_cursor,
            items_field,
            cursor_field,
            has_more_field,
        )?;
        Ok(Page {
            client,
            path,
            params,
            next_cursor,
            items_field,
            cursor_field,
            has_more_field,
            cursor_param,
            options,
            data,
            has_more,
            cursor,
        })
    }

    pub async fn next_page(&self) -> Result<Option<Page<T>>, Error> {
        if !self.has_more {
            return Ok(None);
        }
        let cursor = match &self.cursor {
            Some(c) => c.clone(),
            None => return Ok(None),
        };
        let params = with_cursor(&self.params, self.cursor_param, cursor);
        let page = Self::fetch(
            self.client.clone(),
            self.path.clone(),
            params,
            self.next_cursor,
            self.items_field,
            self.cursor_field,
            self.has_more_field,
            self.cursor_param,
            self.options.clone(),
        )
        .await?;
        Ok(Some(page))
    }

    pub fn into_stream(self) -> impl Stream<Item = Result<T, Error>> {
        async_stream::try_stream! {
            let mut current = self;
            loop {
                let Page { client, path, params, next_cursor, items_field, cursor_field, has_more_field, cursor_param, options, data, has_more, cursor } = current;
                for item in data {
                    yield item;
                }
                let next = match (has_more, cursor) {
                    (true, Some(c)) => c,
                    _ => break,
                };
                let next_params = with_cursor(&params, cursor_param, next);
                current = Page::fetch(client, path, next_params, next_cursor, items_field, cursor_field, has_more_field, cursor_param, options).await?;
            }
        }
    }
}

fn with_cursor(params: &[(String, String)], key: &str, value: String) -> Vec<(String, String)> {
    let mut out: Vec<(String, String)> = params.iter().filter(|(k, _)| k != key).cloned().collect();
    out.push((key.to_string(), value));
    out
}

#[allow(clippy::type_complexity, clippy::result_large_err)]
fn parse_page<T: DeserializeOwned>(
    body: &serde_json::Value,
    next_cursor: NextCursor<T>,
    items_field: &str,
    cursor_field: Option<&str>,
    has_more_field: Option<&str>,
) -> Result<(Vec<T>, Option<String>, bool), Error> {
    let data: Vec<T> = match body.get(items_field) {
        Some(v) => serde_json::from_value(v.clone())?,
        None => Vec::new(),
    };
    let cursor_value = cursor_field
        .and_then(|f| body.get(f))
        .and_then(|v| v.as_str());
    let cursor = next_cursor(&data, cursor_value);
    let has_more = match has_more_field {
        Some(f) => body.get(f).and_then(|v| v.as_bool()).unwrap_or(false),
        None => cursor.is_some(),
    };
    Ok((data, cursor, has_more))
}

#[cfg(test)]
mod pagination_tests {
    use super::*;

    fn passthrough(_items: &[serde_json::Value], cursor: Option<&str>) -> Option<String> {
        cursor.map(str::to_string)
    }

    #[test]
    fn parse_page_is_field_name_agnostic() {
        let shape_a = serde_json::json!({
            "sessions": [{"id": "a"}, {"id": "b"}],
            "nextCursor": "c2"
        });
        let (items, cursor, has_more): (Vec<serde_json::Value>, _, _) =
            parse_page(&shape_a, passthrough, "sessions", Some("nextCursor"), None).unwrap();
        assert_eq!(items.len(), 2);
        assert_eq!(cursor.as_deref(), Some("c2"));
        assert!(has_more);

        let shape_b = serde_json::json!({
            "data": [{"id": "x"}],
            "cursor": serde_json::Value::Null
        });
        let (items, cursor, has_more): (Vec<serde_json::Value>, _, _) =
            parse_page(&shape_b, passthrough, "data", Some("cursor"), None).unwrap();
        assert_eq!(items.len(), 1);
        assert_eq!(cursor, None);
        assert!(!has_more);
    }

    #[test]
    fn parse_page_respects_has_more_field() {
        let body = serde_json::json!({"items": [], "hasMore": true, "nextCursor": "n"});
        let (_items, _cursor, has_more): (Vec<serde_json::Value>, _, _) = parse_page(
            &body,
            passthrough,
            "items",
            Some("nextCursor"),
            Some("hasMore"),
        )
        .unwrap();
        assert!(has_more);
    }
}