Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cannot retrieve GraphQLResponses with ErrorExtensions properly #927

Open
bengsparks opened this issue Apr 18, 2024 · 4 comments
Open

Cannot retrieve GraphQLResponses with ErrorExtensions properly #927

bengsparks opened this issue Apr 18, 2024 · 4 comments

Comments

@bengsparks
Copy link
Contributor

The traits for extending the request builders of reqwest, surf, etc. all appear like this:

pub trait ReqwestExt {
    fn run_graphql<ResponseData, Vars>(
            self,
            operation: Operation<ResponseData, Vars>,
        ) -> BoxFuture<'static, Result<GraphQlResponse<ResponseData>, CynicReqwestError>>
        where
            Vars: serde::Serialize,
            ResponseData: serde::de::DeserializeOwned + 'static,
}

Additionally, the definition of a GraphQLResponse looks like this:

pub struct GraphQlResponse<T, ErrorExtensions = serde::de::IgnoredAny> {
    ... 
}

In the first code example, there is no way to specialize the otherwise defaulted ErrorExtensions generic argument of GraphQlResponse.

This implies that attempting to deserialize responses from GraphQL servers, triggered by requests made via these traits, will lead to these extensions being dropped, as per the docs of serde::de::IgnoredAny.
Likely extending the signatures of run_graphql would be sufficient to allow for the additional extension type

I have hacked together an MRE here that demonstrates the issue and provides a new trait to facilitate the desired behaviour with reqwest. However, it would mean a breaking change, and the API isn't as nice as I would have liked. Nonetheless, I hope it helps :)

@obmarg
Copy link
Owner

obmarg commented Apr 18, 2024

Thanks for the report @bengsparks.

This is a tricky one to solve nicely - I could add another generic param to run_graphql but forcing everyone to deal with that for this relatively niche use case isn't ideal. A separate function for users who want this would also be possible but doesn't seem great either...

I'll have a think - in the mean time I would recommend just using your own reqwest etc. integration.

@bengsparks
Copy link
Contributor Author

One suggestion I have is to implement a form of delayed evaluation with a builder-esque structure?
Something that would allow for

let response = client
    // Prepare to send HTTP request to already running GraphQL server
    .get(format!("http://{}/graphql", graphql.host_with_port()))
    // Send request with `run_graphql`, WHICH DOES NOT DESERIALIZE YET!
    // Instead, store the response as e.g. `reqwest::Error<reqwest::Response>` inside of a 
    // DelayedResponse<ResponseData>, which is inferrable from the provided arguments.
    .run_graphql(FieldWithString::build(FieldWithStringVariables {
        input: "InputGoesHere",
    }))
    // Perform JSON deserialisation here upon `DelayedResponse<ResponseData>::retain_extensions<Extensions>`,
    // which contains sufficient type information to deserialise via 
    // `serde_json::from_str::<GraphQlResponse<ResponseData, Extensions>>`
    .retain_extensions::<Extension>();

But ultimately all it does it plug the fact that the *Ext::run_graphql trait methods have a hole in their APIs :/.
Nonetheless, thought I should pass this small idea on, maybe it can be used for something better.

@obmarg
Copy link
Owner

obmarg commented Apr 18, 2024

Ooh, actually that is exactly the right answer, good thinking.

run_graphql should return a builder as you suggest, and then it can impl IntoFuture so that users can still just do run_graphql(...).await if they don't need to call retain_extensions.

I don't going to have time to implement this immediately but that sounds like the way to go.

@bengsparks
Copy link
Contributor Author

bengsparks commented Apr 18, 2024

Glad to hear my suggestion could be of use!

The only reason I was hesitant to propose this is because of blocking reqwest, which is likely used in non-async environments, and therefore cannot be awaited upon.

But perhaps there is a cool solution here too. Or maybe it will have to be the odd one out and require explicit usage. I imagine the majority of cynic's usages are called from async contexts anyhow :)

obmarg added a commit that referenced this issue Apr 28, 2024
#### Why are we making this change?

Partially addresses the needs of #927.

#### What effects does this change have?

Changes the `ReqwestExt` trait to return `CynicReqwestBuilder`.

Calling `CynicReqwestBuilder::retain_extensions::<T>` and `await`ing will
deserialise the `extensions` field using the shape of T.

Directly `await`ing `CynicReqwestBuilder` (i.e. the previous behaviour,
thereby maintaining backwards compatibility) will ignore the contents of the 
"extensions" field.

Adds `tokio = { version = "1", features = ["macros"] }` and `mockito =
"1.4.0"` to dev dependencies to provide an asynchronous mock server for
testing purposes.

`cynic/tests/http.rs` provides a comparison of their usages.

#### Examples:

For a GraphQL server that responds with
```json
{
  "errors": [
    {
      "message": "Unauthorized",
      "locations": null,
      "path": [ "helloWorld" ],
      "extensions": { "code": 401 }
    }
  ]
}
```

##### Including `extensions` with `DelayedResponse::retain_extensions`
```rs
#[derive(Debug, serde::Deserialize)]
struct Extensions {
    code: u16,
}

let output = client
    .get(format!("http://{}/graphql", graphql.host_with_port()))
    .run_graphql(FieldWithString::build(FieldWithStringVariables {
        input: "InputGoesHere",
    }))
    .retain_extensions::<Extensions>()
    .await;
dbg(!&output);
```

Will produce a response with `extensions: Some(Extensions { code: 401 }
)`.

##### Backwards compatible
```rs
let client = reqwest::Client::new();
let output = client
    .get(format!("http://{}/graphql", graphql.host_with_port()))
    .run_graphql(FieldWithString::build(FieldWithStringVariables {
        input: "InputGoesHere",
    }))
    .await;
dbg(!&output);
```

Will produce a response with `extensions: Some(IgnoredAny)`.

---------

Co-authored-by: Benjamin Sparks <[email protected]>
Co-authored-by: Graeme Coupar <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants