Seit Spring Boot 2.7.0 unterstützt das Framework GraphQL mit einer „nativen“ Integration, die auf GraphQL Java basiert. Wer zuvor die Kickstart-Variante genutzt hat, erfährt hier in knappen Zeilen, wie man seine Anwendung migriert.
Konfiguration
Der einfachste Teil. Die Konfigurationsparameter sind nun Teil des Spring-Namensraums. Die beiden wichtigsten Parameter am Besipiel:
Kickstart | Spring Boot |
graphql.servlet.mapping | spring.graphql.path |
graphiql.mapping | spring.graphql.graphiql.path |
QueryResolver
In Spring Boot läuft alles über @Controller
statt über Resolver und es kommen vermehrt Annotationen zum Einsatz. Hier ein einfaches Beispiel:
// Kickstart
@Component
public class Query implements GraphQLQueryResolver {
private final EntityManager entityManager;
public Marktpartner marktpartner(Long id) {
return entityManager.find(Marktpartner.class, id);
}
}
// Spring Boot
@Controller
public class Query {
private final EntityManager entityManager;
@QueryMapping
public Marktpartner marktpartner(@Argument Long id) {
return entityManager.find(Marktpartner.class, id);
}
}
Wenn im GraphQL-Schema weitere Attribute definiert waren, die nicht im zurückgegebenen Objekt enthalten waren, oder um Assoziationen aufzulösen, wurden weitere Resolver-Klassen geschrieben. In Spring Boot gibt es hierfür das @SchemaMapping
:
// Kickstart
@Component
public class MarktpartnerResolver implements GraphQLResolver<Marktpartner> {
public String getUri(Marktpartner marktpartner) {
return String.format(URL_VISITENKARTE, marktpartner.getId());
}
public Set<Marktfunktion> getMarktrollen(Marktpartner marktpartner, MarktrollenFilter filter) {
return repository.find...
}
}
@Controller
public class MarktpartnerResolver {
@SchemaMapping(typeName = "Marktpartner", field = "uri")
public String getUri(Marktpartner marktpartner) {
return String.format(URL_VISITENKARTE, marktpartner.getId());
}
@SchemaMapping(typeName = "Marktpartner", field = "marktrollen")
public Set<Marktfunktion> getMarktrollen(Marktpartner marktpartner, @Argument MarktrollenFilter filter) {
return repository.find...
}
}
Wichtig ist hierbei, das nur in @Controller
-annotierten Klassen nach @QueryMapping
oder @SchemaMapping
gesucht wird.
Relay-Unterstützung
In Spring Boot gibt es noch keine Unterstützung für Relay. Deshalb ist es notwendig, das Schema anzupassen. Dies führt nach meinem jetzigen Kenntnisstand leider auch dazu, dass Clients angepasst werden müssen, jedenfalls, wenn man erst einmal den einfachsten Weg geht.
// Kickstart
type Query {
marktpartner(id: ID!) : Marktpartner!
marktpartners(first: Int, after: String, last: Int, before: String, filter: MarktpartnerFilter) : MarktpartnerConnection @connection(for: "Marktpartner")
}
# Relay.js types:
# ---------------
interface Node {
id: ID
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Marktpartner implements Node {
id: ID!
name: String!
// ...
}
// Spring Boot
type Query {
marktpartner(id: ID!) : Marktpartner!
marktpartners(first: Int, after: String, last: Int, before: String, filter: MarktpartnerFilter) : Connection
}
# Relay.js types:
# ---------------
interface Node {
id: ID
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Connection {
edges: [Edge]
pageInfo: PageInfo
}
type Edge {
cursor: String!
node: Node!
}
type Marktpartner implements Node {
id: ID!
name: String!
// ...
}
Hinzu gekommen sind also die expliziten Typ-Definitionen für Connection und Edge.
Die obige Lösung führt jedoch dazu, dass Anfragen des Clients angepasst werden müssen:
// Kickstart
query Marktpartner($first: Int, $filter: MarktpartnerFilter, $marktrollen: MarktrollenFilter) {
marktpartners(first: $first, filter: $filter) {
edges {
cursor
node {
id
name
}
}
}
}
// Spring Boot
query Marktpartner($first: Int, $filter: MarktpartnerFilter, $marktrollen: MarktrollenFilter) {
marktpartners(first: $first, filter: $filter) {
edges {
cursor
node {
id
... on Marktpartner {
name
}
}
}
}
}
Außerdem wird noch eine TypeResolver
-Implementierung benötigt:
// Spring Boot
public class NodeTypeResolver implements TypeResolver {
@Override
public GraphQLObjectType getType(TypeResolutionEnvironment env) {
Object object = env.getObject();
if (object instanceof Marktpartner) {
return env.getSchema().getObjectType("Marktpartner");
}
throw new UnsupportedOperationException("NodeTypeResolver unvollständig für das Objekt " + object);
}
}
Testing
Am längsten hat die Umstellung der integrativen Tests gedauert, da ich dazu bisher nur wenige Beispiele gefunden hatte. Das wichtigste dabei: Es wird spring-webflux benötigt, auch wenn die Anwendung selbst gar nicht fluxig ist:
pom.xml (Spring Boot)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.graphql</groupId>
<artifactId>spring-graphql-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<scope>test</scope>
</dependency>
Für Tests gibt es in Spring Boot die Klasse GraphQlTester
und davon diverse Unterklassen für verschiedene Einsatzszenarien. Mein integrativer Test sieht nach der Umstellung wie folgt aus::
@Transactional
@SpringBootTest
class QueryIT {
private WebGraphQlTester tester;
@Autowired
private ObjectMapper objectMapper;
// ...
@Autowired
private WebApplicationContext context;
@BeforeEach
void before() {
WebTestClient webTestClient = MockMvcWebTestClient.bindToApplicationContext(context)
.configureClient()
.baseUrl("/api/graphql")
.defaultHeaders(headers -> headers.setBasicAuth("test", "test"))
.build();
tester = HttpGraphQlTester.create(webTestClient);
// Testdaten anlegen ...
}
@Test
void findetMarktpartnerUeberNameUndMarktrolle() {
MarktpartnerFilter filter = MarktpartnerFilter.builder().namensteil("acme").build();
MarktrollenFilter marktrollenFilter = MarktrollenFilter.builder()
.marktrollen(List.of(MarktfunktionRolle.LIEFERANT))
.build();
ObjectNode variables = objectMapper.createObjectNode();
variables.set("filter", objectMapper.convertValue(filter, ObjectNode.class));
variables.set("marktrollen", objectMapper.convertValue(marktrollenFilter, ObjectNode.class));
variables.put("first", 10L);
tester.documentName("marktpartners")
.variable("filter", variables.get("filter"))
.variable("marktrollen", variables.get("marktrollen"))
.variable("first", variables.get("first"))
.execute()
.path("marktpartners.edges").entityList(EdgeDummy.class).hasSize(1)
.path("marktpartners.edges[0].node.name").entity(String.class).isEqualTo("ACME Energie GmbH")
;
}
}
Zunächst wird also ein WebTestClient
erzeugt und konfiguriert und anschließend ein HttpGraphQlTester
. Dieser wird dann im eigentlich Test verwendet, um einen GraphQL-Aufruf durchzuführen. Wird wie oben eine documentName("marktpartners")
angegeben, dann sucht das System die Datei resources/graphql-test/marktpartners.graphql
. Alternativ kann mit der Methode document
die GraphQL-Anfrage als String übergeben werden.
Mit execute
wird die Anfrage gesendet und anschließend das Ergebnis geprüft. Hier kommen die path
-Aufrufe zum Einsatz und Standardvergleichsmethoden wie hasSize
oder isEqualTo
.
Stolperfallen und andere Anomalitäten
Kleine Sammlung von Inkompatibilitäten zum Verhalten von Kickstart-GraphQL:
- Verwende
List
stattSet
(beiSet<Enumtyp>
gab es Exeptions, dass auf ein Property nicht zugegriffen werden kann) - Bean-Standard für Rückgabe-DTOs einhalten, also Methoden nicht
edges()
benennen sonderngetEdges()