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

CBOR as response serialization #791

Open
grantperry opened this issue Oct 19, 2020 · 20 comments
Open

CBOR as response serialization #791

grantperry opened this issue Oct 19, 2020 · 20 comments
Labels

Comments

@grantperry
Copy link
Contributor

I'm working in an embedded platform with low processing power and the need for very efficient data transfer, hence I have been using CBOR as the response serialization instead of JSON. This works very well and haven't come encountered any bugs. What I am trying to do now is use the more expansive Scalar type of CBOR instead of the default juniper Scalar.

I understand that I will likely need to impl ScalarValue on the CBOR Scalar, but this defeats the purpose of using CBOR for its more concise binary format, as I would have to convert all the concise scalar values to the primitive representation that GraphQL offers.

What I am trying to find out, is if there is a way of overriding the strict four type response of GraphQL? Big ask I know 😁

Look forward to your help!

@tyranron
Copy link
Member

tyranron commented Oct 20, 2020

@grantperry

if there is a way of overriding the strict four type response of GraphQL?

No, they're declared by GraphQL spec. If you override the spec, then you'll have something else, but not the GraphQL.

But... defining your own custom ScalarValue doesn't restrict with DefaultScalarValue types. It's opposite, in fact. You can declare your custom GraphQL scalars and control the parsing/representation with your CborScalarValue the way it never touches DefaultScalarValue. I mean, you can parse numbers from &str directly into i128 instead of using i32 in-between, and vice-versa.

@kranfix
Copy link

kranfix commented Oct 22, 2020

@tyranron GraphQL use typically JSON but could use anything. The server response and the client variables could be serialized in CBOR too. In fact I'm looking for this feature too for IoT applications.
 

https://graphql.org/learn/best-practices/#json-with-gzip

To use, you would need enable a features=[..,"CBOR",...] in the cargo.toml and in the HTTP header send "Content-Type": "application/cbor".

This feature will reduce the communication latency.

@grantperry
Copy link
Contributor Author

Thanks @tyranron and @kranfix!

I will have to give outputting values without coercing them into the GraphQL types a little hack. Might need some more assistance on that.

@kranfix You might be interested in our solution. I guess you could say I'm working on IoT. A HTTP server was too heavy for our platform, so we instead use plain UDP, with GraphQL request and CBOR response. much lighter than requiring a Webserver.

@grantperry
Copy link
Contributor Author

I think I've found my issue @tyranron.

pub fn execute<'a, S, CtxT, QueryT, MutationT>(
    document_source: &'a str, 
    operation_name: Option<&str>, 
    root_node: &'a RootNode<QueryT, MutationT, S>, 
    variables: &Variables<S>, 
    context: &CtxT
) -> Result<(Value<S>, Vec<ExecutionError<S>>), GraphQLError<'a>> where
    S: ScalarValue,
    &'b S: ScalarRefValue<'b>,
    QueryT: GraphQLType<S, Context = CtxT>,
    MutationT: GraphQLType<S, Context = CtxT>, 

The requirement on S: ScalarValue forces me to convert my internal representation to GraphQLType. I'm a little confused, were you saying this initially?

The requirement of S: ScalarValue is what makes this a GraphQL implementation I guess :sad:

@kranfix
Copy link

kranfix commented Oct 22, 2020

@grantperry Sometimes I use GQL in MQTT or NATS for small queries. It also could be UDP to speed up the data transfer, but it is more used in local networks where the probability of data corruption is much less.
In that sense, I'm planning to try [quiche](https://docs.rs/quiche/0.5.1/quiche/) but there are not enough libraries for all platforms (for example microcontrollers).

@grantperry
Copy link
Contributor Author

@kranfix I will have a look into quiche. I've only heard about QUIC/h3 over last week or so. quiche doesn't look particularly idiomatic yet, but I like what it has to offer.

@kranfix
Copy link

kranfix commented Oct 22, 2020

@grantperry @tyranron the solution is not changing the Juniper core, but also the http adaptors (juniper_rocket, juniper_warp, etc).

In the end, all adaptors call to juniper::execute after parsing JSON data, then it would be a good option to parse that variables from CBOR.

@grantperry, with this change, it is possible to send  data in CBOR format without a CborScalarValue.
But now you must do it manually, but it would be woderful if the adaptors could do it with only changing a parameter in the http header indicating to the server to parse variable and returning data in JSON or CBOR, and letting everyone to choose it's preferred format.

let (res, _errors) = juniper::execute(
    "query user($id: String!){
      user(id: $id){
        id,
        name
        friends {
          name
        }
      }
    }",
    None,
    &schema,
    // parsed variables: This comes from JSON but could come from another format like CBOR
    &vec![("id".to_owned(), InputValue::scalar("xyz"))]
      .into_iter()
      .collect(),
    &ctx,
  )
  .unwrap();

@grantperry
Copy link
Contributor Author

@kranfix I'm not sure were on the same wavelength? I'm not worried about the Request being in CBOR. Only the Response... This is my Request handler. The works perfectly fine, but the only scalar types returned by juniper::execute are the four defined in GraphQLType

if let Ok((size, peer)) = socket.recv_from(&mut buf) {
    if let Ok(query) = String::from_utf8(buf[0..size].to_vec()) {
        let resp = match execute(
            &query,
            None,
            &self.root_node,
            &Variables::new(),
            &self.context,
        ) {
            Ok((val, errs)) => {
                serde_cbor::to_vec(&CborGQLResponse {
                    data: val,
                    errors: errs,
                })
                .unwrap()
            }
            Err(e) => serde_cbor::to_vec(&CborGQLErrors { errors: e }).unwrap(),
       };
       if let Err(e) = socket.send_to(&resp, &peer) {
           error!("Failed to send udp response: {:?}", e);
       };
    }
}

