Next-generation web APIs

Tegenwoordig kun je er bijna niet meer omheen. Of het nu een mobiele app is die het spoorboekje inlaadt, of het een bestandssysteem is dat een back-up maakt in de cloud, of een heel applicatielandschap aan microservices dat berichten over en weer stuurt, de meeste programma’s maken gebruik van webinterfaces, of web APIs. Hoewel zeker niet de enige manier, zijn web APIs de de facto standaard geworden om verschillende (componenten van) applicaties met elkaar te laten communiceren.

Een API in zijn algemeenheid is een goed gedefinieerde interface om jouw applicatie tegenaan te bouwen; het staat dan ook letterlijk voor application programming interface. Dit zouden functies en klassen in een geïmporteerde library kunnen zijn, of de door het besturingssysteem aangeboden events als muisklikken en toetsaanslagen. Steeds vaker wordt echter gekozen voor een over het internet benaderbare interface, een web API. Dit stelt programma’s die op gescheiden omgevingen draaien, in staat op met elkaar te praten. De programma’s kunnen op andere computers of servers staan, maar ze kunnen ook in een eigen sandbox-omgeving (container) draaien, in bijvoorbeeld Docker of Kubernetes.

Een van de redenen dat web APIs zo in trek zijn, is dat virtueel elke taal je in staat stelt een internetverbinding te openen. Daarnaast hoef je alleen een manier te vinden om informatie als tekst te representeren, en je hebt een basale web API. Voor de tekstrepresentatie gebruikt men veelal het JSON (JavaScript Object Notation) formaat. Dit is een erg lichtgewicht notatie, die de meest veelvoorkomende datatypes (tekst, getallen, objecten en lijsten) intuïtief kan weergeven.

REST

Met alleen een internetverbinding en een JSON-representatie zijn we er nog niet. Hoe definiëren we welke functies (endpoints in webtermen) beschikbaar zijn? De meestgebruikte API-structuur is REST, wat staat voor REpresentational State Transfer. REST definieert een aantal conventies waaraan een webserver dient te voldoen. Een client-applicatie vraagt informatie op aan de server middels de HTTP-methode GET en stuurt informatie op middels een POST. Entiteiten verwijder je met een DELETE. Hoe een server zijn API moet definiëren, lopen de meningen over uiteen. Een veelgebruikte handleiding is die van Zalando. Ook over het terug te geven antwoord is geen eenduidige visie. Als er wat mis is, in de aangeleverde data of in de interne logica van de server, geeft de server een foutcode terug, zoals de klassieke 404 als een endpoint of resource niet gevonden is. De enige eis voor deze foutcodes is dat ze gedocumenteerd zijn per functie. Bij voorkeur zijn ze logisch.

De server is degene die de REST-interface definieert. De clients maken alleen gebruik van de gegeven interface. Als een client de lijst van alle films op wil vragen, dan bepaalt de server welke informatie er in het antwoord zit. De server kan besluiten om bijvoorbeeld alleen een identificatietoken en een titel terug te geven, terwijl de client eigenlijk ook een jaar van uitgave had gewild. Indien zowel de server als de client bij één partij in beheer zijn, zoals bij verschillende projecten bij Sogyo, is het ontbreken van informatie in dit voorbeeld slechts irritant. We passen het endpoint aan zodat deze ook het jaar teruggeeft en we kunnen het jaar van uitgave in de lijst tonen. Als de API echter bij een derde partij in onderhoud is, kunnen we ons erbij neerleggen dat we de informatie niet gaan krijgen, we kunnen de informatie later er bij vragen (maar is langzaam), of we kunnen een RFC (request for change) indienen bij de betrokken partij. Een andere optie is dat de server standaard alle beschikbare informatie teruggeeft, maar dit zorgt voor onnodige belasting van de database en van het netwerkverkeer.

Als we dit in extremis trekken, kunnen we zeggen dat een REST API van alle clients moet weten wat ze van de API verlangen. Als een client op verschillende momenten verschillende wensen heeft, moet de server hiervoor twee alternatieven bieden. Dit zorgt voor een onzichtbare verstrengeling tussen client en API. In het geval van microservices is het idee juist om een grote, monolithische applicatie op te knippen in vele kleintjes, en daarmee deze complexiteit te vermijden. De verstrengeling die REST met zich mee kan brengen, kan ervoor zorgen dat je in plaats van een net microservicelandschap, een gedistribueerde monoliet neerzet. Met andere woorden houd je de spaghettibrij, maar dan versnippert over meerdere applicaties. In een van de projecten liepen we er tegenaan dat een stuk functionaliteit over drie verschillende Git repositories strekte. Hierdoor werd een kleine wijziging al snel omslachtig. In een ander project hadden we een verzameling vergelijkbare endpoints, die een net iets andere kijk op dezelfde data teruggaven. Hiermee vulden we andere schermen in dezelfde applicatie.

GraphQL

