Blog

Unkompliziertes Testen von Restful Services mit Dispatch in Scala

Urs Peter

Urs Peter

Aktualisiert Oktober 22, 2025
14 Minuten

Für das Testen einer Restful-Service-API suchte ich nach einer schlanken Bibliothek, mit der ich CRUD-Operationen von Rest-Services mit so wenig Code wie möglich testen kann. Meine Suche führte mich zu Dispatch, einem sehr kompakten Scala-DSL-Wrapper um den zuverlässigen HttpClient von Apache. Diese DSL ist jedoch nicht sehr gut dokumentiert und aufgrund der starken Verwendung von symbolischen Methodennamen schwer zu entschlüsseln, aber dennoch sehr ansprechend, wenn man sie versteht. In diesem Blog werde ich sie für Sie entschlüsseln und zeigen, wie einfach es ist, Restful Services mit einfachen Onelinern zu testen.

Meine Voraussetzungen

Bevor wir in das geheimnisvolle DSL von Dispatch eintauchen, lassen Sie uns einen Blick auf die Voraussetzungen werfen, die ich zum Testen meines Restful-Dienstes hatte. Diese werden wahrscheinlich auch für viele andere Restful-Dienste gelten:

  • Unterstützung für CRUD-Operationen mit den http-Methoden POST, GET, PUT und DELETE
  • Unterstützung für XML und Json als Eingabe- und Ausgabe-Nutzdaten
  • Unterstützung für das Lesen von http-Statuscodes zum Testen der Ergebnisse von fehlerhaften Antworten wie 404 (Not Found)
  • Unterstützung für Sicherheitsaspekte wie https und Basic/Digest-Authentifizierung

Beginnen Sie mit dem Ende im Kopf

