Een REST API maken

In eerdere edities van Java Magazine heb je al kennis kunnen maken met Akka.
Akka is een open source framework, waarmee je gemakkelijk schaalbare applicaties kunt ontwikkelen. Akka is geïnspireerd door Erlang en maakt gebruik van actoren om processen eenvoudig gelijktijdig uit te voeren. Het Akka framework is geschreven in Scala en kan zowel met Java als met Scala worden gebruikt. In dit artikel laten we zien dat Akka ook uitstekend kan worden gebruikt voor het ontwikkelen van RESTful API’s. Hiervoor gebruiken we een onderdeel van Akka, namelijk Akka HTTP.

Akka HTTP was voorheen bekend als Spray. Deze naam zie je soms nog steeds in de code en configuratie terugkomen. De artifact-naam is nog akka-http-experimental, maar laat je hierdoor niet afschrikken. Dit is puur, omdat er nog wijzigingen op de interface kunnen komen. Akka HTTP is zeker stabiel genoeg om in productie te gebruiken.

 

De RESTful API

Om de kracht van Akka HTTP te illustreren gaan we het framework gebruiken om een eenvoudige RESTful API te ontwikkelen, namelijk de Robots API. Met deze API kunnen we gegevens over onze favoriete robots onderhouden. De API biedt de volgende mogelijkheden:

 

Aan de slag

Nu het bekend is welke beknopte functionaliteiten onze RESTful API aan moet bieden, kunnen we aan de slag. We starten met het opzetten van het project en gebruiken een recente versie van Scala (2.11.7) en Akka (2.4.4). Bij Scala-projecten gebruikt men vaak SBT (Scala Build Tool), maar voor dit project gebruiken we Gradle, omdat ik dit prettiger vind werken en om te laten zien hoe eenvoudig dit is:


apply plugin: 'scala'
apply plugin: 'application'
 
repositories {
  mavenCentral()
}
 
ext {
  akkaVersion = "2.4.4"
}
 
dependencies {
  compile group: 'org.scala-lang', name: 'scala-library', version: '2.11.7'
  compile group: 'com.typesafe.akka', name: 'akka-actor_2.11', version: akkaVersion
  compile group: 'com.typesafe.akka', name: 'akka-http-experimental_2.11', version: akkaVersion
  compile group: 'com.typesafe.akka', name: 'akka-http-spray-jsonexperimental_2.11', version: akkaVersion
}
 
mainClassName = "RobotsApiApp"

 

Routes en Directives                          

Het hart van Akka HTTP is de Route. De Route bepaalt wat er uiteindelijk gebeurt met de binnenkomende requests en wordt opgebouwd met behulp van Directives. Directives zijn kleine eenvoudige bouwblokken, die je kunt nesten of combineren met ~.

Akka levert standaard een aantal Directives voor verschillende doeleinden:

  • Vertakken aan de hand van de HTTP-methode: get, post
  • Extractie van parameters: parameters
  • Extractie van headers: headerValueByName
  • Authenticatie: authenticateBasic
  • Marshalling: complete, entity, handleWith