Wij zijn niet de eersten die tegen dit probleem zijn aangelopen. Facebook heeft gepoogd dit op te lossen door geen interface-framework te definiëren, maar een querytaal. Dit is GraphQL geworden, een graaf-gebaseerde querytaal. Tegenwoordig is het project open source en hangt het onder de onafhankelijke GraphQL-stichting (onderdeel van The Linux Foundation).

Net zoals een REST-server, definieert een GraphQL-server een aantal endpoints. Het essentiële verschil is dat GraphQL de client in staat stelt om een eigen, op maat gemaakte query op te stellen die mixt en matcht uit de aangeboden informatie. Ik zal hier een korte indruk geven van wat GraphQL is. Voor de volledige filosofie kan ik de documentatie aanraden. Ze houden de uitleg conceptueel en taalonafhankelijk. Als we naar het volgende korte voorbeeld kijken:

query {
  hero {
    name
    friends {
      name
    }
  }
}

Wat we hier zien is een query, een verzoek om data. Het standaardvoorbeeld van GraphQL is Star Wars. Binnen Star Wars vragen wij een held op. Van die held willen wij de naam en de vrienden (waarvan ook de naam) weten. De server biedt potentieel meer informatie aan, mochten we daar behoefte aan hebben. Aangezien we alleen om de namen hebben gevraagd, krijgen we ook slechts terug

{
  "data": {
    "hero": {
      "name": "R2-D2",
      "friends": [
        {
          "name": "Luke Skywalker"
        },
        {
          "name": "Han Solo"
        },
        {
          "name": "Leia Organa"
        }
      ]
    }
  }
}

De client bepaalt wat er in het antwoord zit op basis van de zoekopdracht. De server haalt alleen extra informatie op als dit hem gevraagd wordt. De server biedt een flexibele API waar de client naar behoefte informatie uit kan opvragen, met in principe arbitraire complexiteit. Als we iets willen veranderen aan de data, doen we dat met een mutatie:

mutation CreateReviewForEpisode($ep: Episode!, $review: ReviewInput!) {
  createReview(episode: $ep, review: $review) {
    stars
    commentary
  }
}
-----
{
  "ep": "JEDI",
  "review": {
    "stars": 5,
    "commentary": "This is a great movie!"
  }
}

In dit geval willen we een review onder een aflevering hangen. De precieze syntactische betekenis daargelaten, geven we aan de server door dat we op basis van een ep en een review parameter de createReview aanroepen. Alle mutaties werken op deze manier, dus ook het aanpassen, verwijderen of offeren van het eerstgeboren kind.

Als de server ruimhartig genoeg is in het beschikbaar stellen van data, dan is er weinig noodzaak om van het doel van de client af te weten. De clientapplicatie kan voor verschillende schermen hetzelfde endpoint aanroepen (bijv. hero) en andere informatie vragen. Dit reduceert de complexiteit van de server en ontvlecht de koppeling tussen endpoint en scherm.

Dat de server niet weet welke vraag de afnemer gaat stellen, heeft ook een duidelijk nadeel. Je kunt geen geoptimaliseerde zoekopdracht aan de database geven. Het voorgestelde mechanisme om de query te interpreteren is als een graaf: we beginnen bij een object (hero R2-D2) en halen vervolgens de naam en later de vrienden op. Dit betekent dat de server meerdere rondritjes maakt naar de database om een verzoek in te willigen. Als we de server onverhoopt vragen om een narcistische held, die zichzelf als vriend heeft, betekent dat bovendien dat dezelfde entiteit twee keer uit de database gehaald wordt. Hier zijn natuurlijk ook weer omwegen voor te bedenken. Een van die omwegen is een zogenaamde dataloader, een aparte klasse die entiteiten in het geheugen houdt. Hierdoor verliest de server wel een deel van zijn simpliciteit en elegantie.

En nu in het echt

Om een gevoel te krijgen bij hoe GraphQL werkt, heb ik een kleine voorbeeldapplicatie gemaakt. Mijn projectje is deels geïnspireerd door het Star Wars-thema en deels door een intern project waar ik twee jaar aan werkte. Ik doopte het project tot Graph Dracula. In Graph Dracula kan de gebruiker films en acteurs beheren en aangeven wie er in welke film meespeelt met welke rol. Uit ervaring met het echte project bleek al dat er vele kijken op dezelfde data zijn: ik wil een film zien met daarbij de acteur, ik wil de acteur zien met de films waarin hij speelt en in verschillende schermen wil ik verschillende details zien om acteurs dan wel films te kunnen onderscheiden.

Aangezien de meeste van de projecten van Sogyo in C#/.NET plaatsvinden, heb ik me voor de server op die taal gericht. Ik heb gekeken naar GraphQL.NET en Hot Chocolate. Uiteindelijk ben ik voor de laatste gegaan, omdat deze een moderne integratie biedt met ASP.NET Core (het vlaggenschip multiplatform webframework van Microsoft). Naast Hot Chocolate gebruikte ik de standaardtoolkit voor .NET-webservices, namelijk het eerder genoemde ASP.NET Core en database mapper EntityFrameworkCore. Voor de daadwerkelijke opslag gebruikte ik een MSSQL Docker container. Alle code die ik moest schrijven om een GraphQL-server én een interactieve playground de lucht in te trekken, is:

