Transformieren und Validieren von JSON Requests in Play2

Als Anfänger in der Arbeit mit Scala stehe ich aktuell vor der Aufgabe, eine einfache Webservice API in Play2 zu realisieren.

Der Webservice hat einen Endpunkt /api/experiments, über den via POST ein A/B Test (bzw. Experiment) mit 2 oder mehr Varianten angelegt werden kann.

Der Body eines solchen Requests ist eine JSON Struktur, die den A/B Test beschreibt:

{
  "name": "Checkout page buttons",
  "scope": 100.0,
  "variations": [
    {
      "name": "Group A",
      "weight": 70.0
    },
    {
      "name": "Group B",
      "weight": 30.0
    }
  ]
}

Mit dieser Struktur wird ein A/B Test erzeugt, der zwei Gruppen kennt (A und B), wobei in Gruppe A 70% der Benutzer wandern, in Gruppe B 30%.

Auf die fachlichen Aspekte des Systems möchte ich aber gar nicht weiter eingehen. Wer interessiert ist tiefer einzusteigen findet unter https://github.com/meinauto/freeab-server eine Node.js Implementation, die ich unter https://github.com/manuelkiessling/freeab-server-scala derzeit nach Scala überführe.

An dieser Stelle möchte ich lediglich auf die Verarbeitung des JSON Body eines eingehenden POST Requests innerhalb des Play2 Controllers eingehen, insbesondere in Hinblick auf die fachliche Validierung der JSON Struktur und ihrer Überführung in Models in der Applikation.

Aktuell kennt die Applikation zwei fachliche Entitäten: Experiment und Variation, wobei ein Experiment eine Liste von mindestens zwei oder mehr Variation Entitäten beinhaltet.

Jede dieser Entitäten ist im Code repräsentiert in je zwei verschiedenen Models. Ein FormExperiment ist eine case Klasse welche das Model eines von außen über den Request eingehenden, aber noch nicht persistierten Experiment darstellt. Diesem Model fehlt im Gegensatz zum persistierten Model z.B. eine eindeutige ID:

final case class FormExperiment(
  name: String,
  scope: Double,
  formVariations: List[FormVariation]
)

Ein FormExperiment wiederum beinhaltet eine Liste von FormVariation Objekten:

final case class FormVariation(
  name: String,
  weight: Double
)

Ein FormExperiment mit einer Liste von FormVariation Objekten wird durch die Persistierung zu einem Experiment mit einer Liste von Variation Objekten, und damit zu “richtigen” fachlichen Entitäten im Sinne der Businesslogik der Anwendung. Auf die Details hierzu gehe ich jedoch nicht ein - im Folgenden zeige ich, wie aus dem JSON des eingehenden Requests ein FormExperiment mit einer Liste von FormVariation Objekten wird, und wie sichergestellt wird dass dies nur geschieht, wenn die JSON Struktur syntaktisch und fachlich korrekt ist.

“Fachlich korrekt” meint hier beispielsweise die Anforderung, dass die Summe aller weight Attribute der Variationen immer genau 100 (Prozent) ergeben muss, oder dass innerhalb eines Experiments die Namen der Variationen eindeutig (im Sinne von unique) sein müssen.

Aber eins nach dem anderen. Wie kann man den JSON Body innerhalb eines Scala Play2 Controllers überhaupt lesen? Play2 bringt eine sehr mächtige JSON Bibliothek mit. Diese verfügt über sogenannte Reads. Dies ist ein Trait welcher es ermöglicht, JSON beispielsweise in case Klassen zu überführen. Für eine FormVariation sieht das beispielsweise so aus:

import play.api.libs.json._
import play.api.libs.functional.syntax._

final case class FormVariation(
  name: String,
  weight: Double
)

implicit val formVariationReads: Reads[FormVariation] = (
  (JsPath \ "name").read[String] and
    (JsPath \ "weight").read[Double]
  )(FormVariation.apply _)

Wie man sieht muss man lediglich einen Reads definieren, der die einzelnen Komponenten der JSON Struktur auf die richtigen Typen mappt und dann in die case Klasse transformiert. Wie dieser dann zum Einsatz kommt, sehen wir später.

Diese Reads können auch geschachtelt werden, so dass wir unsere Entitätenstruktur, bei der ein FormExperiment neben direkten Attributen auch noch eine Liste von FormVariation Objekten beinhaltet, einfach abbilden können:

import play.api.libs.json._
import play.api.libs.functional.syntax._

final case class FormVariation(
  name: String,
  weight: Double
)

implicit val formVariationReads: Reads[FormVariation] = (
  (JsPath \ "name").read[String] and
    (JsPath \ "weight").read[Double]
  )(FormVariation.apply _)
  
final case class FormExperiment(
  name: String,
  scope: Double,
  formVariations: List[FormVariation]
)

implicit val formExperimentReads: Reads[FormExperiment] = (
  (JsPath \ "name").read[String] and
    (JsPath \ "scope").read[Double] and
      (JsPath \ "variations").read[List[FormVariation]]
  )(FormExperiment.apply _)

Nun haben wir für alle Entitäten eine case Klasse definiert sowie jeweils einen Reads, der eine entsprechende JSON Struktur in diese Entitäten überführen kann. Setzen wir das Ganze in Bewegung:

package controllers

import play.api.mvc._
import play.api.libs.json._
import play.api.libs.functional.syntax._

object Experiments extends Controller {

  final case class FormVariation(
    name: String,
    weight: Double
  )

  implicit val formVariationReads: Reads[FormVariation] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "weight").read[Double]
    )(FormVariation.apply _)

  final case class FormExperiment(
    name: String,
    scope: Double,
    formVariations: List[FormVariation]
  )

  implicit val formExperimentReads: Reads[FormExperiment] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "scope").read[Double] and
        (JsPath \ "variations").read[List[FormVariation]]
    )(FormExperiment.apply _)

  def save = Action(BodyParsers.parse.json) { request =>
    val formExperimentResult = request.body.validate(formExperimentReads)
    Ok("Got the JSON!")
  }
}

Dieser einfache Controller mit der Methode save nimmt den POST Request entgegen. Er validiert den Request Body, dies sorgt automatisch auch für eine Transformation in die Zielklassen.

Die Operation kann gelingen oder fehlschlagen - die beiden Ergebnisarten kann man einfach mit einem match behandeln:

package controllers

import play.api.mvc._
import play.api.libs.json._
import play.api.libs.functional.syntax._

object Experiments extends Controller {

  final case class FormVariation(
    name: String,
    weight: Double
  )

  implicit val formVariationReads: Reads[FormVariation] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "weight").read[Double]
    )(FormVariation.apply _)

  final case class FormExperiment(
    name: String,
    scope: Double,
    formVariations: List[FormVariation]
  )

  implicit val formExperimentReads: Reads[FormExperiment] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "scope").read[Double] and
        (JsPath \ "variations").read[List[FormVariation]]
    )(FormExperiment.apply _)

  def save = Action(BodyParsers.parse.json) { request =>
    val formExperimentResult = request.body.validate(formExperimentReads)
    formExperimentResult match {
      case e: JsError => {
        BadRequest("Something went wrong!")
      }
      case s: JsSuccess[FormExperiment] => {
        val formExperiment = s.get
        Ok("Received the experiments with name " + formExperiment.name)
      }
    }
  }
}

Eine eigene fachliche Validierung kann nun sehr einfach hinzugefügt werden, indem man den zu prüfenden reads Schritt mit einem Filter verknüpft. Ein Filter prüft das jeweilige transformierte Element auf Basis einer eigenen Funktion, die true bei Erfolg und false bei einem Fehlschlag zurückliefern muss. Ein Fehlschlag erzeugt dann einen ValidationError, der zum Abbruch der Transformation und zu einem JsError führt:

package controllers

import play.api.data.validation.ValidationError
import play.api.mvc._
import play.api.libs.json._
import play.api.libs.functional.syntax._

object Experiments extends Controller {

  final case class FormVariation(
    name: String,
    weight: Double
  )

  implicit val formVariationReads: Reads[FormVariation] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "weight").read[Double]
    )(FormVariation.apply _)

  final case class FormExperiment(
    name: String,
    scope: Double,
    formVariations: List[FormVariation]
  )

  implicit val formExperimentReads: Reads[FormExperiment] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "scope").read[Double] and
        (JsPath \ "variations").read[List[FormVariation]]
          .filter(ValidationError("The sum of the variation weights must be 100.0"))(
            _.map(_.weight).sum == 100.0
          )
    )(FormExperiment.apply _)

  def save = Action(BodyParsers.parse.json) { request =>
    val formExperimentResult = request.body.validate(formExperimentReads)
    formExperimentResult match {
      case e: JsError => {
        BadRequest("Something went wrong!")
      }
      case s: JsSuccess[FormExperiment] => {
        val formExperiment = s.get
        Ok("Received the experiments with name " + formExperiment.name)
      }
    }
  }
}

Hier ist die Prüfung sehr einfach und kann inline als anonyme Funktion deklariert werden. Bei komplexeren Prüfungen kann man dies natürlich auch über eine zusätzliche Funktion lösen:

package controllers

import play.api.data.validation.ValidationError
import play.api.mvc._
import play.api.libs.json._
import play.api.libs.functional.syntax._

object Experiments extends Controller {

  private def areVariationsNamesUnique_?(formVariations: List[FormVariation]): Boolean = {
    formVariations.map(_.name).distinct.size == formVariations.size
  }

  final case class FormVariation(
    name: String,
    weight: Double
  )

  implicit val formVariationReads: Reads[FormVariation] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "weight").read[Double]
    )(FormVariation.apply _)

  final case class FormExperiment(
    name: String,
    scope: Double,
    formVariations: List[FormVariation]
  )

  implicit val formExperimentReads: Reads[FormExperiment] = (
    (JsPath \ "name").read[String] and
      (JsPath \ "scope").read[Double] and
        (JsPath \ "variations").read[List[FormVariation]]
          .filter(ValidationError("The sum of the variation weights must be 100.0"))(
            _.map(_.weight).sum == 100.0
          )
          .filter(ValidationError("Variations names must be unique"))(areVariationsNamesUnique_?)
    )(FormExperiment.apply _)

  def save = Action(BodyParsers.parse.json) { request =>
    val formExperimentResult = request.body.validate(formExperimentReads)
    formExperimentResult match {
      case e: JsError => {
        BadRequest("Something went wrong!")
      }
      case s: JsSuccess[FormExperiment] => {
        val formExperiment = s.get
        Ok("Received the experiments with name " + formExperiment.name)
      }
    }
  }
}

Wie man hier weiterhin sieht, können mehrere Filteroperationen einfach aneinandergekettet werden. Das bei einem Fehlschlag entstehende JsError Objekt enthält alle Validierungsfehler mit ihren Fehlertexten, die z.B. wie folgt zu einer zusammenhängenden Fehlermeldung transformiert werden können:

formExperimentResult match {
  case e: JsError => {
    BadRequest("The following validation errors occured: " +
               e.errors.flatMap(_._2.map(_.message)) mkString ". ")
  }
  // ...
}
Filed under

Who are we and what do we do at Galeria Kaufhof and HBC Europe? We are a passionate and highly motivated team of developers located in Cologne, Germany. We are whole-heartedly committed to modern project workflows, agile ideas and open-source software. We are constantly improving our services to satisfy our beloved customers. We promote openness and always love to share our technology findings with the world. Sounds good? Come and join us!