In het onderstaande voorbeeld zie je dat we eerst splitsen op het URL-pad. Bij een leeg pad (http://localhost:8080/) geven we de API-documentatie terug. En onder "/robots" splitsen we aan de hand van de HTTP-methode, zodat je de correcte afhandeling kunt doen. Met complete geven we het antwoord terug. Nu nog in de vorm van eenvoudige tekst. Voor de DELETE lezen we het laatste stukje URL-pad, dat is de naam van de robot.

De Route ziet er dan als volgt uit (aan het einde van dit artikel volgt de volledige code).


val route: Route =
  pathPrefix("robots") {
    get {
      complete("Lijst met alle robots")
    } ~ post {
      complete("Voeg een robot toe")
    } ~ delete {
      path(Segment) { naam =>
        complete(s"Verwijder robot $naam")
      }
    }
  } ~ path("") {
    complete("Robots API documentatie")
  }

De werking hiervan kun je testen met curl:


$ curl http://localhost:8080
Robots API documentatie
 
$ curl -X POST http://localhost:8080/robots
Voeg een robot toe
 
$ curl -X DELETE http://localhost:8080/robots/Asimo
Verwijder robot Asimo

 

Marshalling

Nu gaan we een domeinobject aanmaken, dat naar JSON kan worden gemarshalled.


case class Robot(naam: String, kleur: Option[String], aantalArmen: Int) {
  require(aantalArmen >= 0, "Robots kunnen geen negatief aantal armen hebben!")
}

In onze API houden we de lijst met bekende robots bij. Die vullen we voor het gemak alvast met twee robots:


var robots = List(Robot("R2D2", Some("wit"), 0), Robot("Asimo", None, 2))

Akka weet al hoe de standaardklassen (zoals String, List, Int) moeten worden gemarshalled. Voor onze domeinklasse moet je een impliciete variabele toevoegen. Met jsonFormat3 geven we aan dat Robot een klasse is met 3 instance-variabelen (naam, kleur en aantalArmen).


implicit val RobotFormat = jsonFormat3(Robot)

Door het toevoegen van de marshallers is Akka in staat om de robot-objecten om te zetten naar JSON en van JSON naar robot-objecten. Met complete kunnen we nu simpelweg de lijst met robots teruggeven. Voor de POST gebruiken we handleWith. Dit vertaalt de input naar ons domeinobject en het vertaalt ook het uiteindelijke resultaat weer naar JSON. We geven hier de nieuwe robot weer terug.


val route: Route =
  path("robots") {
    get {
      complete(robots)
    } ~ post {
      handleWith { robot: Robot =>
        robots = robot :: robots
        robot
      } 
    }
  } ~ path("") {
    complete("Robots API documentatie")
  }

We kunnen dit nu weer testen met curl.


$ curl http://localhost:8080/robots
 
[{
  "name": "R2D2",
  "color": "white",
  "amountOfArms": 0
}, {
  "name": "Asimo",
  "amountOfArms": 2
}]
 
$ curl -H "Content-Type: application/json" -X POST -d '{"naam": "C3PO", "kleur":
"goud", "aantalArmen": 2}' http://localhost:8080/robots
 
{
  "naam": "C3PO",
  "kleur": "goud",
  "aantalArmen": 2
}

 

Validatie

Als je ongeldige input geeft, dan krijg je ook netjes foutmeldingen terug. Bijvoorbeeld als je een String meegeeft, waar de API een Int verwacht.


$ curl -H "Content-Type: application/json" -X POST -d '{"naam": "C3PO", "kleur":
"goud", "aantalArmen": "veel"}' http://localhost:8080/robots
 
The request content was malformed:
Expected Int as JsNumber, but got "veel"


Kleur is een optioneel veld, dus die hoef je niet mee te geven. De andere velden zijn wel verplicht.
$ curl -H "Content-Type: application/json" -X POST -d '{"kleur": "groen",
"aantalArmen": "1"}' http://localhost:8080/robots
 
The request content was malformed:
Object is missing required member 'naam'

In de Robots klasse hebben we een requirement toegevoegd. Ook deze wordt netjes gecontroleerd en doorgegeven.

 

Opstarten van de applicatie

Er zijn verschillende manieren om de ontwikkelde applicatie op te starten:

  • Direct vanuit je IDE: door in het app object een main method aan te maken.
  • Vanuit Gradle: door in de build.gradle de application plugin toe te voegen. Dit maakt het mogelijk om de applicatie te starten met behulp van het commando: gradle run.
  • Als een fatJar: door het gebruik de gradle-one-jar Gradle plugin om één jar te maken met daarin onze code en alle dependencies. Een voorbeeld hiervan is terug te vinden in het complete code-voorbeeld op GitHub. Start de applicatie vervolgens met java –jar <jar-naam>.

 

