GraphQL mit Spotify – Teil 1: Server

Keine Kommentare

Update 1:

  • Hinweis über blockierte REST Aufrufe durch Spotify. Die Demo Seite und der source code auf github sind bereits aktualisiert.
  • Verbessertes Code Syntax-Highlighting

GitHub ermöglicht es seit 2016 auf seine umfangreiche API per GraphQL zuzugreifen. Auch die New York Times verwendet GraphQL. Wie man GraphQL selbst einsetzen kann, soll in dieser Blog-Reihe gezeigt werden. In diesem Artikel wird ein GraphQL-Server für den Zugriff auf Spotify entwickelt. Es soll gezeigt werden,

  1. wie ein einfacher Server mit GraphQL-Schnittstelle aufgesetzt werden kann,
  2. Wie ein GraphQL Schema implementiert wird und was es ist
  3. welche weiteren Schritte nötig sind, um die Daten von der Spotify API zu laden und sie dann per GraphQL zu Verfügung zu stellen.

GraphQL basierender Spotify client - screenshot

Was ist GraphQL?


Als kurze Beschreibung soll für uns diese Definition dienen:

GraphQL ist eine Spezifikation einer Client-Server-Kommunikation (über http). Mehr Details kann man auf graphql.org finden. Tatsächlich wurde 2015 eine ausführliche Spezifikation veröffentlicht, auf deren Basis sich ein großes Ökosystem für Server und Client entwickelt hat. Eine Übersicht findet man bei „awesome GraphQL“: https://github.com/chentsulin/awesome-graphq

Hinter der Entwicklung von GraphQL stand für Facebook das Ziel, eine effiziente und flexible client-server-Kommunikation zu ermöglichen.

  • GraphQL ist eine fortgeschrittene Schnittstelle für die spezifischen Anforderungen der Kommunikation von Mobilen Geräten
  • GraphQL ist „eine Art“ oder „enthält eine“ Abfragesprache
  • GraphQL ist ein zentraler Punkt für die Aggregation aus verschiedenen Datenquellen

In diesem Artikel wird beschrieben, wie wir beispielhaft einen einfachen Server auf Basis der Referenz Implementierungen für Node.js, graphql-js und express-graphql, für Spotify Musik Daten aufbauen können.

Unterschied zu REST

Für einen einfachen Vergleich können wir beispielsweise im Client nach einem Künstler suchen, und seine Alben und die Liste der Tracks laden. Anstatt dazu folgende REST Endpoints auf der Client Seite abzufragen:

https://api.spotify.com/v1/search?type=artist
https://api.spotify.com/v1/artists/{artist-id}
https://api.spotify.com/v1/artists/{artist-id}/albums
https://api.spotify.com/v1/albums/{album-id}/tracks

werden wir über GraphQL nur genau eine Abfrage für alle Infos an einen Endpunkt an den Server absetzen. Dabei werden wir genau angeben (müssen), welche Attribute geliefert werden sollen. Somit hat aber die Client-Seite die genaue Kontrolle darüber, welche Daten empfangenen werden sollen.

Die Vorteile liegen auf der Hand:

  • Nur ein http request: In mobilen Netzen ist jeder Verbindungsaufbau und jeder erneute Request durch eine hohe Latenz und lange Ping Zeiten „teuer“: Mit jeder neuen, nachfolgenden „(n+1)“ Abfrage dauert es insgesamt sehr viel länger, auch wenn nur geringe Datenmengen übermittelt werden!
  • Nur die benötigten Daten werden übertragen: Wenn wir genau spezifizieren können, welche einzelnen Datenfelder benötigt werden, wird das Minimum übertragen: Im Gegensatz dazu wird REST generell eine „Ressource“ als „all or nothing“ übertragen. Also generell auch Felder, die nicht benötigt werden. Wenn beispielsweise nur ein Bild eines Künstlers angezeigt werden soll, würden auch alle anderen Attribute mitgeschickt werden, anstatt nur ein Link zum Bild. Workarounds für diese Spezialfälle, indem jeweils ein eigener Endpoints zu Verfügung gestellt wird, sind bei GraphQL nicht mehr nötig!

