Last active
January 9, 2016 03:07
-
-
Save thiloplanz/198944a59dfda0868265 to your computer and use it in GitHub Desktop.
Combines the Ning HTTP client library and the Jackson JSON library.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name := "ning-json-client" | |
scalaVersion := "2.11.7" | |
libraryDependencies += "com.fasterxml.jackson.core" % "jackson-core" % "2.6.0" | |
libraryDependencies += "com.fasterxml.jackson.core" % "jackson-databind" % "2.6.0" | |
libraryDependencies += "com.fasterxml.jackson.module" %% "jackson-module-scala" % "2.6.0-1" | |
libraryDependencies += "com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % "2.6.0" | |
libraryDependencies += "org.slf4j" % "slf4j-api" % "1.7.12" | |
libraryDependencies += "org.slf4j" % "slf4j-simple" % "1.7.12" % "runtime,optional" | |
libraryDependencies += "com.ning" % "async-http-client" % "1.9.21" | |
libraryDependencies += "org.specs2" %% "specs2-core" % "3.6.6" % "test" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright (c) 2015/2016, Thilo Planz. | |
// | |
// This program is free software: you can redistribute it and/or modify | |
// it under the terms of the Apache License, Version 2.0 | |
// as published by the Apache Software Foundation (the "License"). | |
// | |
// Unless required by applicable law or agreed to in writing, | |
// software distributed under the License is distributed on an | |
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
// KIND, either express or implied. See the License for the | |
// specific language governing permissions and limitations | |
// under the License. | |
// | |
// You should have received a copy of the License along with this program. | |
// If not, see <http://www.apache.org/licenses/LICENSE-2.0>. | |
import java.io.{InputStream, IOException} | |
import java.nio.charset.Charset | |
import com.fasterxml.jackson.databind.ObjectMapper | |
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule | |
import com.fasterxml.jackson.module.scala.DefaultScalaModule | |
import com.fasterxml.jackson.module.scala.experimental.ScalaObjectMapper | |
import com.ning.http.client._ | |
import com.ning.http.util.Base64 | |
import org.slf4j.{Logger, LoggerFactory} | |
import scala.concurrent.{Future, Promise} | |
import scala.util.{Failure, Success, Try} | |
/** | |
* Combines the Ning HTTP client library and the Jackson JSON library. | |
* | |
* Tries to make the following scenario trivial: | |
* | |
* 1) send HTTP request with optional application/json entity | |
* 2) check for "200 OK" result status | |
* 3) if OK, receive application/json result | |
* 4) JSON (in and out) mapped to Scala classes of your choice without much hassle | |
* | |
* Also allows variations from that scenario with a reasonable amount of configuration. | |
* | |
* - posting something other than JSON | |
* - "unusual" headers | |
* - retrieving something other than JSON | |
* - different error handling (default just errors out) | |
* - supplying your custom version of Ning and Jackson ObjectMapper | |
* | |
* | |
*/ | |
class NingJsonClient(ning: AsyncHttpClient, | |
objectMapper: ObjectMapper with ScalaObjectMapper = NingJsonClient.defaultObjectMapper, | |
logger : Logger = NingJsonClient.logger) { | |
def get[T: Manifest](url: String, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.AcceptJson, | |
okayStatusCode: Int = 200 | |
) : Future[T] = executeRequest[T](ning.prepareGet(url), url, queryParams, requestHeaders, okayStatusCode) | |
def postJson[T: Manifest](url: String, | |
entity: Any, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendAndAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
postBytes(url, objectMapper.writeValueAsBytes(entity), queryParams, requestHeaders, okayStatusCode) | |
def postText[T: Manifest](url: String, | |
entity: String, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendTextAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
postBytes[T](url, entity.getBytes(NingJsonClient.UTF8), queryParams, requestHeaders, okayStatusCode) | |
def postBytes[T: Manifest](url: String, | |
entity: Array[Byte], | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendOctetsAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
executeRequest[T](ning.preparePost(url).setBody(entity), url, queryParams, requestHeaders, okayStatusCode) | |
def postByteStream[T: Manifest](url: String, | |
entity: InputStream, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendOctetsAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
executeRequest[T](ning.preparePost(url).setBody(entity), url, queryParams, requestHeaders, okayStatusCode) | |
def putJson[T: Manifest](url: String, | |
entity: Any, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendAndAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
putBytes(url, objectMapper.writeValueAsBytes(entity), queryParams, requestHeaders, okayStatusCode) | |
def putText[T: Manifest](url: String, | |
entity: String, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendTextAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
putBytes[T](url, entity.getBytes(NingJsonClient.UTF8), queryParams, requestHeaders, okayStatusCode) | |
def putBytes[T: Manifest](url: String, | |
entity: Array[Byte], | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendOctetsAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
executeRequest[T](ning.preparePut(url).setBody(entity), url, queryParams, requestHeaders, okayStatusCode) | |
def putByteStream[T: Manifest](url: String, | |
entity: InputStream, | |
queryParams: Seq[(String, String)] = Map.empty.toList, | |
requestHeaders : RequestHeaders = RequestHeaders.SendOctetsAcceptJson, | |
okayStatusCode: Int = 200 | |
): Future[T] = | |
executeRequest[T](ning.preparePut(url).setBody(entity), url, queryParams, requestHeaders, okayStatusCode) | |
private def executeRequest[T: Manifest](request: RequestBuilderBase[_], | |
url: String, queryParams: Seq[(String, String)], | |
requestHeaders : RequestHeaders, | |
okayStatusCode: Int) : Future[T]= { | |
val wanted = manifest[T].runtimeClass | |
queryParams.foreach { case (name, value) => request.addQueryParam(name, value) } | |
requestHeaders.headers.foreach { case (name, value) => request.addHeader(name, value)} | |
val result = Promise[T]() | |
ning.executeRequest(request.build, | |
new AsyncCompletionHandler[Unit] { | |
override def onCompleted(response: Response) = | |
if (wanted == classOf[Try[_]]) { | |
val toTry = manifest[T].typeArguments(0) | |
result.success(tryOnCompleted(response, url, queryParams, okayStatusCode)(toTry).asInstanceOf[T]) | |
} | |
else{ | |
result.complete(tryOnCompleted[T](response, url, queryParams, okayStatusCode)) | |
} | |
override def onThrowable(t: Throwable) = if (wanted == classOf[Try[_]]){ | |
result.success(Failure(t).asInstanceOf[T]) | |
} else t match { | |
case io: IOException => | |
logger.warn("IO error when calling "+url+": " + io.toString); | |
result.failure(new UnsuccessfulRequestException(HttpStatus(0, t.toString, url, queryParams))) | |
case _ => logger.error("crashed when calling " + url, t); result.failure(t) | |
} | |
} | |
) | |
result.future | |
} | |
private def readResponse[T:Manifest](response: Response, status: HttpStatus) : Try[T] = { | |
val wanted = manifest[T].runtimeClass | |
// do they want to Try ? | |
if (wanted == classOf[Try[_]]){ | |
val toTry = manifest[T].typeArguments(0) | |
return Try(readResponse(response, status)(toTry)).asInstanceOf[Try[T]] | |
} | |
// do they want the raw Response ? | |
if (classOf[Response].isAssignableFrom(wanted)) { | |
return Success(response.asInstanceOf[T]) | |
} | |
// and we expect the body to be JSON | |
val entity = response.getResponseBodyAsBytes | |
// handle empty entity | |
if (wanted == classOf[Unit]){ | |
if (entity == null || entity.length == 0) { | |
return Success(null.asInstanceOf[T]) | |
} | |
return Failure(new UnsuccessfulRequestException(status.copy(statusText = s"Got a response entity with ${entity.length} bytes, excepted none. "+status.statusText))) | |
} | |
if (wanted == classOf[Option[_]]){ | |
if (entity == null || entity.length == 0) { | |
return Success(None.asInstanceOf[T]) | |
} | |
// let it fall into Jackson, which can handle Option[X] | |
} | |
return Try(objectMapper.readValue(entity)(manifest[T])) | |
} | |
private def tryOnCompleted[T: Manifest](response: Response, url: String, queryParams: Seq[(String, String)], okayStatusCode: Int ): Try[T] = { | |
val wanted = manifest[T].runtimeClass | |
// do they want the raw Response ? | |
if (classOf[Response].isAssignableFrom(wanted)){ | |
return Success(response.asInstanceOf[T]) | |
} | |
// otherwise we require the expected status code (default: 200 OK) | |
val status = HttpStatus(response.getStatusCode, response.getStatusText, url, queryParams) | |
if (status.statusCode != okayStatusCode){ | |
// do they want Either? then give them Left | |
if (wanted == classOf[Either[_,_]]){ | |
val left = manifest[T].typeArguments(0) | |
return readResponse(response, status)(left).map(Left(_)).asInstanceOf[Try[T]] | |
} | |
return Failure(new UnsuccessfulRequestException(status)) | |
} | |
// do they want Either? then give them Right | |
if (wanted == classOf[Either[_,_]]){ | |
val right = manifest[T].typeArguments(1) | |
return readResponse(response, status)(right).map(Right(_)).asInstanceOf[Try[T]] | |
} | |
readResponse[T](response, status) | |
} | |
} | |
object NingJsonClient { | |
private val defaultObjectMapper = new ObjectMapper with ScalaObjectMapper | |
defaultObjectMapper.registerModule(DefaultScalaModule) | |
defaultObjectMapper.registerModule(new JavaTimeModule) | |
private val logger = LoggerFactory.getLogger("NingJsonClient") | |
val UTF8 = Charset.forName("UTF-8") | |
} | |
/** | |
* Helper to build HTTP request headers. | |
*/ | |
class RequestHeaders private(val headers: Map[String, String]) { | |
def withHeader(name: String, value: String) = new RequestHeaders(this.headers + (name -> value)) | |
def withAccept(contentType: String) = withHeader("Accept", contentType) | |
def withContentType(contentType: String) = withHeader("Content-Type", contentType) | |
def withBasicAuth(username: String, password: String) = withHeader("Authorization", "Basic "+Base64.encode((username+":"+password).getBytes(NingJsonClient.UTF8))) | |
} | |
object RequestHeaders { | |
val None = new RequestHeaders(Map.empty) | |
val AcceptJson = None.withAccept("application/json") | |
val SendAndAcceptJson = AcceptJson.withContentType("application/json") | |
val SendTextAcceptJson = AcceptJson.withContentType("text/plain; charset=UTF-8") | |
val SendOctetsAcceptJson = AcceptJson.withContentType("application/octet-stream") | |
} | |
case class HttpStatus(statusCode: Int, statusText: String, requestUrl: String, requestQueryParams: Seq[(String, String)]) | |
class UnsuccessfulRequestException(val status: HttpStatus) extends RuntimeException( | |
"Request to "+status.requestUrl+" returned status code "+status.statusCode+" "+status.statusText) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright (c) 2015/2016, Thilo Planz. | |
// | |
// This program is free software: you can redistribute it and/or modify | |
// it under the terms of the Apache License, Version 2.0 | |
// as published by the Apache Software Foundation (the "License"). | |
// | |
// Unless required by applicable law or agreed to in writing, | |
// software distributed under the License is distributed on an | |
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
// KIND, either express or implied. See the License for the | |
// specific language governing permissions and limitations | |
// under the License. | |
// | |
// You should have received a copy of the License along with this program. | |
// If not, see <http://www.apache.org/licenses/LICENSE-2.0>. | |
import com.fasterxml.jackson.databind.JsonMappingException | |
import com.fasterxml.jackson.databind.node.ObjectNode | |
import com.ning.http.client.{AsyncHttpClient, Response} | |
import org.specs2.mutable.Specification | |
import scala.concurrent.Await | |
import scala.concurrent.duration.Duration | |
import scala.util.Try | |
class NingJsonClientSpec extends Specification{ | |
val httpBin = "http://httpbin.org" | |
val timeout = Duration("10 seconds") | |
val ning = new AsyncHttpClient() | |
val client = new NingJsonClient(ning) | |
type HttpBinResponse = ObjectNode | |
private def get[T:Manifest](url: String, queryParams : Seq[(String, String)] = Seq.empty, okayStatusCode: Int = 200, requestHeaders: RequestHeaders = RequestHeaders.AcceptJson) = | |
Await.result(client.get[T](httpBin + url, queryParams = queryParams, okayStatusCode=okayStatusCode, requestHeaders=requestHeaders), timeout) | |
private def post[T:Manifest](url: String, entity: Any, queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.postJson[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
private def postText[T:Manifest](url: String, entity: String, queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.postText[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
private def postBytes[T:Manifest](url: String, entity: Array[Byte], queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.postBytes[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
private def put[T:Manifest](url: String, entity: Any, queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.putJson[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
private def putText[T:Manifest](url: String, entity: String, queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.putText[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
private def putBytes[T:Manifest](url: String, entity: Array[Byte], queryParams : Seq[(String, String)] = Seq.empty) = | |
Await.result(client.putBytes[T](httpBin + url, entity, queryParams = queryParams), timeout) | |
"NingJsonClient" >> { | |
"can parse JSON" >> { get[HttpBinResponse]("/get").at("/args").toString must_== "{}" } | |
"can post JSON" >> { post[HttpBinResponse]("/post", Map("hey" -> "ho")).at("/json/hey").asText must_== "ho" } | |
"can post text" >> { postText[HttpBinResponse]("/post", "super'dooper").at("/data").asText must_== "super'dooper" } | |
"can post bytes" >> { postBytes[HttpBinResponse]("/post", Array[Byte]('a','b','c')).at("/data").asText must_== "abc" } | |
"can put JSON" >> { put[HttpBinResponse]("/put", Map("hey" -> "ho")).at("/json/hey").asText must_== "ho" } | |
"can put text" >> { putText[HttpBinResponse]("/put", "super'dooper").at("/data").asText must_== "super'dooper" } | |
"can put bytes" >> { putBytes[HttpBinResponse]("/put", Array[Byte]('a','b','c')).at("/data").asText must_== "abc" } | |
"can send query parameters" >> { get[HttpBinResponse]("/get", | |
queryParams = Seq("x" -> "y", "x" -> "z", "foo" -> "bar")).at("/args").toString must_== | |
"""{"foo":"bar","x":["y","z"]}""" } | |
"can return the raw Ning Response" >> { get[Response]( "/get").getStatusCode must_== 200 } | |
"rejects non-200 status codes" >> { | |
def bad = get[Integer]("/status/400") | |
bad must throwA[UnsuccessfulRequestException] | |
} | |
"can require empty response entity using Unit" >> { | |
get[Unit]("/status/200") | |
get[Unit]("/get") must throwA[UnsuccessfulRequestException] | |
} | |
"can accept empty response entity using Option" >> { | |
get[Option[String]]("/status/200") must beNone | |
get[Option[HttpBinResponse]]("/get").get.at("/args").toString must_== "{}" | |
get[Option[String]]("/get").get must throwA[JsonMappingException] // Option still does not hide errors | |
} | |
"can accept an alternative status code" >> { get[Unit]("/status/201", okayStatusCode = 201); true } | |
"can return Either an error result or the Right result" >> { | |
get[Either[Response, String]]("/get", okayStatusCode = 999).left.get.getStatusCode must_== 200 | |
get[Either[String, HttpBinResponse]]("/get") must beRight | |
get[Either[String, Option[HttpBinResponse]]]("/get").right.get must beSome[HttpBinResponse] | |
get[Either[String, Option[HttpBinResponse]]]("/status/200").right.get must beNone | |
get[Either[Option[HttpBinResponse], String]]("/get", okayStatusCode = 999).left.get must beSome[HttpBinResponse] | |
} | |
"can make Basic Auth requests" >> { get[HttpBinResponse]("/get", requestHeaders = RequestHeaders.AcceptJson.withBasicAuth("Jony", "secret")) | |
.at("/headers/Authorization").asText must_== "Basic Sm9ueTpzZWNyZXQ="} | |
"can return Try instead of throwing exceptions" >> { | |
get[Try[String]]("missingSlashResultInWrongHostnameAndDNSError") must beFailedTry | |
get[Try[String]]("/status/200") must beFailedTry | |
get[Try[HttpBinResponse]]("/get") must beSuccessfulTry | |
get[Either[Try[String], String]]("/get", okayStatusCode = 999).left.get must beFailedTry | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment