GraphQL SPQR extend mutation parameters of an input object

2.3k Views Asked by At

Being able to extend existing types is brilliant because it allows modularisation of the code and separation of privilege. I have found good examples as to how to extend objects outputs in query (see below), but no good way to extend what inputs a given object has.

For the sake of the example lets say we have a class User.

class User {
    String firstName;
    String lastName;
}

If we declare a bean, we can have a query like so:

/**
 * This is valid and can be invoked using
 * query {
 *     user(id=1) {
 *         firstName
 *         lastName
 *     }
 * }
 */
@GraphQLQuery(name = "user")
public User getUser(@GraphQLArgument(name = "id") long id) {

}

Then in another bean bean we can extend the User

    /**
     * <<this currently works>>
     * So now this query becomes valid
     * query {
     *     user(id=1) {
     *        firstName
     *        lastName
     *        address {    <-- this is not a top level, but extends User
     *            streetNam
     *        }
     *     }
     * }
     */
    @GraphQLQuery(name = "address")
    public Address getUserAddress(@GraphQLContext User) {

    }

Similarly for mutation, we can define:

    /**
     * <<this currently works>>
     * This can be invoked using:
     * mutation {
     *     addUser(user :{
     *         firstName: "John"
     *         lastName: "Smith"
     *      })
     *      fistName
     * }
     */
     @GraphQLMutation(name = "addUser")
     public User addUser(@GraphQLArgument(name = "user") User user) {

     }

Now I am trying to add address, in the same manner we added it for query, but add be an input argument of User. The following is still declared in some bean.

    /**
     * << this is what I am trying to achieve>>
     * I want to be able to invoke the following query and not having to declare 'Address' inside of 'User' class.
     * mutation {
     *  addUser(user :{
     *      firstName: "John"
     *      lastName: "Smith"
     *      address: {    <-- being able to pass address as argument now, and be part of user.
     *          streetName: "1 str"
     *      }
     *      })
     *        fistName
     *  }
     */
    // e.g. something like ...
    @GraphQLInputField(name = "address")
    public void addAddressToUser(@GraphQLContext User user, @GraphQLArgument Address address) {

    }
1

There are 1 best solutions below

2
On

I came up with a way you can do this currently, but it requires some effort.
I'm using GraphQL-SPQR 0.9.8 (that I'll release in a matter of days). You can achieve the same in 0.9.7, it's just a little less ergonomic.

@Test
public void testSchema() {
    GraphQLSchema schema = new GraphQLSchemaGenerator()
            // Ignore extra fields (like "address") when deserializing
            .withValueMapperFactory(JacksonValueMapperFactory.builder()
                    .withConfigurers(conf -> conf.getObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false))
                    .build())
            .withInputFieldBuilders((conf, defaults) -> defaults.prepend(new UserInputBuilder(defaults.getFirstOfType(JacksonValueMapper.class))))
            .withArgumentInjectors(new UserInjector())
            .withOperationsFromSingleton(new TestService())
            .generate();

    GraphQL exe = GraphQL.newGraphQL(schema).build();
    ExecutionResult result = exe.execute("{user(in: {name: \"A. Man\", address: {type: \"home\", street: {name: \"Fakestreet\", number: 123}}}) {name, street {number}}}");
}


public class TestService {

    @GraphQLQuery //or mutation, no difference
    public User user(User in) {
        return in;
    }
}

// Redefines how User objects are deserizalized
public static class UserInjector extends InputValueDeserializer {

    @Override
    public Object getArgumentValue(ArgumentInjectorParams params) {
        User user = (User) super.getArgumentValue(params);
        Map<?, ?> rawInput = (Map<?, ?>) params.getInput();
        Address address = params.getResolutionEnvironment().valueMapper.fromInput(rawInput.get("address"), GenericTypeReflector.annotate(Address.class));
        // Preprocess the address in any way you need, here I just extract the street
        user.setStreet(address.getStreet());
        return user;
    }

    @Override
    public boolean supports(AnnotatedType type) {
        return GenericTypeReflector.isSuperType(User.class, type.getType());
    }
}

//Redefines the way User input type is mapped
public static class UserInputBuilder implements InputFieldBuilder {

    private final InputFieldBuilder original;

    public UserInputBuilder(InputFieldBuilder original) {
        this.original = original;
    }

    @Override
    public Set<InputField> getInputFields(InputFieldBuilderParams params) {
        Set<InputField> fields = original.getInputFields(params);
        // Add the extra "address" field you want
        fields.add(new InputField("address", "User's home address", GenericTypeReflector.annotate(Address.class), null, null));
        return fields;
    }

    @Override
    public boolean supports(AnnotatedType type) {
        return GenericTypeReflector.isSuperType(User.class, type.getType());
    }
}

public class User {

    private String name;
    private Street street;

    public User(String name, Street street) {
        this.name = name;
        this.street = street;
    }

    public String getName() {
        return name;
    }

    public Street getStreet() {
        return street;
    }

    @GraphQLIgnore //can be filtered out in a different way, without touching this class
    public void setStreet(Street street) {
        this.street = street;
    }
}

public class Address {

    private Street street;
    private String type;

    public Address(Street street, String type) {
        this.street = street;
        this.type = type;
    }

    public Street getStreet() {
        return street;
    }

    public String getType() {
        return type;
    }
}

That said, it would maybe be easier to just register a custom deserializer in Jackson (or Gson, whatever you're using) and skip the custom ArgumentInjector.