public class Startup
{
    protected override void ConfigureServices(IServiceCollection services)
    {
        // Registreer andere afhankelijkheden als een database.

        services.AddGraphQL(c => { 
            c.RegisterQueryType<ObjectType<Query>>();
            c.RegisterMutationType<ObjectType<Mutation>>();
        });
    }

    protected override void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        // Zet andere dingen op.

        app.UseGraphQL(); // Registreer GraphQL
        app.UsePlayground(); // Zorg voor een interactieve site om deze GraphQL-server mee uit te proberen
    }
}

Dit is de zogenaamde startup-klasse die standaard gegenereerd wordt bij het opzetten van een nieuw ASP.NET Core-project. Ik registreer mijn Query en Mutation klassen als types, zodat een client de methoden in deze twee overkoepelende types kan aanroepen. Het query type is het type dat alle getter-endpoints definieert en het mutatietype is het type dat definieert welke acties de data muteren. Waar ik meteen tegenaan liep, was dat veranderingen aan mijn data, aanroepen van functies in het mutatie-type, niet gepersisteerd werden. De klaargezette wijzigingen werden nooit daadwerkelijk naar de database gestuurd. Hiervoor schreef ik een middleware, die bij elk binnenkomend verzoek wordt uitgevoerd. Deze middleware beheert een databasetransactie per request en, als alles volgens plan verlopen is, zet het de veranderingen door naar de database.

Een tweede punt waar ik tegenaan liep, was dat de documentatie van Hot Chocolate soms dubbelzinnig was. Met de hulp van het demoproject waarin ze het voorbeeld met Star Wars uitwerken, en de broncode kwam ik een heel eind. Uiteindelijk had ik met weinig moeite een server de lucht in getrokken. In eerste instantie maakte ik de fout door een te beperkte kijk op de data terug te geven; ik hield de rol van een acteur in een film achter. Dit moest ik na het ontwikkelen van de front-end nog corrigeren. Op een kleine typefout na, hoefde ik verder niet terug te kijken op de GraphQL API die de server aanbood en kon ik los gaan met de UI. Later bedacht ik me dat het zelf nog beknopter kon. Ik maakte een apart endpoint voor een lijst van acteurs en voor individuele acteurs. Een individuele acteur ophalen op ID is echter hetzelfde als een lijst van acteurs ophalen gefilterd op alle acteurs met een bepaald ID.

De front-end wilde ik als losstaande applicatie laten draaien. Ook hier had ik prima het ASP.NET Core-platform kunnen gebruiken (of een Javascript-framework, maar daar praten we niet over). Op basis van de irrationele reden dat ik het framework even beu was, besloot ik om de UI een aparte website te laten zijn, geschreven in vibe.d. Ik wilde vibe.d al een tijd proberen en toon hiermee aan dat het ontwikkelen van een meertalig applicatielandschap tot de mogelijkheden behoort. Tot zover het goedpraten. Later bleek dat er geen GraphQL client library beschikbaar is om van de plank te trekken. Een eigen “library” schrijven was triviaal, aangezien GraphQL bouwt op de elementaire bouwstenen van webcommunicatie. Om op een naïeve wijze te kunnen praten met de API, waren er slechts ~30 regels code nodig. GraphQL biedt mogelijkheden om client-side het model aan introspectie te onderwerpen en aan de hand van de metadata de input te valideren. Dit zit uiteraard niet in mijn minimale implementatie.

Het voordeel van het gebruik van GraphQL laat zich vooral spreken in het verschil tussen het aantal datastructuren aan de client- en aan de serverkant. Voor de verschillende schermen definieert de client aparte datastructuur, terwijl de server een generieke acteur dan wel film aanbiedt. De client stelt in de voorbeeldapplicatie 10 soorten vragen, terwijl de server 5 antwoorden definieert. Het verschil ontstaat doordat de client naar eigen inzicht kan “shoppen” in de aangeboden informatie. De server definieert Film– en Acteur-types. Een film verwijst naar de acteurs die erin spelen en acteurs verwijzen naar de films waarin ze spelen. De client kan enkel de naam en nationaliteit van een acteur opvragen om in een lijst te tonen, maar kan van hetzelfde endpoint gebruik maken om alle informatie van dezelfde acteur en de film waarin die speelt te vragen. Daar waar we naar streven is bereikt: de server is compleet losgekoppeld van de client.

Conclusie

Het is duidelijk dat GraphQL heeft geleerd van de valkuilen van REST. Het voldoet beter aan de eisen die aan een moderne, van alle markten thuis web API gesteld worden dan het starre REST. Voor zover ik nu kan overzien lost GraphQL een aantal problemen op en creëert het geen nieuwe. Andere ontwikkelaars die ik over het protocol spreek, zijn ook allemaal erg positief. Het voornaamste nadeel is dat het een externe afhankelijkheid is die je moet downloaden, terwijl REST in meer of mindere mate ingebakken is. Zeker bij een groter project, waar de initiële opzet wat omslachtiger mag zijn, zal het zich terugbetalen.