Migration Kickstart-GraphlQL zu Spring Boot GraphQL

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:

KickstartSpring Boot
graphql.servlet.mappingspring.graphql.path
graphiql.mappingspring.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 statt Set (bei Set<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 sondern getEdges()

Spring Integration DSL vs. Custom ObjectMapper

Spring Integration nutzt eigene ObjectMapper. Möchte man aber genau diesen konfigurieren, dann kommt man nicht umhin, einen eigenen HttpMessageConverter zu konstruieren und diesen explizit in der Flow-Definition zu setzen:

    @Bean
    IntegrationFlow flow() {
        return IntegrationFlows.from("channelname")
                .handle(Http.outboundGateway("http://server/api?query")
                        .httpMethod(HttpMethod.GET)
                        .messageConverters(customMessageConverter())
                        .expectedResponseType(new ParameterizedTypeReference<CustomViewType>() {
                        })
                        .requestFactory(requestFactory)
                )
                .handle(spec -> customService.doSomething((CustomViewType) spec.getPayload()))
                .get();
    }

    private HttpMessageConverter<?> customMessageConverter() {
        return new MappingJackson2HttpMessageConverter(objectMapper());
    }

    private ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(...);
        // eigene Konfiguration hier
        return mapper;
    }

E-Mail-Templates mit Thymeleaf (Teil 3 von 1)

Im dritten der bisher nur einteiligen Serie über E-Mail-Templates mit Thymeleaf zeige ich, wie sich HTML-basierte E-Mails erzeugen lassen, die vom Anwender mit seinem E-Mail-Programm der Wahl vor dem Versenden noch bearbeitet werden können (im ersten Teil wird es um die rein server-seitige Generierung von E-Mails gehen und der zweite Teil ist der Vorläufer zu diesem, bei dem es um Text-E-Mails geht).

Wunsch der Fachabteilung war es, zwei Datensätze zu vergleichen und die Unterschiede hervorzuheben. Die E-Mail sollte eine Tabelle enthalten, in dem diese Werte gegenübergestellt werden:

Vom Server vor-generierte E-Mail

Über einen mailto-Link lässt sich im body-Parameter nur Text übergeben (siehe Teil 2 dieser Reihe) und bei der Variante aus Teil 1 verschickt der Server selbst die E-Mail, ohne das der Anwender noch eingreifen könnte.

Die Lösung besteht nun darin, dass der Server eine .eml-Datei für den Download erzeugt. Die damit verknüpfte Anwendung ist das E-Mail-Programm, dass die Nachricht dann sendebereit anzeigt.

Controller-Code

    @GetMapping(path = "/{id}/vergleichen/{candidate}")
    public void mailvorlageZeitreihenVergleichen(
            @PathVariable("id") Long id,
            @PathVariable("candidate") Long candidateId,
            HttpServletResponse response
    ) throws IOException {
        Context context = new Context();
        context.setVariable("original", service.zeitreihe(id));
        context.setVariable("candidate", service.zeitreihe(candidateId));
        context.setVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME,
                new ThymeleafEvaluationContext(applicationContext, conversionService));
        String body = templateEngine.process("intern/zeitreihen/mailvorlageZeitreihenVergleichen", context);

        response.setContentType("text/plain");
        response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"Negativliste_"
                + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmm")) + ".eml\"");
        BufferedWriter out = new BufferedWriter(new OutputStreamWriter(response.getOutputStream()));
        out.write(body);
        out.flush();
    }

Der Code besteht aus drei Abschnitten:

  1. Ermitteln der Daten und übertragen in den Context.
  2. Verarbeiten des Thymeleaf-Templates.
  3. Schreiben der Daten in den OutputStream der HttpServletResponse.

In den Context schreiben wir die Daten, auf die wir später im Template zugreifen wollen. Einzige Besonderheit ist hier der ThymeleafEvaluationContext. Diesen brauchen wir, damit ggf. eigene Converter genutzt werden können, z. B. für die Formatierung von Datumswerten (die Syntax mit den doppelt-geschweiften Klammern: ${{zeitraum}}).

Über den Header CONTENT_DISPOSITION teilen wir dem Browser zum einem mit, dass er die empfangenen Daten als Download anbieten soll und zum anderen legen wir hier auch den Dateinamen fest, den wir mit .eml enden lassen. Das zeigt sich dann für den Anwender wie folgt:

