jsonrustserdeserde-json

serde deserialize json array into option struct


I am retrieving data from an API that uses pagination. I want to deserialize the pagination object into my own rust struct. These objects look like this:

{
    "offset":0
    , "max":20
    , "size":20
    , "links":[
        {
            "rel":"next"
            , "uri":"https://.../api/v1/endpoint?offset=20"
        }
        , {
            "rel":"prev"
            , "uri":"https://.../api/v1/endpoint"
        }
    ]
}

where offset, max, and size are always given and links has either or both of next or prev links.

I then use the following structs to parse the above json into a Pagination struct:

use serde::*;
use serde_json::Result;

#[derive(Deserialize, Debug)]
#[serde(tag = "rel")]
enum PaginationRef {
    #[serde(alias = "next")]
    Next { uri: Url },
    #[serde(alias = "prev")]
    Prev { uri: Url },
}

// I know the list has at most 2 links
#[derive(Deserialize, Debug)]
struct PaginationLinks(
    #[serde(default)] Option<PaginationRef>,
    #[serde(default)] Option<PaginationRef>,
);

#[derive(Deserialize, Debug)]
pub struct Pagination {
    links: PaginationLinks,
    max: i64,
    offset: i64,
    size: i64,
}

fn main() -> Result<()> {
    let data = r#"
{
    "offset":0
    , "max":20
    , "size":20
    , "links":[
        {
            "rel":"next"
            , "uri":"https://.../api/v1/endpoint?offset=20"
        }
        , {
            "rel":"prev"
            , "uri":"https://.../api/v1/endpoint"
        }
    ]
}
"#;

    let v: Pagination = serde_json::from_str(data)?;

    println!("{:#?}", v);

    Ok(())
}

The problem

I only care about the next link from the json string. I want to simplify the Pagination struct into the following:

#[derive(Deserialize, Debug)]
pub struct Pagination{
    next: Option<Url>,
    max: i64,
    offset: i64,
    size: i64,
}

I have tried to use field attributes to get the result I want, but I don't think they work for this problem. I am guessing I need to write a custom deserializer to turn "links":[...] into an Option<Url>. How can I achieve this?

Edit

Here are the tests I implemented. Both of them pass:

pagination.rs

// Implemented to test correct parsing (insufficient)
impl Pagination {
    pub fn get_next_page_link(&self) -> Option<&Url> {
        if let Some(page) = &self.links.0 {
            let result = match page {
                PaginationRef::Next{uri} => Some(uri),
                _ => None,
            };
            return result;
        }
        return None;
    }
}

pagination/tests.rs

use url::Url;

use super::Pagination;

#[test]
fn test_deserialization_1_link(){
    let pagination_json = r#"
    {
        "offset":0
        , "max":20
        , "size":20
        , "links":[
            {
                "rel":"next"
                , "uri":"https://localhost/api/v1/endpoint?offset=20"
            }
        ]
    }
    "#;
    let result = serde_json::from_str::<Pagination>(pagination_json).unwrap();
    assert_eq!(result.get_next_page_link().unwrap(), &Url::parse("https://localhost/api/v1/endpoint?offset=20").unwrap())
}

#[test]
fn test_deserialization_2_link(){
    let pagination_json = r#"
    {
        "offset":0
        , "max":20
        , "size":20
        , "links":[
            {
                "rel":"next"
                , "uri":"https://localhost/api/v1/endpoint?offset=20"
            }
            , {
                "rel":"prev"
                , "uri":"https://localhost/api/v1/endpoint"
            }
        ]
    }
    "#;
    let result = serde_json::from_str::<Pagination>(pagination_json).unwrap();
    assert_eq!(result.get_next_page_link().unwrap(), &Url::parse("https://localhost/api/v1/endpoint?offset=20").unwrap())
}

Edit 2

I have already accepted @drewtato's answer, but here's my cargo.toml file:

[package]
name = "my_project"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
jsonapi="0.7.0"
serde={"version" = "1.*", "features" = ["derive"]}
serde_json="1.*"
url = { version = "2.*", features = ["serde"] }
reqwest={"version" = "0.11.*", "features" = ["json"]}
tokio={"version" = "1.*", "features" = ["full"]}

Solution

  • You can use your existing types to make a deserialize function. I've removed uri from Prev since that field is never read. This is fine since Serde ignores extra fields by default.

    #[derive(Deserialize, Debug)]
    struct PaginationLinks(
        #[serde(default)] Option<PaginationRef>,
        #[serde(default)] Option<PaginationRef>,
    );
    
    #[derive(Deserialize, Debug)]
    #[serde(tag = "rel", rename_all = "snake_case")]
    enum PaginationRef {
        Next { uri: Url },
        Prev {},
    }
    
    fn links<'de, D>(deserializer: D) -> Result<Option<Url>, D::Error>
    where
        D: Deserializer<'de>,
    {
        let links = PaginationLinks::deserialize(deserializer)?;
        let item = [links.0, links.1]
            .into_iter()
            .flatten()
            .find_map(|opt_pr| match opt_pr {
                PaginationRef::Next { uri } => Some(uri),
                _ => None,
            });
        Ok(item)
    }
    

    And you can use that in your simplified Pagination struct.

    #[derive(Deserialize, Debug)]
    pub struct Pagination {
        #[serde(rename = "links", deserialize_with = "links")]
        next: Option<Url>,
        max: i64,
        offset: i64,
        size: i64,
    }