Skip to content

Instantly share code, notes, and snippets.

@thiloplanz
Last active January 9, 2016 03:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thiloplanz/198944a59dfda0868265 to your computer and use it in GitHub Desktop.
Save thiloplanz/198944a59dfda0868265 to your computer and use it in GitHub Desktop.
Combines the Ning HTTP client library and the Jackson JSON library.
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"
// 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)
// 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