Das Ziel dieses Blogs ist es, einen RestClientHelper zu erstellen, der eine Reihe von generischen Restful-Client-Methoden bietet und zu erklären, wie diese mit Dispatch implementiert werden. Mit diesen Methoden werden wir in der Lage sein, Restful Service APIs sehr einfach aufzurufen und zu testen. Der RestClientHelper wird ein Trait sein, der die folgenden clientseitigen CRUD-Methoden bietet: [scala] trait RestClientHelper { //Erstellen (POST) def create[T](target: String, reqBody: String)(fromRespStr: String => T): T def create[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T //Lesen (GET) def Abfrage[T](Ziel: String, params: Seq[(String, String)])( fromRespStr: String => T): (Int, Option[T]) def Abfrage[T](Ziel: String, params: Seq[(String, String)])( fromRespXml: Elem => T): (Int, Option[T]) //Aktualisieren (PUT) def update[T](target: String, reqBody: String)(fromRespStr: String => T): T def update[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T def update[T](target: String, reqBody: String): Int def update[T](target: String, reqBody: Elem): Int //Löschen (DELETE) def delete(Ziel: String) :Int } [/scala] Wie Sie sehen können, bestehen die meisten Methoden aus zwei Parameterlisten. Die erste Parameterliste stellt die Eingabe für den Rest-Aufruf dar, z.B. die Ziel-URI und den Request Body oder die Parameter-Map. Die zweite Parameterliste ist eine Funktion, die die Ausgabe eines Restdienstes in den gewünschten Typ umwandelt. Der Rückgabetyp dieser Methoden ist vom Typ T, Int oder Tuple2[Int, Option[T]]. Int steht immer für den http-Statuscode, Option[T] für die konvertierte Ausgabe, wenn der Dienstaufruf erfolgreich war.

So verwenden Sie den RestClientHelper

Um zu verstehen, wie die RestClientHelper-Eigenschaft verwendet werden soll, sehen wir uns ein Beispiel an. Angenommen, wir haben ein Domänenobjekt, Person, das Serialisierungs- und Deserialisierungsmethoden für xml bereitstellt, dann würde eine CRUD-Aufrufsequenz mit der RestClientHelper-Eigenschaft wie folgt aussehen: [scala] val person = Person("John Doe") //POST /add.xml val p = create("add.xml",person.toXml){Person.fromXml()} assert(p.id != None) //GET /search.xml?q=John+Doe val (status, personOpt) = query("search.xml", Seq("q" -> "John Doe")) { Person.fromXml() } assert(status == 200) //PUT /update.xml val changedPerson = p.copy(.name = "John Who") val status2 = update("update.xml", changedPerson.toXml) assert(status2 == 200) //DELETE /delete/4 val status3 = delete("delete/" + changedPerson.id.get) assert(status3 == 200) //GET /search.xml?q=John+Who [scala] val (status4, personOpt2) = query("search.xml", Seq("q" -> "John Who")) { Person.fromXml() } assert(status4 == 404) assert(personOpt2 == Keine) [/scala]

Ein Blick unter die Haube

Lassen Sie uns nun etwas tiefer gehen und herausfinden, wie eine dieser Rest-Client-Hilfsmethoden tatsächlich mit Dispatch implementiert wird. [scala] def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = { executer(:/(host, port) / target << reqBody >[/scala] - { fromRespStr }) } Nun, das ist alles, was für den Aufruf eines Restful-Dienstes mittels einer POST-Anfrage erforderlich ist. Wenn Sie neu in Dispatch sind, kann es Ihnen helfen, diese sehr kompakte DSL zum besseren Verständnis zu entwirren. Die Kernklassen, die an einem http-Aufruf wie oben gezeigt teilnehmen, sind die folgenden:

  • A HttpExecuter (dispatch.HttpExecuter) der für die Ausführung der http-Anfrage verantwortlich ist. Eine übliche Implementierung wäre dispatch.Http. In diesem Beispiel wird er durch das executer-Objekt repräsentiert.
  • A Anfrage (dispatch.Request) und ihr DSL-Wrapper dispatch.RequestVerbs, die für die Erstellung einer bestimmten Art von Anfrage verantwortlich sind, z.B. ein gzip http-Post mit dem Inhaltstyp application/x-www-form-urlencoded. Die Klasse RequestVerbs enthält DSL-ähnliche symbolische Methodennamen, die meist mit einem '<' Zeichen beginnen und anzeigen, dass etwas zu einer Anfrage 'hinzugefügt' wird (linker Pfeil). Die Methode << ist eine davon. Es fügt der Anfrage einen Anfragekörper hinzu und wandelt sie damit automatisch in eine POST-Anfrage um.
  • A Handler (dispatch.Handler) und sein DSL-Wrapper dispatch.HandlerVerbsdie für die Verarbeitung des vom http-Aufruf zurückgegebenen Ergebnisses zuständig ist, z.B. für die Konvertierung in xml oder json. Die Klasse HandlerVerbs enthält auch symbolische DSL-Methodennamen, die meist mit einem '>' Zeichen beginnen und anzeigen, dass etwas mit der 'Ausgabe' des http-Aufrufs gemacht wird (rechter Pfeil). Die Methode >- ist eine davon. Sie wandelt die als String empfangene Antwort in den gewünschten Typ um.
  • Sowohl Request als auch Handler können durch implizite Konvertierungen in ihre entsprechenden DSL-Wrapper RequestVerbs bzw. HandlerVerbs umgewandelt werden.

Mit diesen Schlüsselklassen im Hinterkopf können wir den obigen http-Aufruf ausführlicher umschreiben, um zu verstehen, aus welchen Teilen sich die DSL zusammensetzt:

[scala]
def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = {
val emptyReq:Request = :/(host, port)
//implicit conversion applied explicitely
val reqVerbs:RequestVerbs = Request.toRequestVerbs(emptyReq)
val configuredReq:Request = reqVerbs./(target).<<(reqBody)
//implicit conversion applied explicitely
val handlerVerbs:HandlerVerbs = Request.toHandlerVerbs(configuredReq)
//The http executer always needs to be called with a handler
val handler:Handler[T] = handlerVerbs.>-(fromRespStr)
executer.apply(handler)
}
[/scala]

Der Executer bedarf einer weiteren Erklärung. Wie gesagt, Dispatch erfordert die Verwendung eines Executers, um eine http-Anfrage endgültig auszuführen. Dispatch bietet verschiedene Arten von Executors an, z.B. thread-sichere und nicht-thread-sichere. Ein thread-sicherer Executor, der einen gemeinsam genutzten Verbindungspool verwendet, könnte wie folgt bereitgestellt werden:

[scala]
protected lazy val executer = new Http with thread.Safety
[/scala]

Auf das Gegenstück ohne Threads kann wie folgt zugegriffen werden:

[scala]
protected def executer = new Http
[/scala]

Es sind verschiedene andere Ausführungsprogramme verfügbar. Die oben genannten werden wahrscheinlich für die meisten Zwecke ausreichen.Zusammenfassend können wir feststellen, dass der größte Teil der Leistungsfähigkeit von Dispatch aus Sicht der Nutzung in den Klassen RequestVerbs und HandlerVerbs liegt, die es uns ermöglichen, eine Anfrage zu komponieren und ihre Antwort so zu zerlegen, wie wir es wollen. Es gibt eine ausgezeichnete periodische Tabelle der Dispatch-Operatoren, die alle möglichen Verb-Methoden mit einer kurzen Beschreibung auflistet.

...und nun die kleinen Details

Mit dem Wissen, das wir über Dispatch gewonnen haben, sollten die verbleibenden Methodenimplementierungen des RestClientHelper einfach zu handhaben sein. Ich werde jede Implementierung mit einer kurzen Erklärung der wichtigsten Dispatch-Methoden vorstellen: Erstellen (POST) mit String-Ein- und Ausgabe

[scala]
def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = {
executer(:/(host, port).POST / target << reqBody >- { fromRespStr })
}
[/scala]
  • RequestVars: POST Erstellen Sie eine POST-Anfrage (die POST-Methode ist optional, da die Methode '<<<' die Anfrage zwangsläufig zu einer Post-Anfrage macht)
  • RequestVars: <<(body:String) Senden Sie den angegebenen String-Wert mit dem Inhaltstyp text/plain
  • RequestVars: > -[T](block:(String) => T) Konvertieren Sie den Antwortkörper von einem String in den gewünschten Typ

Erstellen (POST) mit XML-Ein- und Ausgabe

[scala]
def create[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T = {
executer(:/(host, port) / target << reqBody.toString <> { fromRespXml })
}
[/scala]
  • RequestVerbs: <<(body:String) Senden Sie den angegebenen String-Wert mit dem Inhaltstyp text/plain
  • HandlerVerbs: [T](block:(Elem) => T) Konvertieren Sie den Antwortkörper von einem Elem in den gewünschten Typ

Lesen (GET) Die Lesemethode ist wahrscheinlich die faszinierendste der ganzen Serie. Um sie vollständig zu verstehen, werde ich eine ausführliche Erklärung geben:

[scala]
def query[T](target: String, params: Seq[(String, String)])(fromRespStr: String => T): (Int, Option[T]) = {
executer x (:/(host, port) / target <<? params >:> identity) {
case (200, _, Some(entity), _) => {
val respBodyStr = fromInputStream(entity.getContent()).getLines.mkString
(200, Some(fromRespStr(respBodyStr)))
}
case (status, _, _, _) => (status, None)
}
}
[/scala]

Sehen wir uns nun die wichtigsten Dispatch-Zutaten für diese GET-Anfrage an: Eingabeverarbeitung Die Methode <<? von RequestVerbs: <<?(params:Traversable[(String, String)]) fügt einfach Abfrageparameter zur Anfrage-URL hinzu. Verarbeitung der Ausgabe Um das Abfrageergebnis zu verarbeiten, sind wir an zwei Dingen interessiert: dem Antwortcode und dem Antwortkörper. Beide werden in Form eines Tuple2 (Int, Option[T]) zurückgegeben, wobei Int für den Statuscode und Option[T] für das konvertierte Objekt steht, falls die Abfrage ein Ergebnis geliefert hat. Die Frage ist, wie wir beide abrufen können, da Dispatch keine symbolische Methode anbietet, die das für uns erledigt. Schauen wir uns zunächst einmal genauer an, was eigentlich aufgerufen wird: Anstatt dem Ausführenden direkt einen Handler zur Verfügung zu stellen, verwenden wir die Methode 'x'.

[scala]
executer x (…)
[/scala]

Warum ist das so? Durch den Aufruf der apply-Methode des Executors (das ist das, was letztendlich unter der Haube passiert) wird der Handler-Block nur aufgerufen, wenn der Statuscode der Antwort 200 - 204 ist. In allen anderen Fällen wird eine Exception ausgelöst. Wenn wir also alle Antwortcodes abfangen wollen, müssen wir die xT-Methode des Executers verwenden, die den Handler unabhängig von dem zurückgegebenen Antwortcode ausführt. Die nächste Frage ist, welche Art von Handler an die x-Methode des Executers übergeben wird. Wenn wir den ersten Teil des DSL-Konstrukts explizit einem Handler zuweisen würden, sähe es wie folgt aus:

[scala]
val intermediateHandler = (:/(host, port) / target <<? params >:> identity)
[/scala]

Die "Magie" liegt in dem >:> Identitätskonstrukt. Gemäß der API akzeptiert die Methode HandlerVerb : eine Funktion, mit der die Antwort-Header verarbeitet werden können. Wenn Sie die Scala-Methode Predef identity an die Methode >:> übergeben, wird nichts verarbeitet, sondern der resultierende Handler vom Typ Handler[Map[String, Set[String]]] zurückgegeben. Wie gesagt, der obige Handler verarbeitet das Ergebnis selbst nicht. Die eigentliche Verarbeitung erfolgt durch die case-Anweisungen, die die letzten Argumente sind, die an das DSL-Konstrukt übergeben werden. Wie müssen wir das interpretieren? Zum besseren Verständnis könnte die letzte Anweisung wie folgt umgeschrieben werden:

[scala]
val realHandler = intermediateHandler.apply {
case (200, _, Some(entity), _) => {
val respBodyStr = fromInputStream(entity.getContent()).getLines.mkString
(200, Some(fromRespStr(respBodyStr)))
}
case (status, _, _, _) => (status, None)
}
executer.x(realHandler)
[/scala]

Wir erstellen also einen weiteren Handler, indem wir die apply-Methode des zuvor erstellten Handlers aufrufen. Die apply-Methode aller Handler akzeptiert eine Funktion mit der folgenden Signatur:

[scala]
apply(next:(Int, HttpResponse, Option[HttpEntity], () => T) => R)
[/scala]

Int steht für den Antwortcode, HttpResponse und Option[HttpEntity] geben uns Zugriff auf die zugrundeliegende Apache HttpClient-Implementierung und das Argument () => T steht für die Transformationsfunktion, die die Antwort in den Typ T, den Typ des Handlers selbst, umwandelt. Wie Sie wahrscheinlich wissen, IST eine Case-Anweisung eine Funktion (PartialFunction), die verkettet und - auch wenn verkettet - als einzelne PartialFunction an eine Methode übergeben werden kann. Indem wir also die apply-Methode des Handlers mit den oben genannten case-Anweisungen versehen, können wir detailliert festlegen, wie das Low-Level-Ergebnis des http-Aufrufs verarbeitet werden soll. In unserem Fall bedeutet dies: Abrufen und Konvertieren des Antwortkörpers, wenn der Antwortcode 200 ist, andernfalls einfach Rückgabe des Antwortcodes. Auch wenn dieses Konstrukt ziemlich kompliziert aussehen mag, ist es eine sehr leistungsfähige Methode, um auf das Rohergebnis zuzugreifen und es zu verarbeiten, das der http-Aufruf zurückgibt. Aktualisierung (PUT) mit String-Ein- und -Ausgabe

[scala]
def update[T](target: String, reqBody: String)(fromRespStr: String => T): T = {
executer(:/(host, port).PUT / target <<< reqBody >- { fromRespStr })
}
[/scala]
  • RequestVerbs: PUT Erstellen Sie eine PUT-Anfrage (die PUT-Methode ist optional, da die <<< -Methode die Anfrage zu einem PUT macht)
  • RequestVerbs: <<<(body:String) Setzen Sie den angegebenen String-Wert mit dem Inhaltstyp text/plain
  • HandlerVerbs: >-[T](block:(String) => T) Konvertieren Sie den Antwortkörper von einem String in den gewünschten Typ

Aktualisierung (PUT) mit XML-Ein- und Ausgabe [scala] def update[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T = { executer(:/(host, port) / target <<< reqBody.toString <> { fromRespXml }) } [/scala]

  • RequestVerbs: <<<(body:String) Setzen Sie den angegebenen String-Wert mit dem Inhaltstyp text/plain
  • HandlerVerbs: [T](block:(Elem) => T) Konvertieren Sie den Antwortkörper von einem Elem in den gewünschten Typ

Aktualisierung (PUT) ohne Antwortkörper [scala] def update[T](target: String, reqBody: String): Int = { executer x ((:/(host, port) / target <<< reqBody >:> identity) { case (status, , , ) => status }) } [/scala] Wenn wir ein PUT senden möchten, ohne einen Antwortkörper zu erwarten, aber dennoch an dem Antwortcode interessiert sind, verwenden wir das gleiche Konstrukt wie oben im Lesebeispiel beschrieben. Der einzige Unterschied zum Lesebeispiel besteht darin, dass wir nicht den Antwortkörper abrufen, sondern einfach den Antwortcode zurückgeben. Löschen (DELETE) [scala] def delete(target: String):Int = { executer x ((:/(host, port)).DELETE / target >:> identity) { case (status, , , ) => status [/scala] } } Die delete-Methode verarbeitet keinen Antwortkörper. Um zu wissen, ob die Löschung erfolgreich war, sind wir jedoch an dem Antwortcode interessiert. Daher verwenden wir wieder das Konstrukt wie im Lesebeispiel.

Sicherheit & Authentifizierung

Lassen Sie uns abschließend erklären, was zu tun ist, wenn unser Restful-Dienst mit https und/oder Basic/Digest-Authentifizierung gesichert ist. Für https sollte die Secure-Methode von RequestVars bei der Anfrage aufgerufen werden, für die Authentifizierung die as("username", "pwd") Methode. Ein sicherer Aufruf zum Erstellen (POST), der die einfache Authentifizierung verwendet, würde also wie folgt aussehen: [scala] def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = { executer(:/(host, port).secure.as("user", "pwd") / target << reqBody >[/scala] - { fromRespStr }) } Für den Fall, dass Sicherheit in Kombination mit Authentifizierung verwendet wird, ist es selbstverständlich, dass der RestClientHelper-Trait eine Methode bereitstellt, die eine vorkonfigurierte Anfrage zurückgibt, um DRY zu gewährleisten: [scala] trait RestClientHelper { val host: String val port: Int protected def username:String = "unkown" protected def pwd:String = "unkown" private def req = :/(host, port).secure.as(username, pwd) def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = { http(req / target << reqBody >- { fromRespStr }) } ... [/scala]

Warten Sie, es gibt noch mehr

Dispatch bietet zahlreiche weitere Funktionen, die in diesem Blog nicht erwähnt werden. Z.B.: Dispatch kann einen Antwortkörper mit Hilfe der Methode # direkt in ein Lift Json-Objekt umwandeln (ein gutes Beispiel finden Sie auf der Dispatch-Website), es kann http-Anfragen in einem Hintergrund-Thread ausführen, es gibt verschiedene HandlerVerbs und RequestVers, die wir noch nicht behandelt haben, wie z.B. die Verwendung des Gzip-Modus, die Verkettung von Handlern usw., um nur einige davon zu nennen. (Sehen Sie sich das Periodensystem für weitere Informationen an). Für das Testen von Restful Services sollten die in diesem Blog behandelten Themen jedoch ausreichen.

Der RestClientHelper ist einsatzbereit

Zum Abschluss dieses Blogs folgt nun der Quelltext der RestClientHelper-Eigenschaft, die das Testen von Restful-Diensten für Sie hoffentlich zu einem Kinderspiel macht(nachdem Sie Dispatch eingerichtet haben)! [scala] import scala.io.Source. import scala.xml. import org.apache.http. importieren Sie den Versand. trait RestClientHelper { val host: String val port: Int val contextRoot: String protected val ssl = false protected val Benutzername = "notdefined" protected val pwd = "notdefined" protected val executer = new Http mit thread.Safety private def req = { val req = :/(host, port).as(username, pwd) / contextRoot if (ssl) req.secure sonst req } def Abfrage[T](Ziel: String, params: Seq[(String, String)])(fromRespStr: String => T): (Int, Option[T]) = { Ausführender x (Anforderung / Ziel <<? Parameter >:> Identität) { case (200, , Some(entity), ) = > { val respStr = fromInputStream(entity.getContent()).getLines.mkString (200, Some(fromRespStr(respStr))) } case (status , , , ) => (status, None) } } def query[T](target: String)(fromRespStr: String => T): (Int, Option[T]) = { query(target, List())(fromRespStr) } def queryXml[T](target: String)(fromRespXml: Elem => T): (Int, Option[T]) = { queryXml(target, List())(fromRespXml) } def queryXml[T](target: String, params: Seq[(String, String)])(fromRespXml: Elem => T): (Int, Option[T]) = { val convertOutput = (s: String) => fromRespXml(XML.loadString(s)) query(target, params)(convertOutput()) } def create[T](target: String, reqBody: String)(fromRespStr: String => T): T = { executer(anfrage/ziel << reqBody >- { fromRespStr }) } def create[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T = { executer(req / target << reqBody.toString <> { fromRespXml }) } def update[T](target: String, reqBody: String)(fromRespStr: String => T): T = { executer(req / target <<< reqBody >- { fromRespStr }) } def update[T](target: String, reqBody: Elem)(fromRespXml: Elem => T): T = { executer(req / target <<< reqBody.toString <> { fromRespXml }) } def update[T](target: String, reqBody: String): Int = { executer x ((req / target <<< reqBody >:> Identität) { case (status , , , ) => status }) } def update[T](target: String, reqBody: Elem): Int = { update(target, reqBody.toString) } def delete(target: String): Int = { executer x ((req).DELETE / target >:> identity) { case (status, , , ) => Status } } } [/scala] Mögliche Verwendung in einem Objekt oder einer anderen Klasse, in die Sie den RestClientHelper einbinden möchten: [scala] object MyRestClientHelper extends RestClientHelper { val host = "rest.service.host" val port = 443 val contextRoot = "v1" override val ssl = true override val username = "myusername" override val pwd = "secret" } [/scala]

Verfasst von

Urs Peter

Contact

Let’s discuss how we can support your journey.