aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoradamw <adam@warski.org>2017-06-29 17:24:46 +0200
committeradamw <adam@warski.org>2017-06-29 17:24:46 +0200
commit034c40595f217ef1f11ca351666a03aa08976b81 (patch)
tree50ce45e9213120f7e6f74620c6b1eb4b3ffee86b
downloadsttp-034c40595f217ef1f11ca351666a03aa08976b81.tar.gz
sttp-034c40595f217ef1f11ca351666a03aa08976b81.tar.bz2
sttp-034c40595f217ef1f11ca351666a03aa08976b81.zip
Initital draft
-rw-r--r--.gitignore19
-rw-r--r--akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala89
-rw-r--r--akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/package.scala14
-rw-r--r--build.sbt80
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala67
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/Response.scala3
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/SttpHandler.scala9
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala32
-rw-r--r--core/src/main/scala/com/softwaremill/sttp/package.scala182
-rw-r--r--project/build.properties1
-rw-r--r--project/plugins.sbt3
11 files changed, 499 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..305c7c4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+*.class
+*.log
+
+# sbt specific
+.cache
+.history
+.lib/
+dist/*
+target/
+lib_managed/
+src_managed/
+project/boot/
+project/plugins/project/
+
+# Scala-IDE specific
+.scala_dependencies
+.worksheet
+
+.idea*
diff --git a/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala
new file mode 100644
index 0000000..e167dd5
--- /dev/null
+++ b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala
@@ -0,0 +1,89 @@
+package com.softwaremill.sttp.akkahttp
+
+import akka.actor.{ActorSystem, Terminated}
+import akka.http.scaladsl.Http
+import akka.http.scaladsl.model.HttpHeader.ParsingResult
+import akka.http.scaladsl.model._
+import akka.stream.ActorMaterializer
+import akka.stream.scaladsl.Source
+import akka.util.ByteString
+import com.softwaremill.sttp.{IgnoreResponseBody, Method, Request, Response, ResponseBodyReader, SttpStreamHandler}
+
+import scala.concurrent.Future
+
+class AkkaHttpSttpHandler(actorSystem: ActorSystem) extends SttpStreamHandler[Future, Source[ByteString, Any]] {
+ def this() = this(ActorSystem("sttp"))
+
+ private implicit val as = actorSystem
+ private implicit val materializer = ActorMaterializer()
+ import as.dispatcher
+
+ override def send[T](r: Request, responseReader: ResponseBodyReader[T]): Future[Response[T]] = {
+ requestToAkka(r).flatMap { ar =>
+ Http().singleRequest(ar).flatMap { hr =>
+ val code = hr.status.intValue()
+ bodyFromAkkaResponse(responseReader, hr).map(Response(code, _))
+ }
+ }
+ }
+
+ override def sendStream[T](r: Request, contentType: String, stream: Source[ByteString, Any],
+ responseReader: ResponseBodyReader[T]): Future[Response[T]] = {
+
+ for {
+ ar <- requestToAkka(r)
+ ct <- contentTypeToAkka(contentType)
+ hr <- Http().singleRequest(ar.withEntity(HttpEntity(ct, stream)))
+ body <- bodyFromAkkaResponse(responseReader, hr)
+ } yield Response(hr.status.intValue(), body)
+ }
+
+ private def convertMethod(m: Method): HttpMethod = m match {
+ case Method.GET => HttpMethods.GET
+ case Method.POST => HttpMethods.POST
+ case _ => HttpMethod.custom(m.m)
+ }
+
+ private def bodyFromAkkaResponse[T](rr: ResponseBodyReader[T], hr: HttpResponse): Future[T] = rr match {
+ case IgnoreResponseBody =>
+ hr.discardEntityBytes()
+ Future.successful(())
+
+ case AkkaStreamsSourceResponseBody =>
+ Future.successful(hr.entity.dataBytes)
+
+ case _ =>
+ hr.entity.dataBytes
+ .runFold(ByteString(""))(_ ++ _)
+ .map(_.toArray[Byte])
+ .map(rr.fromBytes)
+ }
+
+ private def requestToAkka(r: Request): Future[HttpRequest] = {
+ val ar = HttpRequest(uri = r.uri.toString, method = convertMethod(r.method))
+ val parsed = r.headers.map(h => HttpHeader.parse(h._1, h._2))
+ val errors = parsed.collect {
+ case ParsingResult.Error(e) => e
+ }
+ if (errors.isEmpty) {
+ val headers = parsed.collect {
+ case ParsingResult.Ok(h, _) => h
+ }
+
+ Future.successful(ar.withHeaders(headers.toList))
+ } else {
+ Future.failed(new RuntimeException(s"Cannot parse headers: $errors"))
+ }
+ }
+
+ private def contentTypeToAkka(ct: String): Future[ContentType] = {
+ ContentType.parse(ct).fold(
+ errors => Future.failed(new RuntimeException(s"Cannot parse content type: $errors")),
+ Future.successful)
+ }
+
+ def close(): Future[Terminated] = {
+ actorSystem.terminate()
+ }
+}
+
diff --git a/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/package.scala b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/package.scala
new file mode 100644
index 0000000..3b9a1aa
--- /dev/null
+++ b/akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/package.scala
@@ -0,0 +1,14 @@
+package com.softwaremill.sttp
+
+import java.io.InputStream
+
+import akka.stream.scaladsl.Source
+import akka.util.ByteString
+
+package object akkahttp {
+ implicit object AkkaStreamsSourceResponseBody extends ResponseBodyReader[Source[ByteString, Any]] {
+ override def fromInputStream(is: InputStream): Source[ByteString, Any] = ???
+
+ override def fromBytes(bytes: Array[Byte]): Source[ByteString, Any] = ???
+ }
+}
diff --git a/build.sbt b/build.sbt
new file mode 100644
index 0000000..9126330
--- /dev/null
+++ b/build.sbt
@@ -0,0 +1,80 @@
+import scalariform.formatter.preferences._
+
+lazy val commonSettings = scalariformSettings ++ Seq(
+ organization := "com.softwaremill.sttp",
+ version := "0.1",
+ scalaVersion := "2.12.2",
+ crossScalaVersions := Seq(scalaVersion.value, "2.11.8"),
+ scalacOptions ++= Seq("-unchecked", "-deprecation"),
+ ScalariformKeys.preferences := ScalariformKeys.preferences.value
+ .setPreference(DoubleIndentClassDeclaration, true)
+ .setPreference(PreserveSpaceBeforeArguments, true)
+ .setPreference(CompactControlReadability, true)
+ .setPreference(SpacesAroundMultiImports, false),
+ // Sonatype OSS deployment
+ publishTo := {
+ val nexus = "https://oss.sonatype.org/"
+ val (name, url) = if (isSnapshot.value) ("snapshots", nexus + "content/repositories/snapshots")
+ else ("releases", nexus + "service/local/staging/deploy/maven2")
+ Some(name at url)
+ },
+ credentials += Credentials(Path.userHome / ".ivy2" / ".credentials"),
+ publishMavenStyle := true,
+ pomIncludeRepository := { _ => false },
+ pomExtra := (
+ <scm>
+ <url>git@github.com/softwaremill/sttp.git</url>
+ <connection>scm:git:git@github.com/softwaremill/sttp.git</connection>
+ </scm>
+ <developers>
+ <developer>
+ <id>adamw</id>
+ <name>Adam Warski</name>
+ <url>http://www.warski.org</url>
+ </developer>
+ </developers>
+ ),
+ licenses := ("Apache2", new java.net.URL("http://www.apache.org/licenses/LICENSE-2.0.txt")) :: Nil,
+ homepage := Some(new java.net.URL("http://softwaremill.com/open-source"))
+)
+
+val akkaHttpVersion = "10.0.9"
+val akkaHttp = "com.typesafe.akka" %% "akka-http" % akkaHttpVersion
+
+val scalaTest = "org.scalatest" %% "scalatest" % "3.0.3" % "test"
+
+lazy val rootProject = (project in file("."))
+ .settings(commonSettings: _*)
+ .settings(
+ publishArtifact := false,
+ name := "sttp")
+ .aggregate(core, akkaHttpHandler, tests)
+
+lazy val core: Project = (project in file("core"))
+ .settings(commonSettings: _*)
+ .settings(
+ name := "core",
+ libraryDependencies ++= Seq(
+ "org.scalacheck" %% "scalacheck" % "1.13.5" % "test",
+ scalaTest
+ )
+ )
+
+lazy val akkaHttpHandler: Project = (project in file("akka-http-handler"))
+ .settings(commonSettings: _*)
+ .settings(
+ name := "akka-http-handler",
+ libraryDependencies ++= Seq(
+ akkaHttp
+ )
+ ) dependsOn(core)
+
+lazy val tests: Project = (project in file("tests"))
+ .settings(commonSettings: _*)
+ .settings(
+ name := "tests",
+ libraryDependencies ++= Seq(
+ akkaHttp,
+ scalaTest
+ )
+ ) dependsOn(core, akkaHttpHandler) \ No newline at end of file
diff --git a/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala
new file mode 100644
index 0000000..f2d5527
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala
@@ -0,0 +1,67 @@
+package com.softwaremill.sttp
+
+import java.io.{InputStream, OutputStream, OutputStreamWriter}
+import java.net.HttpURLConnection
+import java.nio.channels.Channels
+import java.nio.file.Files
+
+import scala.annotation.tailrec
+
+class HttpConnectionSttpHandler extends SttpHandler[Id] {
+ override def send[T](r: Request, responseReader: ResponseBodyReader[T]): Response[T] = {
+ val c = r.uri.toURL.openConnection().asInstanceOf[HttpURLConnection]
+ c.setRequestMethod(r.method.m)
+ r.headers.foreach { case (k, v) => c.setRequestProperty(k, v) }
+ c.setDoInput(true)
+ setBody(r, c)
+
+ val status = c.getResponseCode
+ Response(status, responseReader.fromInputStream(c.getInputStream))
+ }
+
+ private def setBody(r: Request, c: HttpURLConnection): Unit = {
+ if (r.body != NoBody) c.setDoOutput(true)
+
+ def copyStream(in: InputStream, out: OutputStream): Unit = {
+ val buf = new Array[Byte](1024)
+
+ @tailrec
+ def doCopy(): Unit = {
+ val read = in.read(buf)
+ if (read != -1) {
+ out.write(buf, 0, read)
+ doCopy()
+ }
+ }
+
+ doCopy()
+ }
+
+ r.body match {
+ case NoBody => // skip
+
+ case StringBody(b) =>
+ val writer = new OutputStreamWriter(c.getOutputStream)
+ try writer.write(b) finally writer.close()
+
+ case ByteArrayBody(b) =>
+ c.getOutputStream.write(b)
+
+ case ByteBufferBody(b) =>
+ val channel = Channels.newChannel(c.getOutputStream)
+ try channel.write(b) finally channel.close()
+
+ case InputStreamBody(b) =>
+ copyStream(b, c.getOutputStream)
+
+ case InputStreamSupplierBody(b) =>
+ copyStream(b(), c.getOutputStream)
+
+ case FileBody(b) =>
+ Files.copy(b.toPath, c.getOutputStream)
+
+ case PathBody(b) =>
+ Files.copy(b, c.getOutputStream)
+ }
+ }
+}
diff --git a/core/src/main/scala/com/softwaremill/sttp/Response.scala b/core/src/main/scala/com/softwaremill/sttp/Response.scala
new file mode 100644
index 0000000..4b90259
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/Response.scala
@@ -0,0 +1,3 @@
+package com.softwaremill.sttp
+
+case class Response[T](status: Int, body: T)
diff --git a/core/src/main/scala/com/softwaremill/sttp/SttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/SttpHandler.scala
new file mode 100644
index 0000000..a3b7685
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/SttpHandler.scala
@@ -0,0 +1,9 @@
+package com.softwaremill.sttp
+
+trait SttpHandler[R[_]] {
+ def send[T](request: Request, responseReader: ResponseBodyReader[T]): R[Response[T]]
+}
+
+trait SttpStreamHandler[R[_], -S] extends SttpHandler[R] {
+ def sendStream[T](request: Request, contentType: String, stream: S, responseReader: ResponseBodyReader[T]): R[Response[T]]
+} \ No newline at end of file
diff --git a/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
new file mode 100644
index 0000000..abb2327
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala
@@ -0,0 +1,32 @@
+package com.softwaremill.sttp
+
+// from https://gist.github.com/teigen/5865923
+object UriInterpolator {
+
+ private val unreserved = {
+ val alphanum = (('a' to 'z') ++ ('A' to 'Z') ++ ('0' to '9')).toSet
+ val mark = Set('-', '_', '.', '!', '~', '*', '\'', '(', ')')
+ alphanum ++ mark
+ }
+
+ implicit class UriContext(val sc:StringContext) extends AnyVal {
+ def uri(args:String*) = {
+ val strings = sc.parts.iterator
+ val expressions = args.iterator
+ val sb = new StringBuffer(strings.next())
+
+ while(strings.hasNext){
+ for(c <- expressions.next()){
+ if(unreserved(c))
+ sb.append(c)
+ else for(b <- c.toString.getBytes("UTF-8")){
+ sb.append("%")
+ sb.append("%02X".format(b))
+ }
+ }
+ sb.append(strings.next())
+ }
+ sb.toString
+ }
+ }
+}
diff --git a/core/src/main/scala/com/softwaremill/sttp/package.scala b/core/src/main/scala/com/softwaremill/sttp/package.scala
new file mode 100644
index 0000000..459f13a
--- /dev/null
+++ b/core/src/main/scala/com/softwaremill/sttp/package.scala
@@ -0,0 +1,182 @@
+package com.softwaremill
+
+import java.io.File
+import java.io.InputStream
+import java.nio.file.Path
+import java.nio.ByteBuffer
+import java.net.URI
+
+import scala.annotation.{implicitNotFound, tailrec}
+import scala.io.Source
+import scala.language.higherKinds
+
+package object sttp {
+ /*
+
+ - set headers
+ - set cookies (set from response)
+ - partial request (no uri + method) / full request
+ - start with an empty partial request
+ - multi-part uploads
+ - body: bytes, input stream (?), task/future, stream (fs2/akka), form data, file
+ - auth
+ - access uri/method/headers/cookies/body spec
+ - proxy
+ - user agent, buffer size
+ - charset
+ - zipped encodings
+ - SSL - mutual? (client side)
+
+ - stream responses (sendStreamAndReceive?) / strict responses
+ - make sure response is consumed - only fire request when we know what to do with response?
+
+ - reuse connections / connectio pooling - in handler
+
+ - handler restriction? AnyHandler <: Handler Restriction
+
+ Options:
+ - timeouts (connection/read)
+ - follow redirect
+ - ignore SSL
+
+ //
+
+ We want to serialize to:
+ - string
+ - byte array
+ - input stream
+ - handler-specific stream of bytes/strings
+
+ post:
+ - data (bytes/is/string - but which encoding?)
+ - form data (kv pairs - application/x-www-form-urlencoded)
+ - multipart (files mixed with forms - multipart/form-data)
+
+ */
+
+ //
+
+ type Id[X] = X
+ type Empty[X] = None.type
+
+ case class Method(m: String) extends AnyVal
+ object Method {
+ val GET = Method("GET")
+ val HEAD = Method("HEAD")
+ val POST = Method("POST")
+ val PUT = Method("PUT")
+ val DELETE = Method("DELETE")
+ val OPTIONS = Method("OPTIONS")
+ val PATCH = Method("PATCH")
+ }
+
+ trait ResponseBodyReader[T] {
+ def fromInputStream(is: InputStream): T
+ def fromBytes(bytes: Array[Byte]): T
+ }
+ object IgnoreResponseBody extends ResponseBodyReader[Unit] {
+ override def fromInputStream(is: InputStream): Unit = {
+ @tailrec def consume(): Unit = if (is.read() != -1) consume()
+ consume()
+ }
+ override def fromBytes(bytes: Array[Byte]): Unit = {}
+ }
+ implicit object StringResponseBody extends ResponseBodyReader[String] {
+ override def fromInputStream(is: InputStream): String = {
+ Source.fromInputStream(is, "UTF-8").mkString
+ }
+ override def fromBytes(bytes: Array[Byte]): String = {
+ new String(bytes, "UTF-8")
+ }
+ }
+
+ def responseAs[T](implicit r: ResponseBodyReader[T]): ResponseBodyReader[T] = r
+ def ignoreResponseBody: ResponseBodyReader[Unit] = IgnoreResponseBody
+
+ sealed trait RequestBody
+ sealed trait SimpleRequestBody
+ case object NoBody extends RequestBody
+ case class StringBody(s: String) extends RequestBody with SimpleRequestBody
+ case class ByteArrayBody(b: Array[Byte]) extends RequestBody with SimpleRequestBody
+ case class ByteBufferBody(b: ByteBuffer) extends RequestBody with SimpleRequestBody
+ case class InputStreamBody(b: InputStream) extends RequestBody with SimpleRequestBody
+ case class InputStreamSupplierBody(b: () => InputStream) extends RequestBody with SimpleRequestBody
+ case class FileBody(f: File) extends RequestBody with SimpleRequestBody
+ case class PathBody(f: Path) extends RequestBody with SimpleRequestBody
+
+ /**
+ * Use the factory methods `multiPart` to conveniently create instances of this class. A part can be then
+ * further customised using `fileName`, `contentType` and `header` methods.
+ */
+ case class MultiPart(name: String, data: RequestBody with SimpleRequestBody, fileName: Option[String] = None,
+ contentType: Option[String] = None, additionalHeaders: Map[String, String] = Map()) {
+ def fileName(v: String): MultiPart = copy(fileName = Some(v))
+ def contentType(v: String): MultiPart = copy(contentType = Some(v))
+ def header(k: String, v: String): MultiPart = copy(additionalHeaders = additionalHeaders + (k -> v))
+ }
+
+ def multiPart(name: String, data: String): MultiPart = MultiPart(name, StringBody(data))
+ def multiPart(name: String, data: Array[Byte]): MultiPart = MultiPart(name, ByteArrayBody(data))
+ def multiPart(name: String, data: ByteBuffer): MultiPart = MultiPart(name, ByteBufferBody(data))
+ def multiPart(name: String, data: InputStream): MultiPart = MultiPart(name, InputStreamBody(data))
+ def multiPart(name: String, data: () => InputStream): MultiPart = MultiPart(name, InputStreamSupplierBody(data))
+ // mandatory content type?
+ def multiPart(name: String, data: File): MultiPart = MultiPart(name, FileBody(data), fileName = Some(data.getName))
+ def multiPart(name: String, data: Path): MultiPart = MultiPart(name, PathBody(data), fileName = Some(data.getFileName.toString))
+
+ case class RequestTemplate[U[_]](
+ method: U[Method],
+ uri: U[URI],
+ body: RequestBody,
+ headers: Map[String, String]
+ ) {
+
+ def get(uri: URI): Request = this.copy[Id](uri = uri, method = Method.GET)
+ def head(uri: URI): Request = this.copy[Id](uri = uri, method = Method.HEAD)
+ def post(uri: URI): Request = this.copy[Id](uri = uri, method = Method.POST)
+ def put(uri: URI): Request = this.copy[Id](uri = uri, method = Method.PUT)
+ def delete(uri: URI): Request = this.copy[Id](uri = uri, method = Method.DELETE)
+ def options(uri: URI): Request = this.copy[Id](uri = uri, method = Method.OPTIONS)
+ def patch(uri: URI): Request = this.copy[Id](uri = uri, method = Method.PATCH)
+
+ def header(k: String, v: String): RequestTemplate[U] = this.copy(headers = headers + (k -> v))
+
+ def data(b: String): RequestTemplate[U] = this.copy(body = StringBody(b))
+ def data(b: Array[Byte]): RequestTemplate[U] = this.copy(body = ByteArrayBody(b))
+ def data(b: ByteBuffer): RequestTemplate[U] = this.copy(body = ByteBufferBody(b))
+ def data(b: InputStream): RequestTemplate[U] = this.copy(body = InputStreamBody(b))
+ def data(b: () => InputStream): RequestTemplate[U] = this.copy(body = InputStreamSupplierBody(b))
+ // mandatory content type?
+ def data(b: File): RequestTemplate[U] = this.copy(body = FileBody(b))
+ def data(b: Path): RequestTemplate[U] = this.copy(body = PathBody(b))
+
+ def formData(fs: Map[String, String]): RequestTemplate[U] = ???
+ def formData(fs: (String, String)*): RequestTemplate[U] = ???
+
+ def multipartData(parts: MultiPart*): RequestTemplate[U] = ???
+
+ def send[R[_], T](responseReader: ResponseBodyReader[T])(
+ implicit handler: SttpHandler[R], isRequest: IsRequest[U]): R[Response[T]] = {
+
+ handler.send(this, responseReader)
+ }
+
+ def sendStream[R[_], S, T](contentType: String, stream: S, responseReader: ResponseBodyReader[T])(
+ implicit handler: SttpStreamHandler[R, S], isRequest: IsRequest[U]): R[Response[T]] = {
+
+ handler.sendStream(this, contentType, stream, responseReader)
+ }
+ }
+
+ object RequestTemplate {
+ val empty: RequestTemplate[Empty] = RequestTemplate[Empty](None, None, NoBody, Map.empty)
+ }
+
+ type PartialRequest = RequestTemplate[Empty]
+ type Request = RequestTemplate[Id]
+
+ @implicitNotFound("This is a partial request, the method & url are not specified. Use .get(...), .post(...) etc. to obtain a non-partial request.")
+ private type IsRequest[U[_]] = RequestTemplate[U] =:= Request
+
+ val sttp: RequestTemplate[Empty] = RequestTemplate.empty
+}
diff --git a/project/build.properties b/project/build.properties
new file mode 100644
index 0000000..64317fd
--- /dev/null
+++ b/project/build.properties
@@ -0,0 +1 @@
+sbt.version=0.13.15
diff --git a/project/plugins.sbt b/project/plugins.sbt
new file mode 100644
index 0000000..d4afe46
--- /dev/null
+++ b/project/plugins.sbt
@@ -0,0 +1,3 @@
+addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.4.0")
+
+addSbtPlugin("io.get-coursier" % "sbt-coursier" % "1.0.0-RC5")