https://nilshartmann.net / @nilshartmann
Freiberuflicher Software-Entwickler, Berater und Trainer aus Hamburg
Java | JavaScript, TypeScript | React | GraphQL
Teil 1: GraphQL
Teil 2: GraphQL Anwendungen bauen
Jeder Zeit: Eure Fragen!
09:00-17:00
Zwischendurch Pausen ☕️ 🍰
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
"GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data" (https://graphql.org)
GraphQL APIs stellen einen Graphen mit Objekten bereit
Objekte enthalten Felder und die Felder haben Rückgabe-Typen (vergleichbar mit Java)
Aus dem Graphen kann mit der Query-Sprache eine Untermenge der Felder ausgewählt werden
Dazu gibt es ein spezielles Root-Objekt, mit dem ein Query beginnt
query {
story {
id title writtenBy {
id user { name }
}
}
}
query {
story(storyId: 5) {
id title writtenBy {
id user { name }
}
}
}
Ausgeführt werden Operationen. Der Operation-Type beschreibt, was in der Anfrage getan werden soll
Der Operation-Type bestimmt auch den Einstiegspunkt in den Objekt-Graphen
Operationen können Namen haben.
Das ist vor allem für Debugging und Code-Generatoren relevant
query NewestStory {
story {
id
title
}
}
fragment BaseMember on Member {
id joined
user { is username }
}
query {
story {
writtenBy { ...BaseMember }
reactions {
givenBy { ...BaseMember }
}
}
}
Ein Union-Type kann mehr als einen Typ zurückliefern:
mutation addComment
(input: { storyId: "1", content: "..." }) {
...on AddCommentSuccessPayload {
newComment { id }
}
...on AddCommentFailurePayload {
errorMessage
}
}
Ein Interface erzwingt gemeinsame Felder an den Objekten, die das Interface implementieren (ähnlich wie in Java mit Methoden)
Beispiel: id
und createdAt
sind am Node
-Interface definiert
query {
node(id: "...") {
id
createdAt
...on Story { title body }
...on Comment { content }
}
}
Queries können Variablen haben.
Variablen müssen im Query deklariert werden
Werte für Variablen werden in einem eigenen JSON-Objekt an den Server geschickt
query ($storyId: ID!) {
story(id: $storyId) {
id
title body
}
}
Mach' dich mit der GraphQL-Abfragesprache vertraut
Docs
-Explorer (Buch-Symbol links oben) verwenden, um die API zu untersuchendata
: Die gelesenen Daten (Struktur darunter entspricht der Abfrage)errors
: Liste mit (technischen) Fehlern, u.a. Fehlermeldungextensions
: Freibelegbares Objekt für proprietäre Erweiterungen (z.B. Debug-Informationen)Beschreibung eines Objekt Types
type Story {
id: ID!
title: String!
body: String!
}
Dokumentation mit drei doppelten Anführungszeichen
Kommentare mit Hash-Zeichen
"""
A `Story` is the main object in our service.
"""
type Story {
"""Identifies this object"""
id: ID!
# todo: implement new tags-field (PROJ-666)
}
Skalare Typen entsprechen primitiven Typen in Java
Standard-Typen: ID, String, Boolean, Int, Float
type Member {
id: ID!
username: String!
# no exclamation mark: can be null
likes: Int
amount: Float!
activeMember: Boolean
}
Man kann eigene skalare Typen bauen
Wie in Java
enum ReactionType {
like,
laugh,
thumbUp
}
Referenzen auf andere Objekt-Typen
type Member {
# ...
}
type Comment {
# ...
}
type Story {
"""Reference to the Member that has written this Story"""
writtenBy: Member!
comments: [Comment!]!
}
Felder können Argumente haben
Die Felder mit ihren Namen und Typen müssen in der API definiert werden
Argumente können Default-Werte haben
Achtung! Argumente dürfen keine Objekt-Typen sein!
type Story {
# Mandatory argument maxLength, defaults to 20
# if not specified by the client
excerpt(maxLength: Int! = 20): String!
}
Root-Typen bilden den Einstiegspunkt für Queries in den Objekt-Graphen
Root-Typen sind Query, Mutation und Subscription
Syntax und Verhalten genauso wie bei Objekt-Typen
Felder an den Root-Typen werden auch Root-Felder genannt
type Query {
"""Returns a List of all stories"""
stories: [Story!]!
"""Returns a story by its ID or null"""
story(id: ID!): Story
}
type Mutation {
addComment(storyId: ID!, memberId: ID!, content: String!): Comment!
}
Objekt-Typen können nicht als Argument an ein Feld übergeben
Als Argumente an Feldern können nur skalare Typen, Enums und Input-Typen übergeben werden.
Ein Input-Type wird mit input
definiert, sieht ansonsten aus wie ein Objekt-Type
input AddCommentInput {
storyId: ID!
memberId: ID!
content: String!
}
type Mutation {
addComment(input: AddCommentInput!): Comment!
}
Union Types bilden eine Menge von anderen Typen
Ein Feld kann ein Typen aus dieser Menge zurückliefern ("A oder B")
type AddCommentSuccess { newComment: Comment! }
type AddCommentFailed { errorMessage: String! }
union AddCommentResult = AddCommentSuccess | AddCommentFailed
type Mutation {
addComment(input: AddCommentInput!): AddCommentResult!
}
Mit einem Interface wird erzwungen, dass Objekte über gleiche Felder verfügen
interface Node {
id: ID!
}
type Story implements Node {
id: ID! # Field defined in Node-Interface
title: String! # additional Story fields
}
type Comment implements Node {
id: ID!
content: String!
}
type User {
id: ID!
email: String
}
type Query {
# Returns either Story or Comment, but not User
node(id: ID!): Node
}
In GraphQL gibt es nur eine Version der API (kein /api/v1
, /api/v2
)
Das Schema kann jederzeit erweitert werden
Man kann Felder mit deprecated
markieren, um anzuzeigen, dass sie nicht genutzt werden sollen
type Query {
# Ausgangspunkt
getStoryById(id: ID!): Story
}
type Query {
getStoryById(id: ID!): Story
# Neues Feld, beeinträchtigt bestehenden Client nicht
stories: [Story!]!
}
type Query {
getStoryById(id: ID!): Story @deprecated("Use story instead")
story(id: ID!): Story
stories: [Story!]!
}
workspace
: Hier arbeiten wir, bitte in der IDE öffnen
workspace/publy-backend
: Spring Boot-Projekt, in dem wir die GraphQL API implementierenworkspace/publy-userservice
: Spring Boot-Projekt mit dem UserServiceworkspace/steps
: Lösungen für die einzelnen ÜbungenNach dem Öffnen in der IDE sollte es keine Compilier-Fehler geben.
Starten des Backends
nh.publy.backend.PublyApplication
aus (darin enthalten ist eine main
-Methode)http://localhost:8090
öffnest, sollte GraphiQL geöffnet werden.publy-backend/resources/graphql/publy.graphqls
und beschreibe darin eine API, die folgende Objekt-Typen enthält:id
und profileImage
id
, title
, body
, excerpt
und einer Referenz (writtenBy
) auf den Memberid
-Feld ist vom Typ ID
alle anderen Stringsstories
: Liefert eine Liste der Story
-Objekte zurück. Nicht nullable.story
: Liefert eine einzelne Story zurück. Das Feld soll ein Argument haben: storyId
. Das Feld kann null
zurückgebensteps/01-schema
errors
-Feld in der Antwort) data
-Feld bereitsgestelltZentrales Interface in GraphQL-Java: Ermitteln die Daten für ein Feld eines Queries
Query.story
oder Story.title
Resolver
genanntinterface DataFetcher<T> {
T get(DataFetchingEnvironment environment);
}
T
ist der Typ, der von diesem DataFetcher zurückgeliefert wird
Beispiel:
class PingDataFetcher implements DataFetcher<String> {
@Override
public String get(DataFetchingEnvironment env) {
return "Pong";
}
}
Jeder DataFetcher wird im RuntimeWiring einem Feld der API zugewiesen
Spring for GraphQL bietet eine Abstraktion von GraphQL-Java an
Erstes Release von Spring for GraphQL enthalten in Spring Boot 2.7 (Mai 2022)
Features:
spring-boot-starter-graphql
für Spring BootDas Schema wird in Dateien mit der Endung .graphqls
abgelegt
main/resources/graphql
Spring Boot sammelt alle Schema-Dateien ein und erzeugt ein Schema daraus
Typen können erweitert werden
# story.graphqls
extend type Query {
story(id: ID!): Story
}
# comment.graphqls
extend type Query {
comment(id: ID): Comment
}
Handler-Funktionen werden an Controller
-Klassen implementiert
@Controller
public GraphQLController {
@QueryMapping
public String ping() { return "Pong!" }
}
Handler-Funktionen für Felder am Query-Typen werden mit @QueryMapping
annotiert
@MutationMapping
, Subscription: @SubscriptionMapping
Der Name der Methode entspricht dem Namen des Feldes des entsprechenden Root-Typen (oder explizit mit value
setzen)
@Argument
um ein einzelnes Argument zu erhaltentype Query {
ping(msg: String): String!
}
@QueryMapping
public String ping(@Argument String msg) {
return "Hello " + msg;
}
input GreetingInput { name: String, msg: String }
type Query {
greet(input: GreetingInput!): String!
}
class GreetingInput { private String name; private String msg; /* getter+setter... */ }
// oder Java 17 Records:
record GreetingInput(String name, String msg) {}
@QueryMapping
public String greet(@Argument GreetingInput input) {
return "Hello " + input.getName();
}
class GreetingInput { @Size(min=5) private String name; private String msg; /* getter+setter... */ }
@QueryMapping
public String greet(@Valid @Argument GreetingInput input) {
return "Hello " + input.getName();
}
steps/01-schema
nach publy-backend/src/main/resources/graphql
(bestehendes Schema überschreiben)nh.publy.backend.graphql.PublyGraphQLController
:Query.stories
und für Query.story(id: ID!)
StoryRepository
verwenden. query {
stories {
id title body
writtenBy { id profileImage }
}
}
query {
story(storyId: 1) {
id title body
writtenBy { id profileImage }
}
}
steps/10_handler_function
body
- und writtenBy
-Feld an der Story
-EntityDie Funktion wird dann mit @SchemaMapping
annotiert
Source
-Objekt, auf dem das Feld ermittelt werden soll@Controller
class GraphQLController {
@SchemaMapping
public String excerpt(Story source, @Argument int maxLength) {
return source.getBody().substring(0, maxLength);
}
}
Wenn die als Parameter übergebene Klasse nicht so heißt, wie im GraphQL Schema,
kann mit typename
der GraphQL-Typ explizit gesetzt werden
@SchemaMapping(typeName="Story")
public String excerpt(StoryDto source) {
// ...
}
@QueryMapping
, @SubscriptionMapping
und @MutationMapping
sind nur Aliase für
@SchemaMapping(typeName="Query")
@SchemaMapping(typeName="Mutation")
@SchemaMapping(typeName="Subscription")
Handler-Funktionen werden grundsätzlich nacheinander ausgeführt
Handler-Funktionen können aber CompletableFuture
, Flux
oder Mono
-Objekte zurückliefern
Dann werden mehrere Handler-Funktionen parallel ausgeführt
@Service
public class DomainService {
@Async
CompletableFuture<String> determineExcerpt(Story story, int maxLenght) {
// long running task...
}
}
@Controller
public class GraphQLController {
@Autowired DomainService domainService;
@SchemaMapping
public CompletableFuture<String> excerpt(Story story, @Argument int maxLength) {
return domainService.determineExcerpt(story, maxLength); // asynchron bzw. reaktiv
}
}
Wir erweitern den Member-Typen um das User-Objekt:
type User {
id: ID!
name: String!
email: String!
}
type Member {
# ...
user: User
}
Die Member
-Klasse hat aber keine Referenz auf den User (nur dessen Id)
@Entity
public class Member {
// ...
@NotNull
private String userId;
public String getUserId() { return this.userId };
}
Der User kommt aus dem UserService
(externer Micro-Service)
Vorbereitung 1: UserService starten
nh.graphql.publy.userservice.UserserviceApplication
aus dem publy-userservice
-Verzeichnis
(darin ist eine main
-Methoden vorhanden)Vorbereitung 2: Ergänze das Schema
material/15-schema-mapping/schema.graphqls
an das Ende deiner publy.graphqls
-Schema-Datei.User
-Typ definiert und der Member
-Typ wird um ein user
-Feld erweitert.Implementiere die Schema-Mapping-Funktion für das Feld Member.user
UserService
ist bereits implementiertPublyGraphQLController
aus steps/10_handler-function
Der folgende Query sollte User zurückliefern, wenn Du die Schema-Mapping-Funktion korrekt implementiert hast:
query {
story(storyId: 1) {
id
writtenBy {
# Hier sollten nun Daten kommen:
user {
id name email
}
}
}
}
Eine mögliche Lösung findest Du in steps/15_schema-mapping
QueryMapping
nur für den Mutation- und Subscription-TypeQueryMapping
type Comment {
id: ID!
story: Story!
writtenBy: Member!
content: String!
}
type Story {
# ...
comments: [Comment!]!
}
input AddCommentInput {
storyId: ID!
memberId: ID!
content: String!
}
type Mutation {
addComment(input: AddCommentInput!): Comment!
}
Input
am Endeinput
Payload
am Endeinput AddCommentInput {
storyId: ID!
memberId: ID!
content: String!
}
type AddCommentPayload {
newComment: Comment!
}
type Mutation {
addComment(input: AddCommentInput!): AddCommentPayload!
}
PublyGraphQLController
aus steps/15_schema-mapping
material/17-mutation/schema.graphqls
an das Ende deiner bestehenden publy.graphqls
-DateiStory
hat nun comments
. Es gibt die addComment
-Mutation.PublyGraphQLController
input
-Argument und den Rückgabetyp AddCommentPayload
PublyGraphQLController
-Konstruktor den fertigen PublyDomainService
übergeben lassenaddComment
, der einen Kommentar in der DB speichertmaterial/17-mutation/add-comment.graphql
funktionierensteps/17_mutations
query {
stories {
writtenBy { user { id email } }
}
}
Ein DataLoader
"verzögert" das Laden von Daten
DataLoader
"verzögert" das Laden von Datenwhere ID in ...
)graphql-java
abstrahiert Spring for GraphQL davonKey
: Typ des Keys, den Du beim Verwenden an den DataLoader übergeben willstValue
: Typ des Java-Objekts, das der DataLoader für einen Key zurückliefertCompletableFuture
zurückliefernpublic CompletableFuture<User> user(Member member, DataLoader<String, User> userLoader) {
String userId = member.getUserId(); // userId ist in DB gespeichert
return userLoader.load(userId); // Laden des Users wird verzögert
}
BatchLoaderRegistry
gibt es HilfsfunktionenBatchLoaderRegistry
steht als Spring Bean zur VerfügungforTypePair
registrierst Du einen BatchLoader für ein Key-Value-PaarBatchLoaderEnvironment
public PublyGraphQLController(/* ... */, BatchLoaderRegistry registry) {
registry.forTypePair(String.class, User.class).registerBatchLoader(
(List<String> keys, BatchLoaderEnvironment env) -> {
// hier zum Beispiel DB-Aufruf oder REST-Aufruf
// findUsers liefert Flux<User> zurück.
return userService.findUsers(keys);
}
);
}
null
zurückgegeben werdenMappedBatchLoader
verwendet werden, der ein Mono-Objekt mit einer Map zurückliefertpublic PublyGraphQLController(/*... */, BatchLoaderRegistry registry) {
registry.forTypePair(String.class, User.class).registerMappedBatchLoader(
(List<String> keys, BatchLoaderEnvironment env) -> {
// zurückgeben: Mono<Map<String, User>>
}
);
}
MappedBatchLoader
mit einer BatchMapping-Funktion implementiert werdenMember
-Objekten)Mono
mit einer Map
oder ein Flux
mit Objekten zurück (wie vorher gesehen).equals
und hashCode
-Methoden in den Objekten, die als Keys verwendet werden, korrekt implementiert sind!@Controller
public class PublyGraphQLController {
@BatchMapping
public Flux<User> user(List<Member> member) {
List<String> keys = member.stream().map(Member::getUserId).collect(Collectors.toList());
return userService.findUsers(keys);
}
// -- oder: --
@BatchMapping
public Mono<Map<Member, User>> user(List<Member> member) {
// ...
}
// BatchLoaderRegistry und SchemaMapping für User-Feld entfallen jetzt
}
query {
story(storyId: 1) {
title
body
}
}
query {
story(storyId: 1) {
title
body
comments { id content }
}
}
DataFetchingFieldSelectionSet
aus graphql-java
enthält eine Liste aller Felder, die im aktuellen Query unterhalb des Feldes einer Handler-Funktion abgefragt sindDataFetchingFieldSelectionSet
kannst Du an deine Handler-Funktionen übergeben lassen.contains
kannst Du mit einem Pattern fragen, ob bestimmte Felder im Query vorhanden sind@QueryMapping
List<Story> stories(DataFetchingFieldSelectionSet selectionSet) {
if (s.contains("comments/**") ) {
// SQL Statement mit JOIN auf Comment-Tabelle
return ...;
}
// SQL Statement nur mit Stories
return ...;
}
PublyGraphQLController
und füge als neuen Parameter die BatchLoaderRegistry
hinzuBatchLoaderRegistry
eine DataLoader-Funktion, die für die Keys der User die entsprechenden User laden kannUserService
verwenden (findUsers
)User
sind im Ergebnis vorhanden? Wieviele User(-Ids) werden im BatchLoader geladen?query {
stories {
writtenBy { user { id name } }
comments { writtenBy { user { id name } } }
}
}
steps/20_dataloader
type Query {
stories(first:Int, after:String, last:Int, before:String): PostConnection!
}
posts
könnt ihr einen Parameter vom Typ ScrollSubrange
angebenfirst
, last
etc ab)Window
-Objekt zurückliefern mussWindow
-Objekt enthält die gefundenen ErgebnisseEdge
-Objekte und das PageInfo
-Objekt erzeugt und zum Client zurück gegeben wirderrors
-Feld zurückgeliefert:{
"errors": [
{
"message": "addComment.input.content: Größe muss zwischen 5 und 2147483647 sein",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"addComment"
],
"extensions": {
"classification": "INTERNAL_ERROR"
}
}
]
}
{
"errors": [
{
"message": "Unauthorized",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"addComment"
],
"extensions": {
"classification": "UNAUTHORIZED"
}
}
]
}
errors
-Objekt ist nur eingeschränkt spezifiziert:message
: Fehlermeldunglocations
: Auf welche Code-Stelle im Query bezieht sich der Fehler (wenn vorhanden)path
: Pfad zum Feld, das den Fehler verursacht hat (wenn vorhanden)extensions
: Proprietäre Erweiterungen (classification
kommt von Spring for GraphQL)errors
-Objekt nur für "Request Errors" verwenden, wenn der Query gar nicht
oder nicht vernünftig ausgeführt werden kanntype Query {
stories: [Story!]!
}
type StoriesPayload {
stories: [Story!]!
maxStories: Int!
}
type Query {
stories: [Story!]!
}
type Mutation {
addComment: [Comment!]!
}
type AddCommentPayload {
newComment: Comment
errorMessage: String
}
type Mutation {
addComment: AddCommentPayload!
}
union
-Typen:type AddCommentSuccessPayload { newComment: Comment! }
type AddCommentFailedPayload { errorMsg: String! }
union AddCommentPayload = AddCommentSuccessPayload | AddCommentFailedPayload
type Mutation {
addComment(inpit: AddCommentInput!): AddCommentPayload!
}
mutation {
addComment(input: {storyId: 1, memberId: 1, content: "Toll"}) {
... on AddCommentSuccessPayload {
newComment { id content }
}
... on AddCommentFailedPayload { errorMsg }
}
}
addComment
-Mutation auf einen Union-Type umAddCommentPayload
-Typen aus deinem Schema.material/30-error-handling/schema.graphqls
in dein Schema.PublyGraphQLController
aus steps/20_dataloader
addComment
-Mutation anAddCommentPayload
kannst Du in AddCommentSuccessPayload
umbennenAddCommentFailedPayload
PublyDomainService
eine Exception fliegt, gib AddCommentFailedPayload
zurück mit einer FehlermeldungAddCommentSuccessPayload
zurückaddComment
aus und frag den neuen Kommentar bzw. die errorMsg
absteps/30_error_handling
@PreAuthorize
absichern@PreAuthorize
absichernHandler-Funktionen können mit @AuthenticationPrincipal
sich den aktuellen Principal übergeben lassen
@MutationMapping
@PreAuthorize("hasRole('USER')")
public AddCommentPayload addComment(@Argument AddCommentInput input, @AuthenticationPrincipal User user) {
Long memberId = publyDomainService.getMemberForUser(user);
// ...
Comment newComment = publyDomainService.addComment(
input.getStoryId(),
memberId,
input.getContent()
);
return ...;
}
Zum Testen deiner Anwendung gibt es einen @GraphQLTest
.
Dieser erzeugt u.a. Controller-Klassen und RuntimeWirings (also GraphQL Infrastruktur)
In diesem Test kann ein GraphQlTester
verwendet werden, um GraphQL Queries auszuführen
Das Ergebnis des Queries kann dann validiert werden
@GraphQlTest
public class PublyGraphQLControllerTest {
// ausgelassen: Mocks konfigurieren, z.B. für Repositories mit @MockBean
@Autowired
GraphQlTester graphQlTester;
private final String query = "query { ping }";
@Test
void pingReturnsPong() {
// Query ausführen
GraphQlTester.Response response = graphQlTester.document(query)
.execute();
// Ergebnis validieren
response
.path("ping").entity(String.class).isEqualTo("pong");
}
Der Query kann entweder direkt als String übergeben werden (wie gesehen)
oder in einer Datei abgelegt werden (xyz.graphql
), die im Klassenpfad sein muss
@Test
void pingReturnsPong() {
graphQlTester.document("query { ping } "); // ...
graphQlTester.documentName("ping-test-query"); // erwartet ping-test-query.graphql im Klassenpfad
}
Mit dem WebGraphQlTester
können GraphQL Requests über HTTP gemacht werden
An einen laufenden Server oder in-place
Dazu kann ein gewohnten @SpringBootTest
geschrieben werden
Die Testklasse muss außerdem mit @AutoConfigureHttpGraphQlTester
annotiert werden
API ansonsten wie GraphQlTester
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureHttpGraphQlTester
public class PublyGraphQLControllerWebTest {
@Autowired
private WebGraphQlTester webGraphQlTester;
@Test
void pingReturnsPong() {
// Query ausführen
webGraphQlTester.document("query { ping }")
.execute()
.validate(/* ... */);
}
}
Der injizierte WebGraphQlTester
ist fertig konfiguriert
Kann aber pro Request angepasst werden, z.B. um HTTP Header zu setzen
dazu muss eine neue Instanz mit mutate
erzeugt werden
graphQlTester
.mutate() // erzeugt neue Instant, die konfiguriert werden kann
.header("Authorization", "Bearer user-mock-token")
.build()
.document(query) /* konfigurierte Instanz verwenden... */
Schreibe einen Test für unsere GraphQL-API
Verwende den @GraphlTest
Im Workspace findest Du im test
-Verzeichnis eine Klasse AbstractPublyGraphQLTest
, die Du als Basis verwenden kannst
given_
), die Du je nach Testfall aufrufen kannstLeite deine Testklasse davon ab
@GraphQlTest
annotierenBespiele findest Du in steps/50_test
Wenn ihr noch Fragen habt, könnt ihr mich erreichen:
Mastodon: @nilshartmann@norden.social
Twitter: @nilshartmann