Typed Client: all classes for the possibleTypes have to be present
On the GitLab GraphQL server (no password required), the WorkItemWidget type (kind INTERFACE) has multiple possible types.
You can run a query like this:
query {
workItemsByReference(contextNamespacePath: "gitlab-org",
refs: [
"&9290"
]) {
nodes {
id
iid
title
webUrl
workItemType {
name
}
widgets {
... on WorkItemWidgetLabels {
labels {
nodes {
id
title
}
}
}
... on WorkItemWidgetLinkedItems {
blocked
blockedByCount
blockingCount
linkedItems {
nodes {
linkId
linkType
linkCreatedAt
linkUpdatedAt
workItem {
id
}
}
}
}
}
}
}
}
https://gitlab.com/-/graphql-explorer
With the java typed client, you need to have all the sub-types declared (as empty class), even if you are only interested in two widgets.
import org.eclipse.microprofile.graphql.Name;
import io.smallrye.graphql.api.Union;
import jakarta.json.bind.annotation.JsonbSubtype;
import jakarta.json.bind.annotation.JsonbTypeInfo;
@Union
@JsonbTypeInfo(key = "__typename", value = {
@JsonbSubtype(alias = "WorkItemWidgetAssignees", type = WorkItemWidgetAssignees.class),
@JsonbSubtype(alias = "WorkItemWidgetAwardEmoji", type = WorkItemWidgetAwardEmoji.class),
@JsonbSubtype(alias = "WorkItemWidgetColor", type = WorkItemWidgetColor.class),
@JsonbSubtype(alias = "WorkItemWidgetCrmContacts", type = WorkItemWidgetCrmContacts.class),
@JsonbSubtype(alias = "WorkItemWidgetCurrentUserTodos", type = WorkItemWidgetCurrentUserTodos.class),
@JsonbSubtype(alias = "WorkItemWidgetDescription", type = WorkItemWidgetDescription.class),
@JsonbSubtype(alias = "WorkItemWidgetDesigns", type = WorkItemWidgetDesigns.class),
@JsonbSubtype(alias = "WorkItemWidgetDevelopment", type = WorkItemWidgetDevelopment.class),
@JsonbSubtype(alias = "WorkItemWidgetHealthStatus", type = WorkItemWidgetHealthStatus.class),
@JsonbSubtype(alias = "WorkItemWidgetHierarchy", type = WorkItemWidgetHierarchy.class),
@JsonbSubtype(alias = "WorkItemWidgetIteration", type = WorkItemWidgetIteration.class),
@JsonbSubtype(alias = "WorkItemWidgetLabels", type = WorkItemWidgetLabels.class),
@JsonbSubtype(alias = "WorkItemWidgetLinkedItems", type = WorkItemWidgetLinkedItems.class),
@JsonbSubtype(alias = "WorkItemWidgetMilestone", type = WorkItemWidgetMilestone.class),
@JsonbSubtype(alias = "WorkItemWidgetNotes", type = WorkItemWidgetNotes.class),
@JsonbSubtype(alias = "WorkItemWidgetNotifications", type = WorkItemWidgetNotifications.class),
@JsonbSubtype(alias = "WorkItemWidgetParticipants", type = WorkItemWidgetParticipants.class),
@JsonbSubtype(alias = "WorkItemWidgetRolledupDates", type = WorkItemWidgetRolledupDates.class),
@JsonbSubtype(alias = "WorkItemWidgetStartAndDueDate", type = WorkItemWidgetStartAndDueDate.class),
@JsonbSubtype(alias = "WorkItemWidgetStatus", type = WorkItemWidgetStatus.class),
@JsonbSubtype(alias = "WorkItemWidgetTimeTracking", type = WorkItemWidgetTimeTracking.class),
@JsonbSubtype(alias = "WorkItemWidgetWeight", type = WorkItemWidgetWeight.class)
})
@Name("WorkItemWidget")
public interface WorkItemWidget {
}
If one is missing (as in this example) you get this stacktrace:
java.lang.RuntimeException: SRGQLDC035010: Cannot instantiate WorkItemWidgetEmailParticipants
at io.smallrye.graphql.client.impl.typesafe.reflection.TypeInfo.lambda$subtype$9(TypeInfo.java:474)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at io.smallrye.graphql.client.impl.typesafe.reflection.TypeInfo.subtype(TypeInfo.java:474)
at io.smallrye.graphql.client.impl.typesafe.json.JsonObjectReader.readObject(JsonObjectReader.java:32)
at io.smallrye.graphql.client.impl.typesafe.json.JsonObjectReader.read(JsonObjectReader.java:27)
at io.smallrye.graphql.client.impl.typesafe.json.JsonReader.read(JsonReader.java:62)
at io.smallrye.graphql.client.impl.typesafe.json.JsonReader.readJson(JsonReader.java:37)
at io.smallrye.graphql.client.impl.typesafe.json.JsonArrayReader.readItem(JsonArrayReader.java:41)
at io.smallrye.graphql.client.impl.typesafe.json.JsonArrayReader.lambda$read$0(JsonArrayReader.java:33)
Setting allowUnexpectedResponseFields does not help here.
WorkitemClientApi gqlApi = TypesafeGraphQLClientBuilder.newBuilder()
.endpoint("https://gitlab.com/api/graphql")
.header("Authorization", "Bearer " + gitlabToken) //no needed for the discussed read query
.allowUnexpectedResponseFields(true)
.build(WorkitemClientApi.class);
Complete project: https://github.com/jmini/gitlab-experiments/tree/main/smallrye-graphql-client
I am not sure if something can be done about this, but I wanted to share this with you (and it might help other users).
Also I don't know if the error message SRGQLDC035010 is used somewhere else, but it would be better to say:
Cannot instantiate
WorkItemWidgetEmailParticipants(not defined as sub-type ofWorkItemWidget)
To help developers to understand what is going on.
The fix is simple for this error is simple.
For my example project: https://github.com/jmini/gitlab-experiments/commit/7a4edce7cb3e530983054cebd66508bf294ee468
We could theoretically ignore unknown subtypes, but it doesn't sound very safe to me, it could lead to unexpected issues, for example, if the user wrongly declares the name of the type, then the object in the response would be silently skipped. The improved exception message makes sense to me...
@t1 any opinion on this?
We could theoretically ignore unknown subtypes, but it doesn't sound very safe to me, it could lead to unexpected issues, for example, if the user wrongly declares the name of the type, then the object in the response would be silently skipped.
Yes I am also not sure about what is correct.
What is sure with the current pattern:
-
when GitLab is adding a new widget type, this breaks our client until we add something like https://github.com/jmini/gitlab-experiments/commit/7a4edce7cb3e530983054cebd66508bf294ee468
-
I had to add many empty classes just to make the Json de-serialization happy, even if in my case (at graphQL query level) I am only interested in
... on WorkItemWidgetLabelsand... on WorkItemWidgetLinkedItems. (I could have reused aWorkItemWidgetDummyand mapped all the cases I am not interested in to this class, but the@JsonbSubtypehas to be complete, for each of the possible__typenamevalues)
Maybe the Server API design is not ideal:
The WorkItem Type (kind OBJECT) have this field:
widgets[WorkItemWidget!] Collection of widgets that belong to the work item.
Without any filtering possibility. and the WorkItemWidget has tons of subtypes.
Do I get this right: it would work, if you would only receive the types you are interested in? In other words: it only fails, if you actually receive a type that you don't expect? So you want to filter some types on the client side. That feels to be going against a design goals of GraphQL: the client specifies exactly what they want.
So I think it would be better (for the world, not necessarily for you at this moment ;-), if the API would allow that type of filtering on the widgets field... something like widgets(typeFilter: ["WorkItemWidgetLabels", "WorkItemWidgetLinkedItems"]). That would also reduce the amount of data that the server had to produce and transport to the client. I have no idea how big these things can be, but it looks like this could make a relevant difference... even without fixing the problems you have. Maybe you can suggest that to GitLab?
Despite me being reluctant to add this, I've looked at the Typesafe Client code: the change would actually be quite simple. The TypeInfo#subtype method could return an Optional, and readObject method could ignore an empty value. If we actually do decide to do that, I think we should absolutely make this an opt-in with a new config option.
Do you understand or even agree with my point of view?
BTW: I completely agree on improving on the error message.
So I think it would be better (for the world, not necessarily for you at this moment ;-), if the API would allow that type of filtering on the
widgetsfield... something likewidgets(typeFilter: ["WorkItemWidgetLabels", "WorkItemWidgetLinkedItems"]).
I think we all agree on that point, but the GitLab server is what it is. Because you asked, I will open the issue in their tracker, but I am not sure they will change their design.
I will not try to defend their pattern (that seems to be very UI/frontent driven at the end), but I don't think that there is too much data transported, since only the ... on WorkItemWidgetXXXX are contributing to the response. If you check my example query, you will see in the response:
"widgets": [
{},
{},
{},
{
... content of WorkItemWidgetLabels
},
{},
{},
{},
{},
{},
{},
{
... content of WorkItemWidgetLinkedItems
},
{},
{},
{},
{},
{},
{}
]
I agree that those {} are noisy, but they are not generating too much data.
For me this discussion is about the deserialization at client side.
Currently we need one entry for each of the possible __typename value that is use as key to discriminate between the subtypes.
I think we should absolutely make this an opt-in with a new config option.
For me this would go in the same direction as .allowUnexpectedResponseFields(true). Something you do not really need an idealistic word.
With that new config set to true my goal would be an attempt to make the client more robust and ignore those empty {} objects. I could even reduce my model to:
@Union
@JsonbTypeInfo(key = "__typename", value = {
@JsonbSubtype(alias = "WorkItemWidgetLabels", type = WorkItemWidgetLabels.class),
@JsonbSubtype(alias = "WorkItemWidgetLinkedItems", type = WorkItemWidgetLinkedItems.class)
})
@Name("WorkItemWidget")
public interface WorkItemWidget {
}
--> which is great, because I only need those two case.
Without this config, I have to fix the client like this https://github.com/jmini/gitlab-experiments/commit/7a4edce7cb3e530983054cebd66508bf294ee468 on each schema change (each addition of a new Widget sub-type is breaking)
But indeed, for users that have more control of the GraphQL server, this new config would not be necessary.
I can understand if you reject this idea of introducing a config. Then this would just be a known fact and users like me needs to update their model, which is fine (maybe this GitLab use-case is a corner case)
In all cases, the error message can be improved to help identifying which sub-type declaration is missing in which interface.
Of course! You're right. The "filtering" already happens (practically) via the fragments you specify; the empty {}s are just a bit ugly.
So forget what I've said before. This is a useful feature. Do you think you could create a PR for this?
I am not yet really familiar with the code base. I would need some pointers:
- can the signature of
io.smallrye.graphql.client.impl.typesafe.reflection.TypeInfo.subtype(String)be changed toOptional<TypeInfo> - where are you defining the config and reading them?
- where are the tests? And how do you extend them (is there a backend somewhere)?
sorry for the delay...
- can the signature of io.smallrye.graphql.client.impl.typesafe.reflection.TypeInfo.subtype(String) be changed to Optional<TypeInfo>
yes, sure.
- where are you defining the config and reading them?
We use MP Config. As a lot of config keys depend on a logical API client name (a.k.a. configKey) or API interface name, they like my-api/mp-graphql/url. But I'm not sure if we want that for this, too. What do you think?
- where are the tests? And how do you extend them (is there a backend somewhere)?
The tests live in the client/tck module. They end with Behavior instead of Test, which is more BDD like. Maybe UnionBeharior is a good test class to add your test.
At the end, my current thinking is that the reason for this issue is a limitation in jsonb. https://github.com/jakartaee/jsonb-api/issues/368
With jackson it would be possible to deserialize the GraphQL result.
@Union
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "__typename", defaultImpl = EmptyWidget.class)
@JsonSubTypes({
@JsonSubTypes.Type(value = WorkItemWidgetLabels.class, name = "WorkItemWidgetLabels"),
@JsonSubTypes.Type(value = WorkItemWidgetLinkedItems.class, name = "WorkItemWidgetLinkedItems")
})
@Name("WorkItemWidget")
public interface WorkItemWidget {
}
Where EmptyWidget configured as defaultImpl in the @JsonTypeInfo would catch all the cases that are not known by the graphql client during deserialisation of the graphql answer.
Standalone example using jsonb (JsonbException: unmarshalling error)
Does not run, jakarta.json.bind.JsonbException: Cannot infer a type for unmarshalling into: snippet.SnippetJsonb$Animal
import jakarta.json.bind.Jsonb;
import jakarta.json.bind.JsonbBuilder;
import jakarta.json.bind.JsonbException;
import jakarta.json.bind.annotation.JsonbSubtype;
import jakarta.json.bind.annotation.JsonbTypeInfo;
public class SnippetJsonb {
@JsonbTypeInfo(key = "__typename", value = {
@JsonbSubtype(alias = "dog", type = Dog.class),
@JsonbSubtype(alias = "cat", type = Cat.class)
})
public static interface Animal {
}
public static final class Dog implements Animal {
public boolean isDog = true;
@Override
public String toString() {
return "Dog [isDog=" + isDog + "]";
}
}
public static final class Cat implements Animal {
public boolean isCat = true;
@Override
public String toString() {
return "Cat [isCat=" + isCat + "]";
}
}
public static void main(String[] args) {
// Create a Jsonb instance
Jsonb jsonb = JsonbBuilder.create();
// Create instances of Dog and Cat
Dog myDog = new Dog();
Cat myCat = new Cat();
// Serialize Dog instance to JSON
try {
String dogJson = jsonb.toJson(myDog);
System.out.println("Serialized Dog JSON: " + dogJson);
// Serialize Cat instance to JSON
String catJson = jsonb.toJson(myCat);
System.out.println("Serialized Cat JSON: " + catJson);
// Deserialize JSON (polymorphic deserialization) Dog
Animal deserializedAnimalFromDogJson = jsonb.fromJson("{\"__typename\":\"dog\"}", Animal.class);
System.out.println("Deserialized Dog: " + deserializedAnimalFromDogJson);
System.out.println("Deserialized Animal (instance of Dog check): " + (deserializedAnimalFromDogJson instanceof Dog));
// Deserialize JSON (polymorphic deserialization) Cat
Animal deserializedAnimalFromCatJson = jsonb.fromJson("{\"__typename\":\"cat\"}", Animal.class);
System.out.println("Deserialized Cat: " + deserializedAnimalFromCatJson);
System.out.println("Deserialized Animal (instance of Cat check): " + (deserializedAnimalFromCatJson instanceof Cat));
// Deserialize JSON (default impl)
Animal a = jsonb.fromJson("{}", Animal.class);
System.out.println("Deserialized Animal: " + a);
} catch (JsonbException e) {
e.printStackTrace();
}
}
}
Standalone example using Jackson
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class SnippetJackson {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "__typename", defaultImpl = Dog.class)
@JsonSubTypes({
@JsonSubTypes.Type(value = Dog.class, name = "dog"),
@JsonSubTypes.Type(value = Cat.class, name = "cat")
})
public static interface Animal {
}
public static final class Dog implements Animal {
public boolean isDog = true;
@Override
public String toString() {
return "Dog [isDog=" + isDog + "]";
}
}
public static final class Cat implements Animal {
public boolean isCat = true;
@Override
public String toString() {
return "Cat [isCat=" + isCat + "]";
}
}
public static void main(String[] args) {
// Create an ObjectMapper instance
ObjectMapper objectMapper = new ObjectMapper();
// Create instances of Dog and Cat
Dog myDog = new Dog();
Cat myCat = new Cat();
// Serialize Dog instance to JSON
try {
String dogJson = objectMapper.writeValueAsString(myDog);
System.out.println("Serialized Dog JSON: " + dogJson);
// Serialize Cat instance to JSON
String catJson = objectMapper.writeValueAsString(myCat);
System.out.println("Serialized Cat JSON: " + catJson);
// Deserialize JSON (polymorphic deserialization) Dog
Animal deserializedAnimalFromDogJson = objectMapper.readValue("{\"__typename\":\"dog\"}", Animal.class);
System.out.println("Deserialized Dog: " + deserializedAnimalFromDogJson);
System.out.println("Deserialized Animal (instance of Dog check): " + (deserializedAnimalFromDogJson instanceof Dog));
// Deserialize JSON (polymorphic deserialization) Cat
Animal deserializedAnimalFromCatJson = objectMapper.readValue("{\"__typename\":\"cat\"}", Animal.class);
System.out.println("Deserialized Cat: " + deserializedAnimalFromCatJson);
System.out.println("Deserialized Animal (instance of Cat check): " + (deserializedAnimalFromCatJson instanceof Cat));
// Deserialize JSON (default impl)
Animal a = objectMapper.readValue("{}", Animal.class);
System.out.println("Deserialized Animal: " + a);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
if the API would allow that type of filtering on the widgets field... something like
widgets(typeFilter: ["WorkItemWidgetLabels", "WorkItemWidgetLinkedItems"]).
I think GitLab has made the same observation. The widgets fields now provides ways to indicate to the server which type of widgets are needed.
See: https://docs.gitlab.com/api/graphql/reference/#workitemwidgets
Which make again the idea of having a @ConstantParameter annotation very attractive (see https://github.com/smallrye/smallrye-graphql/discussions/2213)