@kranfix
Copy link

kranfix commented Oct 22, 2020

@grantperry yes, but if it can be serialized when the server response, it also can be deserialize when the client make a request. For IoT is important both cases.

I will make some experiments with cbor::Value, serder_json::Value and juniper::Value in the following days to make a stronger proposal.

@tyranron
Copy link
Member

tyranron commented Oct 23, 2020

@grantperry a GraphQLType is not a type, but a trait. It's intended to allow your custom Rust types to act like GraphQL types. You build the whole GraphQL schema by implementing GraphQLTypes. GraphQLType itself is not related to your problem.

After performing juniper::execute you have Value<S: Scalar>. This type represents a GraphQL value (according to the GraphQL spec) consisted of GraphQL primitives, where the actual GraphQL primitives are represented with a ScalarValue implementation.

And given a custom CborScalarValue and a MyInt GraphQL scalar, you may implement it in the following way:

struct MyInt(i32);

#[graphql_scalar]
impl GraphQLScalar for MyInt {
    fn resolve(&self) -> Value<CborScalarValue> {
        Value::scalar(self.0)  // this is default one,
        // but, surely, you can do here any direct conversions 
        // into `CborScalarValue` from your `MyInt` type
    }

    fn from_input_value(v: &InputValue<CborScalarValue>) -> Option<MyInt> {
        v.as_scalar_value::<i32>().map(|i| MyInt(*i))
        // Same here, you may use all the power of `CborScalarValue`
        // to convert directly into your `MyInt` type.
    }

    fn from_str<'a>(value: ScalarToken<'a>) -> ParseScalarResult<'a, CborScalarValue> {
        <i32 as ParseScalarValue<CborScalarValue>>::from_str(value)
        // And here we define how the `CborScalarValue` should be parsed from a `&str`
        // for this particular `MyInt` case.
    }
}

So, with a ScalarValue you have the full controll over how your values are parsed, converted into/from user types, and encoded back, so you may omit any in-between misrepresentantions.

@kranfix
Copy link

kranfix commented Oct 23, 2020

It is not necessary to modify it. The GraphQLResponse must be serialized as cbor instead of json in the juniper-rocket crate for example. I'm working on it. I already modified the Graphiql to read cbor. Let me complete the example and I'll share my fork.

@davidpdrsn
Copy link
Contributor

I'm wondering if #782 hinder stuff like this?

@kranfix
Copy link

kranfix commented Oct 23, 2020

@davidpdrsn no. It is independent the GraphQLResponse's CBOR serialization

@kranfix
Copy link

kranfix commented Oct 23, 2020

I did it! It works!
As I mentioned, it is not necessary to change Juniper, but the adaptor.

In this case, I modified the juniper-rocket-async package adding an optional callback.
 

type OptionlSerializer<S = DefaultScalarValue> = Option<fn(&GraphQLBatchResponse<S>) -> String>;

impl<S> GraphQLRequest<S>
where
    S: ScalarValue,
{
    pub fn execute_sync<CtxT, QueryT, MutationT, SubscriptionT>(
        &self,
        root_node: &RootNode<QueryT, MutationT, SubscriptionT, S>,
        context: &CtxT,
        
        // HERE IS THE OPTIONAL CALLBACK
        serializer: &OptionlSerializer<S>,
    ) -> GraphQLResponse;
    
    pub async fn execute<CtxT, QueryT, MutationT, SubscriptionT>(
        &self,
        root_node: &RootNode<'_, QueryT, MutationT, SubscriptionT, S>,
        context: &CtxT,
        // HERE IS THE OPTIONAL CALLBACK AGAIN.
        serializer: &OptionlSerializer<S>,
    ) -> GraphQLResponse
}

And how is the callback invoked?
In the implementation of both methods, replace the let json = serde_json::to_string(&response).unwrap() by this:

let json = match serializer {
            Some(ser) => ser(&response),
            None => serde_json::to_string(&response).unwrap(),
        };

GraphQLResponse(status, json)

And in the example, we could define the callback.

Now, It only is missing to read if the Content-Type is application/json or application/cbor to choose which callback to use, or enabling a juniper::http to set serialization methods not only for json or cbor, by any another serialization format. For example, I'm planning to mix GraphQL with a gRPC serialization, but it is not possible if there's no an easy way to customize the format.

@tyranron I hope this approach could help to make juniper more customizable.

@kranfix
Copy link

kranfix commented Oct 23, 2020

I forgot to put the reference to the code: https://github.com/kranfix/juniper/tree/cbor

@LegNeato
Copy link
Member

I would love to handle this in a generic way. The core is explicitly json agnostic but the webserver integration crates were hardcoded to json as that is what 99% of people will use and the crates themselves are basically adapters that could be reimplemented.

@kranfix
Copy link

kranfix commented Oct 30, 2020

@LegNeato yes, It should be agnostic, but the current limitation is the lack of customization.

@Erik1000
Copy link

Erik1000 commented Aug 1, 2021

Any updates on that?

@kranfix
Copy link

kranfix commented Aug 11, 2021

@Erik1000 I also want an update for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

7 participants