Download der vom Server gerenderten E-Mail-Vorlage

Das Template mit der E-Mail-Vorlage

Bei einer Spring-Boot-Anwendung liegt das Template in der Datei src/main/resources/templates/intern/zeitreihen/mailvorlageZeitreihenVergleichen.html und sieht so aus:

To:
Subject: Zeitreihen-Vergleich: <th:block th:text="${original.bilanzkreis}"></th:block> vs. <th:block th:text="${candidate.bilanzkreis}"></th:block>
X-Unsent: 1
Content-Type: text/html

<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>E-Mail-Vorlage: Zeitreihen vergleichen</title>
  <style>
    body {...} 
    th {...}
    .abweichung {
      color: red !important;
      font-weight: bold !important;
    }
  </style>
</head>
<body>
  <table>
    <thead>
    <tr>
      <th></th>
      <th>Originale Zeitreihe</th>
      <th>Vergleichs-Zeitreihe</th>
    </tr>
    </thead>
    <tbody>
    <tr>
      <th>Bilanzkreis</th>
      <td th:text="${original.bilanzkreis}"></td>
      <td th:text="${candidate.bilanzkreis}" th:classappend="${original.bilanzkreis != candidate.bilanzkreis} ? 'abweichung' : ''"></td>
    </tr>
    <tr>
      <th>Lieferant</th>
      <td th:text="${original.lieferantName}"></td>
      <td th:text="${candidate.lieferantName}" th:classappend="${original.lieferantName != candidate.lieferantName} ? 'abweichung' : ''"></td>
    </tr>
...

Das Template beginnt mit den Steuer-Header für die E-Mail. Ein Empfänger (To:) ist hier weggelassen und muss vom Anwender in seinem Mail-Programm ergänzt werden. Das X-Unsent sorgt dafür, dass das Mail-Programm weiß, dass die E-Mail noch nicht versendet wurde (BTW: Ich habe das Ganze nur mit Outlook ausprobiert). Schließlich teilen wir dem E-Mail-Programm noch mit, das der Inhalt (Body) HTML ist und nach der Leerzeile kommt auch schon der Body. Hier kann wie gewohnt die Thymleaf-Syntax verwendet werden (das klappt auch in den Header-Zeilen wie beim Betreff (Subject) zu sehen ist).

LEGO-Roboter bauen und programmieren – Workshop in der Bücherhalle Hamburg Volksdorf

Am 5. Januar 2018 findet der Workshop „LEGO Roboter bauen und programmieren“ in der Bücherhalle Hamburg Volksdorf statt. Der Kurs war leider innerhalb kürzester Zeit ausgebucht.

Aufgrund des großen Interesses wird es bestimmt noch einen weiteren Termin dort geben (nach dem Umzug der Bücherhalle).

Flyer "LEGO Roboter bauen und programmieren"

Flyer „LEGO Roboter bauen und programmieren“

Jeden Tag etwas dazu lernen: Versehentliche Abhängigkeiten erkennen und verhindern (Maven Enforcer Plugin)

Das Enforcer-Plugin von Maven kann sicherstellen, dass bestimmte Abhängigkeiten zu anderen Bibliotheken (jars) nicht in der finalen auszuliefernden Datei landen, zum Beispiel Bibliotheken, die nur für automatisierte Tests benötigt werden.

Beispiel für eine Konfiguration des Plugins:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <executions>
    <execution>
      <id>enforce-banned-dependencies</id>
      <goals>
        <goal>enforce</goal>
      </goals>
      <configuration>
        <rules>
          <bannedDependencies>
            <excludes>
              <exclude>junit:*:*:*:compile</exclude>
              <exclude>org.hamcrest:*:*:*:compile</exclude>
              <exclude>org.mockito:*:*:*:compile</exclude>
              <exclude>*:JUnitParams:*:*:compile</exclude>
              <exclude>*:hamcrest-date:*:*:compile</exclude>
              <exclude>*:*:*:test-jar:compile</exclude>
              <exclude>*:snh-commons-test:*:*:compile</exclude>
            </excludes>
          </bannedDependencies>
        </rules>
        <fail>true</fail>
      </configuration>
    </execution>
  </executions>
</plugin>

Diese Konfiguration stellt sicher, dass die gängigen Test-Bibliotheken nicht im produktiven Code verwendet werden.