Für unser Beispiel, werden wir

  • einen Javascript Express Server aufsetzen,
  • ein so genanntes GraphQL Schema definieren, und
  • die Daten per REST Api von Spotify laden, asynchron zusammenfassen und von unserem Server per GraphQL ausliefern

Server Setup

Zu Beginn starten wir mit einem einfach Setup des Express-GraphQL Servers:

// server.js
import express from 'express';
import expressGraphQL from 'express-graphql';
import schema from './data/schema';

const app = express ();

app.use('/graphql', expressGraphQL(req => ({
    schema,
    graphiql: true,
    pretty: true
})));

app.set('port', 4000);

let http = require('http');
let server = http.createServer(app);
server.listen(port);

Im nächsten Schritt werden wir noch das fehlende schema implementieren.

GraphQL Schema

„…Man assoziiert HTTP allgemein mit REST, das „Ressourcen“ als sein Kernkonzept enthält. Im Kontrast dazu steht GraphQLs konzeptuelles Model des Entitäten-Graphen.“ (http://graphql.org/learn/serving-over-http)

Das GraphQL-Schema ist eine Liste von Typ-Definitionen.

Ein minimales Schema besteht nur aus einem Wurzelknoten (vom Typ Query type), der direkten oder indirekten Zugriff auf alle anderen Knoten im Graphen ermöglicht.

Wir wollen das mit einem einfachen Beispiel veranschaulichen und implementieren:

Schema Definition – Variante 1

In der folgenden, stark vereinfachten Beispielimplementierung, hat der query type gerade nur das Feld hi, das immer den Wert hello world besitzt. Es ist also die Verbindung zu einem Datenknoten „Hello World“, der über die Kante „hi“ erreicht werden kann.

// data/schema.js 
import {
    GraphQLSchema,
    GraphQLString as StringType,
} from 'graphql';

const schema = new GraphQLSchema({
    query: new GraphQLObjectType({
        name: 'Query',
        fields: {
            hi: {
                type: StringType,
                resolve: () => 'Hello world!'
            }
        }
    })
});

export default schema;

Lassen Sie uns dieses Schema in unserem einfachen Express-Server verwenden, und starten den Server mit dem Befehl node server.js

In einem zweiten Terminal-Fenster starten wir die Abfrage mit curl:

curl 'http://localhost:4000/graphql' \
     -H 'content-type: application/json' \
     -d '{"query":"{hi}"}'

Als Ergebnis bekommen wir folgendes JSON-Dokument:

{
  "data": {
    "hi": "Hello world!"
  }
}

In data steht das Ergebnis der Abfrage. Mögliche Fehler-Informationen würden in einem optionalen error Element stehen.

Da graphiql beim Start des Servers aktiviert ist(per graphiql:true), kann es einfach im Browser über diese Adressehttp://localhost:4000/graphql?query={hi} geöffnet werden, und wir erhalten:

graphiql-hi-

Im Editor-Fenster links wurde automatisch die Abfrage eingetragen und ausgeführt. Hier können wir durch das Schema tool-gestützt Syntax Highlighting und auch Auto-Vervollständigung nutzen!

In GraphiQL kann außerdem rechts neben dem Ergebnis-Fenster die Dokumentation des Schemas aufgeklappt und angezeigt werden, nachdem der Docs-Buttons gedrückt wurde.

Wir können zu unserem Schema auch einige Beschreibungen hinzufügen, die dann auch in der GraphQL ’schema inspection’/Dokumentation rechts auftauchen!

Beispielsweise wie in diesem Beispiel:

import {
    GraphQLString as StringType,
    GraphQLObjectType,
    GraphQLSchema
} from 'graphql';

const schema = new GraphQLSchema({
    query: new GraphQLObjectType({
        name: 'Query',

        description: 'Die Wurzel aller Abfragen."',

        fields: {
            hi: {
                description: 'Gibt einfach nur "Hello world!" zurück',

                type: StringType,

                resolve: () => 'Hello world!'
                // oder einfach konstanter Wert:
                //   resolve: 'Hello world!'
            }
        }
    })
});

Nach dem Neustart des Servers steht das neue Schema in Graphiql zu Verfügung.

graphiql-hi-with-docsDas ist unsere API-Dokumentation, ähnlich wie bei z.B. Swagger. Aktualisierung funktioniert immer automatisch, ohne zusätzlich manuell eine Dokumentation generieren zu müssen!

Graphiql fragt das Schema im Hintergrund über die gleiche Schnittstelle ab, denn selbst das Schema kann immer auch selbst gelesen werden, es stehen zum Beispiel alle Typdefinitionen zu Verfügung.

Man kann das eingebaute Schema direkt abrufen, zum Beispiel mit curl:

curl 'http://localhost:4000/graphql' \
     -H 'content-type: application/json' \
  -d '{"query": "{__schema { types { name, fields { name, description, type {name} } }}}"}'

Es erzeugt ein relativ langes, „für Maschinen lesbares“ JSON-Dokument, das aber für uns nicht einfach zu lesen oder zu verstehen ist.

Schema Definition – Variante 2: Schema IDL

GraphQL Schema IDL: Interface Definition Language

oder auch einfach

GraphQL SDL: Schema Definition Language

Ausgabe als GraphQL SDL per printSchema

Wir können uns dazu mit dem printSchema-Modul eine Darstellung als GraphQL SDL generieren lassen, die uns eine klare, kompakte und lesbare Ausgabe erzeugt:

Mit der bestehenden Schema kann man sie so erstellen:

// data/printSchema.js
import { printSchema } from 'graphql';
import schema from './schema';

console.log(printSchema(schema));

Beim Start mit babel-node data/printSchema erzeugt es uns dieses Ausgabe:

# "Die Wurzel aller Anfragen."
type Query {
   # Gibt einfach nur "Hello world!" zurück'
   hi: String
}

Vielleicht können sie das gleiche Format erkennen, wie es bei der Definition von Typen in flowtype verwendet wird (Typ-Annotationen für JavaScript)

Definition per GraphQL SDL per buildSchema

Wir können dieses Format sogar als einen kürzerer Ansatz zur Definition unseres Schemas im Server verwenden!

Dazu benutzen wir einfach buildSchema aus dem graphql Module, siehe Beispiel:

// data/schema.js
import { buildSchema } from 'graphql';

const schema = buildSchema(`
#
# "Die Wurzel aller Anfragen."
#
type Query {
   # Gibt einfach nur "Hello world!" zurück'
   hi: String
}
`);

export default schema;

Neben der Schema-Typdefinition müssen wir natürlich auch die Logik implementieren, um es mit Werten zu füllen, oder konkrete Werte zurückzugeben.

Im Server wird dazu die Definition des Wurzelknotens per rootValue bereitgestellt, von dem aus alle anderen Knoten erreicht werden. Dazu müssen nur so genannte resolver hinzugefügt werden, auf die wir später genauer eingehen:

Der Rückgabewert der Funktion hi() repräsentiert hier den Wert für das hi Feld von oben.

// ... in server.js :
app.use('/graphql', expressGraphQL(req => ({
    schema,
    
    rootValue: {
      hi: () => 'Hello world!'
    },
    
    graphiql: true,
    pretty: true
})));

Schema für unsere Spotify-Daten

Wir werden im nächsten Schritt ein Schema für unser Beispiel für Spotify-Daten entwerfen und nutzen.

„Die grundlegenden Komponenten eines GraphQL-Schemas sind Objekttypen, die eine bestimmte Art von Objekt mit seinen Feldern darstellen, wie sie von einem [REST] Server abgefragt werden können“ Aus Schemas und Typen

// data/schema.js
import { buildSchema } from 'graphql';

const schema = buildSchema(`
#
# Einfacher Start:
# Wir verwenden hier nur einen Teil der Informationen, die Spotify API zu Verfügung stellt
# z.B. von https://api.spotify.com/v1/artists/3t5xRXzsuZmMDkQzgOX35S
# Bei Bedarf kann dieses Schema also um weitere Felder erweitert werden 
#
type Artist {
  name: String
  image_url: String
  albums: [Album]
}
# Album kann auch eine Single repräsentieren...
type Album {
  name: String
  image_url: String
  tracks: [Track]
}
type Track {
  name: String
  preview_url: String
  artists: [Artists]
  track_number: Int
}
type Query {
  # Liste alle Künstler, deren Namen "byName" enthält
  queryArtists(byName: String = "Red Hot Chili Peppers"): [Artist]
}
`);
export default schema;

Hier hilft uns das GraphQL schema cheatsheet von Hafiz Ismail großartig.

Wir definierten hier konkrete Typen, und auch ihre Beziehungen, um die Struktur unseres Entitäten-Graphen aufzubauen:

  1. Uni-direktionale Verbindungen zwischen Entitäten:
  • Zum Beispiel Alben eines Künstlers (1-m): Um diese Relation zu definieren, fügen wir einfach ein Feld albumvom Typ array bestehend aus Album zum Typ Artist hinzu.
  • Jeder einzelne Künstler Artist selbst kann wiederum dadurch gefunden werden, dass die queryArtists()Methode mit einem Abfrageparameter byName aufgerufen wird – Rückgabewert ist eine Liste von Artists
  • Auf gleiche Weise kann von der Wurzel (oder „Top-Abfrage“) aus jeder Knoten in unserem vollständigen Entity-Graphen erreicht werden: Query->Artist->Album ->Track Z.B. Lassen sich so alle Titel der Alben „eines bestimmten“ Künstlers durch diese GraphQL-Abfrage ermitteln:
{
queryArtists(byName:"Red Hot Chili Peppers") {
 albums {
   name
   tracks {
     name
   }
 }
}
}
  1. Auch wenn GraphQL nur baumartige Daten liefern kann, kann die Abfrage auch Daten aus einem zyklischen Graphen abrufen. Das kann man im Beispiel gut im artists Feld im Track sehen. Es ist mir der Suche in Twitter nach allen „Follower der Follower“ eines Twitter-Benutzers vergleichbar. Man kann so natürlich schnell eine sehr große Ergebnismenge erhalten. Mehr dazu in einem folgenden Blog über Caching.

Schnellstart mit Mocking Server

Mit den vielen Informationen, die bereits im Schema stecken, kann man es direkt verwenden um Beispieldaten für den Betrieb mit einem Mock-Servers zu modellieren. So kann man alle „Strings“ oder Zahlen mit Zufallswerten befüllen, und erhält grundsätzlich Daten mit den gleichen Strukturen.

Um dies zu demonstrieren, verwenden wir den mockServer aus der graphql-tools Bibliothek und erzeugen dynamische Dummy-Daten:

// test/schemaTest.js
import { mockServer } from 'graphql-tools';

let counter = 0;
const simpleMockServer = mockServer(schema, {
    String: () => 'loremipsum ' + (counter ++),
    Album: () => {
        return {
            name: () => { return 'Album One' }
        };
    }
});

const result = simpleMockServer.query(`{
  queryArtists(artistNameQuery:"Marilyn Manson") {
    name
    albums {
      name
      tracks {
        name
        artists { name }
      }
    }
  }
}`).then(result => console.log(JSON.stringify(result, '  ', 1)));

In dem Beispielprojekt kann es mit folgendem Aufruf ausprobiert werden

npm run simpletest

Ergebnis:

{
 "data": {
  "queryArtists": {
   "name": "loremipsum 1",
   "albums": [
    {
     "name": "Album One 2",
     "tracks": [
      {
       "name": "loremipsum 3",
       "artists": [
        {
         "name": "loremipsum 4"
        },
        {
         "name": "loremipsum 5"
        },
        "..." ] } ] } ] } } }

Wie man es flexibel anpassen kann, lasst sich hier nachlesen: apollo’s graphql-tools mocking

Natürlich könnte man anspruchsvollere Mock-Datenobjekte modellieren und nutzen, aber es reicht völlig, um es für eine erste client-seitige Entwicklung mit unserem Schema zu benutzen.

Abfragen/Laden und Senden von echten Musikinfo-Daten

Um „echte“ Daten zu erhalten, müssen wir die Resolver-Funktionen – so ähnlich wie die ‚hi‘-Funktion oben – implementieren.

Resolver-Methoden

Im nächsten Abschnitt werden wir nun die Methoden implementieren, um die Daten zu erzeugen oder zu ermitteln, also die eigentliche Server-Logik für die Abfragen, die so genannten resolver-Methoden. Sie werden verwendet, um Daten aus einer beliebigen Datenquelle abzurufen.

Die verschiedenen Arten von resolver-Funktionen:

  • Rückgabe eines konstanten Wertes / Objekts: z.B. "Hello world"
  • eine Funktion: z.B. new Date().getFullYear()
  • eine Funktion, die einen Promise zurückgibt, das asynchron aufgelöst wird: z.B. fetch("from_url")
  • keine explizite Funktion/fehlend: Wird kein resolver definiert, dann wird der Wert automatisch aus einer Eigenschaft des Objekts mit demselben Namen abgeleitet. Wir werden das z.B. in toArtist() verwenden, wo wir einfach den ES6 spread operator nutzen, um die Werte aus der JSON-Antwort direkt zu übernehmen, also z.B. name , siehe unten.

Wir können Argumente in den resolver-Methoden verwenden, was für unsere Abfrage wichtig ist. Wir werden das später in unserem Beispiel in queryArtists() sehen. Wir könnten hier jede andere Suche hinzufügen, oder später zum Beispiel auch queryUser() …

Promise-Objekte sind sehr interessant, weil sie uns erlauben, viele Datenabfragen asynchron laufen zu lassen, ohne einzeln auf die Ergebnisse warten zu müssen!

Mit diesen Möglichkeiten können wir alle mächtigen Bibliotheken ohne großen Aufwand integrieren und nutzen! Es ist einfach, z.B. Mongoose zu verwenden. Oder Github-, Twitter- oder beliebige andere Client-Bibliotheken. Um einen ersten Eindruck zu vermitteln, habe ich die Abfrage der REST-API von Spotify weiter unten implementiert.

Abfrage für Künstler mit bestimmten Namen

In unserem Fall beginnen wir mit der Abfrage für einen Künstler mit bestimmten Namen:

Es kann eine leere Liste oder sogar mehrere Treffer geben.

Zur Erinnerung: Jedes ‚Feld‘ in rootValue entspricht einer ‚Top-query‘ mit demselben Namen. Bisher hatten wir nur hi. Wir fügen folgende queryArtists-resolver-Implementierung hinzu:

// ... in server.js :

app.use('/graphql', expressGraphQL(req => ({
    schema,
    rootValue: {
        
        queryArtists: (queryArgs) => fetchArtistsByName(queryArgs.byName)
        
    }
    // ...
})));

Hinweis: Alternativ hätten wir diese Logik auch direkt in die Schema Definition 1 hinzufügen können. Dadurch würde die Logik für die Datenabfrage mit der Typdefinitionen gemischt wird. Ich bevorzuge deshalb die zweite Variante der Schema-Definition, denn dabei lässt sich der Logikanteil besser Unit Tests und getrennt weiterentwickeln.

Achtung: Spotify verlangt ein OAuth Token (Bearer…) im Header der Anfrage, deshalb funktioniert dieses Beispiel nicht ohne weitere Anpassungen (Hintergrund). Im Source code auf github, sind die Anpassungen schon integriert. Mehr dazu in einem der nächsten Blog Post…

import fetch from 'node-fetch';

const fetchArtistsByName = (name) => {
   const headers = {
     "Accept": "application/json",
     // this won't work, just as an example
     // see updated code on github for a solution
     // this will be described a follow-up post
     "Authorization": "Bearer BQBg2HM1Q..."
   };
   return fetch(`https://api.spotify.com/v1/search?q=${name}&type=artist`,
               {headers})
    .then((response) => {  
        return response.json();
        // Konvertierung in ein Javascript Objekt
    })
    .then((data) => {
        return data.artists.items || [];
        // herausfiltern der Liste an Künstlern - mit leerer Liste als Default
    })
    .then((data) => {
        return data.map(artistRaw => toArtist(artistRaw));
        // Transformation jedes einzelnen Elements aus den geladenen Daten
    });
};

Mit den Informationen in der rohen JSON-Antwort müssen wir die Liste der Artists per toArtist () transformieren:

  • So wird hier die Liste der Images so reduziert, dass im image-Feld nur noch ein leerer String oder eine URL des ersten Bildes steht.
  • Alle anderen Felder mit dem gleichen Namen wie im Schema werden dann automatisch übernommen, alle anderen ignoriert.
  • Der Rückgabewert für albums ist eine Funktion, die nur dann aufgerufen wird, wenn tatsächlich albums in der GraphQL-Abfrage angefordert wird!
const toArtist = (raw) => {
    return {
        // füllt mit allen raw-Daten (von ES6 Spread Operator):
        // gleichbedeutend mit explizitem 
        //   name: raw.name,  ... 
        ...raw,

        // else: just takes URL of the first image
        // braucht zusätzliche Logik: Per default eine leere Zeichenfolge, wenn kein Bild vorhanden ist
        // Sonst nur die URL des ersten Bildes
        image: raw.images[0] ? raw.images[0].url : '',

        // ... muss die Alben des Künstlers abrufen:
        albums: () => {
            // ähnlich wie fetchArtistsByName ()
            // gibt ein Promise Objekt zurück, das asynchron aufgelöst wird!
            let artistId = raw.id;
            return fetchAlbumsOfArtist(artistId); // has to be implemented, too ...
        }
    };
};

Zusammenfassung

Wir haben unseren eigenen GraphQL-Server erstellt, der uns grundlegende Informationen vom Spotify-API-Server lädt. Jetzt können wir mit dem Laden der Daten zu einem Künstler in Graphiql beginnen: Beim Aufruf über direktem Link von unserem lokalen Server http://localhost:4000/graphql?query=%7B%0A%20%20queryArtists…. oder von der Live Demo https://spotify-graphql-server.herokuapp.com/graphql?query=…. erhalten wir: graphiql-artists

Der Quellcode der Demoversion findet sich auf Github, und wird mit den neuesten Features aus dieser Blogreihe aktualisiert werden. Viel Spaß!

In diesem Artikel haben wir bereits kurz einige Vorteile von GraphQL sehen können:

  • Exakte Definition, welche Daten benötigt und übermittelt werden sollen (kein „over-fetching“): für mobile Datenverbindung
  • Abfrage durch Aggregation auf Server-Seite mit nur einem Request: für mobile Datenverbindung
  • Zugriff auf zusammenhängende Daten möglich, so dass die API-Entwicklung viel leichter von den Anforderungen von der Client-Seite aus getrieben werden kann
  • „Vertrag“ durch Typisierung im Schema, der immer erlaubt, jede Abfrage gegen die aktuelle Schema-Definition zu überprüfen: Keine Fehler durch falsche Abfrage, weil JSON response unterschiedliche Feldnamen enthält (wie „Name“ statt „name“)
  • Nur ein einziger Endpoint für Abfragen von verschiedenen Datenquellen notwendig.

In den nächsten Blog-Artikeln werden wir herausfinden, wie wir

  • die Leistung durch Caching auf der Server-Seite mit dataLoader verbessern können,
  • das hochperformante Relay auf der Client-Seite oder sogar in einem mobilen Client React Native verwenden können, und dazu das Schema erweitern müssen,
  • Standard-Express-Features für die Authentifizierung für personalisierte Infos (wie Playlisten) nutzen können,
  • wie der Entwicklungsaufwand durch den Einsatz von GraphQL auf der Client-Seite reduziert wird
  • und einen eigenen Client auf Basis von freien Bibliotheken aus dem awesome GraphQL Öko-System bauen können
Robert Hostlowsky

In den letzten 19 Jahren sammelte Robert Erfahrungen in verschiedenen Rollen in der Softwareentwicklung. Derzeit arbeitet er bei der codecentric AG als Entwickler und technischer Coach für agile Entwicklungspraktiken mit dem Ziel hoher Qualität und Effizienz.
Seit 2012 ist Robert begeisteter Anhänger der Software-Crafts-Bewegung.

Share on FacebookGoogle+Share on LinkedInTweet about this on TwitterShare on RedditDigg thisShare on StumbleUpon

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.