spring-graphql icon indicating copy to clipboard operation
spring-graphql copied to clipboard

Customize scalar type serialization

Open benneq opened this issue 2 years ago • 4 comments

Currently it's very hard to use custom Value Objects with ID Scalars. Or in general: to provide custom serialization for any kind of generics / class inheritance.

Schema:

type Query {
    bookById(id: ID): ID
}

Controller:

@Controller
public class BookController {

    @QueryMapping
    Mono<BookId> bookById(@Argument BookId id) {
        return Mono.just(id);
    }

    // this is called for `@Argument BookId` and works as expected
    @Bean
    Converter<String, BookId> stringToBookIdConverter() {
        return new Converter<String, BookId>() {
            @Override
            public BookId convert(String source) {
                return new BookId(source);
            }
        };
    }

    // this is not working
    @Bean
    Converter<BookId, String> bookIdToStringConverter() {
        return new Converter<BookId, String>() {
            @Override
            public String convert(BookId source) {
                return source.value(); // this is not called
            }
        };
    }
}
public record BookId(String value) {
    @Override
    public String toString() {
        return "don't use this for serialization"; // instead this is called by GraphqlIDCoercing.serialize
    }
}

As a workaround I could implement a custom BookId scalar, with a matching scalar coercing, but this will lead to new problems:

interface Node {
  id: ID
}

type Book implements Node {
  id: BookId # ERROR, because the Node interface wants the ID scalar
}

Another workaround would be to use RuntimeWiringConfigurer and override the default ID scalar and provide a custom serialize implementation that doesn't use toString(). But that's hard to use, especially if you want to dynamically provide new classes with custom serialization.

The code in graphql.execution.ExecutionStrategy does not seem to provide a way to customize the serialization. Or maybe I have missed something in the code or the docs.

Is there already a (easy) way to do this?

benneq avatar Jun 13 '22 12:06 benneq

@benneq, GraphQL Java has support for scalar types. Have you tried it? This is also mentioned in the Spring for GraphQL reference docs.

rstoyanchev avatar Jun 17 '22 14:06 rstoyanchev

@rstoyanchev Thanks for the response 😸 (Btw: Great docs and Spring YouTube Series 👍 ) Sure I'm using scalar type coercing. At the moment I use this code to (de)serialize the ID scalar to multiple custom Java Classes:

@QueryMapping
Mono<BookId> bookById(@Argument BookId id) {
  return Mono.just(id);
}

// this will convert the returned MyCustomId (from ID Scalar Corercing parseValue and parseLiteral) to BookId
// so it can be used as @Argument in @QueryMapping
@Bean
Converter<MyCustomId, BookId> myConverter() {
  return new Converter<MyCustomId, BookId>() {
    @Override
    public BookId convert(MyCustomId source) {
      return new BookId(/* convert from MyCustomId */);
    }
  };
}

// Scalar ID Coercing serialize method is used to convert BookId to String
@Bean
RuntimeWiringConfigurer configurer() {
  return builder -> builder.scalar(GraphQLScalarType.newScalar().name("ID").coercing(new Coercing<>() {
    @Override
    public String serialize(Object dataFetcherResult) {
      if (dataFetcherResult instanceof BookId bookId) {
        // custom BookId serialization code
        return "Book:" + bookId.value();
      }
      throw new CoercingSerializeException();
    }

    @Override
    public MyCustomId parseValue(Object input) {
      if (input instanceof String) {
        return new MyCustomId(/* parse input */);
      }
      throw new CoercingParseValueException();
    }

    @Override
    public MyCustomId parseLiteral(Object input) {
      if (input instanceof StringValue stringValue) {
        // parse input to intermediate object, because at this point
        // we have no information about the @Argument type
        return new MyCustomId(/* parse input */);
      }
      throw new CoercingParseLiteralException();
    }
  };
}

It's working fine. My only "issue" is that it's a lot of code and feels a bit strange to have the (de)serialization logic in two completely different places.

If this is the intended way to do this, you can close this issue.

benneq avatar Jun 17 '22 16:06 benneq

@benneq it's not very clear why you need to have both a Spring Converter in addition to the GraphQL scalar type coercion. Aside from that, the Spring code is quite minimal, and most of the rest is the actual logic to do the coercion. I'm not quite sure what you're suggesting that could change, and also whether that would be a change here or in GraphQL Java.

rstoyanchev avatar Jun 23 '22 08:06 rstoyanchev

it's not very clear why you need to have both a Spring Converter in addition to the GraphQL scalar type coercion.

@rstoyanchev I'll try to explain it in more detail:

In my GraphQL schema I only have a single ID scalar, that is used for many different types:

interface Node {
  id: ID!
}

type Book implements Node {
  id: ID!
}

type Author implements Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
  book(id: ID!): Book
  author(id: ID!): Author
}

Those ID scalars have different business meaning, depending on the type they are used in: One is a BookId and the other one is a AuthorId. Though I'd want to use seperate BookId and AuthorId value classes in my Java Code for the single ID scalar.

I have a @SchemaMapping with @Argument BookId like here:

@Controller
public class BookController {
    @QueryMapping
    Mono<Book> book(@Argument BookId id) {
        return Mono.just(new Book(...));
    }
}

The provided default ID scalar impl (GraphqlIDCoercing) will simply transform any given value into a String, but my @Argument expects a BookId. Though this will result in a Failed to convert value of type java.lang.String to required type BookId; no matching editors or conversion strategy found. This can be fixed by providing a Converter that can create BookId objects.

Within the scalar coercing code this can not be done, because there (in the parseLiteral and parseValue methods) I have no information about the target type of the @Argument . That's why I use a Converter, because the Converter runs after the coercing and knows the target type and can construct the matching object.

And the next issue is the ID scalar serialization:

@Controller
public class BookController {
    @QueryMapping
    Mono<BookId> someBookId() {
        return Mono.just(new BookId(...));
    }
}

The provided default ID scalar impl (GraphqlIDCoercing) will simply call toString on any object. Though I provide my own ID scalar serialize implementation to run custom serialization, which behaves differently for BookId and AuthorId and other classes.

This conversion can not be done using Spring Converter, because the scalar's coercing serialize method accepts an arbitrary Object and only after the execution of the method the resulting type is known. (I tried Converter<BookId, String> and Converter<BookId, graphql.language.StringValue>)

That's why I have used both, a custom scalar impl and a Converter. The Converter is used for custom @Argument types. And the custom scalar serialize method is needed, to be able to have custom serialization for different classes. Else it would just use toString on any object, which is not desired.

benneq avatar Jun 23 '22 11:06 benneq