obmarg / cynic

A bring your own types GraphQL client library for Rust
https://cynic-rs.dev
Mozilla Public License 2.0
354 stars 45 forks source link

Custom (de)serializers #782

Open djrodgerspryor opened 9 months ago

djrodgerspryor commented 9 months ago

Thanks for making cynic: it's great!

I'm currently using it to talk to an API which works with non-rfc3339 timestamps (they look like: 2023-09-27T18:42:31 - note the lack of an offset at the end). I've added chrono::DateTime as a scalar type as per the docs:

cynic::impl_scalar!(chrono::DateTime<chrono::Utc>, schema::ISODateTime);

But the lack of an offset makes chrono grumpy when parsing these values. Normally I'd handle this with a custom deserialize_with helper in serde. Something like:

fn generous_datetime_parser<'de, D>(
    deserializer: D,
) -> Result<chrono::DateTime<chrono::Utc>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    use serde::Deserialize;

    let value = String::deserialize(deserializer)?;

    chrono::DateTime::parse_from_rfc3339(value.as_str())
        // Try adding a timezone (assume UTC) since those are often missing
        .or_else(|_| chrono::DateTime::parse_from_rfc3339(format!("{}+00:00", &value).as_str()))
        // Try adding a time component in case we were just given a date
        .or_else(|_| {
            chrono::DateTime::parse_from_rfc3339(format!("{}T00:00:00+00:00", &value).as_str())
        })
        .map_err(|e| {
            serde::de::Error::custom(format!(
                "Unable to parse chrono::DateTime from string ({}): {:?}",
                value, e
            ))
        })
        .map(|dt| dt.with_timezone(&chrono::Utc))
}

...

#[derive(cynic::QueryFragment, Debug, serde::Serialize)]
#[cynic(schema = "my_schema")]
struct Foo {
    #[serde(deserialize_with = "generous_datetime_parser")]
    created: chrono::DateTime<chrono::Utc>,
}

Using the deserialize_with serde option as above actually compiles, but it seems to do nothing, I'm guessing because of how cynic handles deserializing the scalar types internally. Is there already a supported way to do this in cynic, or at least a workaround to hook into the deserialization logic?

obmarg commented 9 months ago

Hey @djrodgerspryor - thanks for the report.

The best way to handle this at the moment is probably to use a custom newtype for those fields:

pub struct ISODateTime(chrono::DateTIme<chrono::Utc>);

impl Serialize for ISODateTime {
   // Put your custom serialize code here
}

impl Deserialize<'static> for ISODateTime {
   // Put your custom serialize code here
}

cynic::impl_scalar!(ISODateTime, schema::ISODateTime);

Does that work for you or is it a poor workaround?

I'm not against looking into alternatives if not. Which might be similar to deserialize_with from serde, though I would really like a way to tie it to the type so you don't have to repeat yourself everywhere you use one of these fields.

djrodgerspryor commented 8 months ago

Thanks! The newtype approach is a decent workaround (and let me do some other things like converting GQL strings into structured rust data). I do think it would makes sense for cynic to move away from requiring newtypes though in the spirit of using your own — fully controlled — struct and not a GQL-specific struct like other crates require.

I haven't dug into the internals to check the feasibility of this, but would it be possible to expose a BYO-(De)Serialize variant of cynic::QueryFragment? That way, the user could opt into using serde directly, and automatically get all of it's features (deserialize_with, rename_all etc.) or just implement totally custom serialization logic if needed.