Configuratie

Akka leest zijn configuratie standaard uit application.conf. Dit is in HOCON-formaat. Daarmee is het eenvoudig om een gestructureerde configuratie te maken. Je kunt hier ook de default-waarden van Akka overschrijven. In het onderstaande voorbeeld passen we bijvoorbeeld het niveau van loggen aan. Ook kun je hier prima je eigen configuratie-instellingen in kwijt, zoals het poortnummer waar de API op luistert.


port = 8080
akka {  
  loglevel = "DEBUG"
}

Deze configuratie is vervolgens uit te lezen in je Actor met:


val port = system.settings.config.getInt("port")

 

Logging

Met het directive logRequestResult kunnen we alle requests en responses loggen. Ook kun je zelf logging toevoegen met system.log.info. Als je nu een request doet, zie je dat mooi in de logging.


[INFO] [04/27/2016 14:16:32.534] [RobotSystem-akka.actor.default-dispatcher-4]
[akka.actor.ActorSystemImpl(RobotSystem)] We hebben nu 3 robots.
[DEBUG] [04/27/2016 14:16:32.558] [RobotSystem-akka.actor.default-dispatcher-4]
[akka.actor.ActorSystemImpl(RobotSystem)] RobotsAPI: Response for
  Request : HttpRequest(HttpMethod(POST),http://localhost:8080/robots,List(User-Agent:
curl/7.38.0, Host: localhost:8080, Accept: */*, Timeout-Access:
<function1>),HttpEntity.Strict(application/json,{"naam": "C3PO", "kleur": "goud",
"aantalArmen": 2}),HttpProtocol(HTTP/1.1))
  Response: Complete(HttpResponse(200 OK,List(),HttpEntity.Strict(application/json,{
  "naam": "C3PO",
  "kleur": "goud",
  "aantalArmen": 2
}),HttpProtocol(HTTP/1.1)))

 

De volledige code

De volledige, compacte en beschrijvende code van de API vind je hieronder. Dit is naast de build file het enige dat nodig is om de applicatie te bouwen en uit te voeren. De code is ook te vinden op GitHub: https://github.com/tammosminia/sprayApiExample/tree/javaMagazine/robotsApi.


import akka.actor.ActorSystem
import akka.http.scaladsl.Http
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.stream.ActorMaterializer
import akka.util.Timeout
import spray.json.DefaultJsonProtocol
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._
 
case class Robot(naam: String, kleur: Option[String], aantalArmen: Int) {
  require(aantalArmen >= 0, "Robots kunnen geen negatief aantal armen hebben!")
}
 
object RobotsApiApp extends App with SprayJsonSupport with DefaultJsonProtocol {
  implicit val system = ActorSystem("RobotSystem")
  implicit val materializer = ActorMaterializer()
  implicit val executionContext: ExecutionContext = system.dispatcher
  implicit val timeout = Timeout(5.seconds)
 
  val port = system.settings.config.getInt("port")
 
  implicit val RobotFormat = jsonFormat3(Robot)
 
  var robots = List(Robot("R2D2", Some("wit"), 0), Robot("Asimo", None, 2))
 
  val route: Route = logRequestResult("RobotsAPI") {
    pathPrefix("robots") {
      get {
        complete(robots)
      } ~ post {
        handleWith { robot: Robot =>
          robots = robot :: robots
          system.log.info(s"We hebben nu ${robots.size} robots.")
          robot
        }
      } ~ delete {
        path(Segment) { naam =>
          robots = robots.filter { _.naam != naam }
          complete(s"robot $naam verwijderd")
        }
      }
    } ~ path("") {
      complete("Robots API documentatie")
    }
  }
 
  val bindingFuture = Http().bindAndHandle(route, "localhost", port)
  println(s"Robots API - http://localhost:$port/")
}