diff options
author | adamw <adam@warski.org> | 2017-06-29 17:24:46 +0200 |
---|---|---|
committer | adamw <adam@warski.org> | 2017-06-29 17:24:46 +0200 |
commit | 034c40595f217ef1f11ca351666a03aa08976b81 (patch) | |
tree | 50ce45e9213120f7e6f74620c6b1eb4b3ffee86b | |
download | sttp-034c40595f217ef1f11ca351666a03aa08976b81.tar.gz sttp-034c40595f217ef1f11ca351666a03aa08976b81.tar.bz2 sttp-034c40595f217ef1f11ca351666a03aa08976b81.zip |
Initital draft
-rw-r--r-- | .gitignore | 19 | ||||
-rw-r--r-- | akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/AkkaHttpSttpHandler.scala | 89 | ||||
-rw-r--r-- | akka-http-handler/src/main/scala/com/softwaremill/sttp/akkahttp/package.scala | 14 | ||||
-rw-r--r-- | build.sbt | 80 | ||||
-rw-r--r-- | core/src/main/scala/com/softwaremill/sttp/HttpConnectionSttpHandler.scala | 67 | ||||
-rw-r--r-- | core/src/main/scala/com/softwaremill/sttp/Response.scala | 3 | ||||
-rw-r--r-- | core/src/main/scala/com/softwaremill/sttp/SttpHandler.scala | 9 | ||||
-rw-r--r-- | core/src/main/scala/com/softwaremill/sttp/UriInterpolator.scala | 32 | ||||
-rw-r--r-- | core/src/main/scala/com/softwaremill/sttp/package.scala | 182 | ||||
-rw-r--r-- | project/build.properties | 1 | ||||
-rw-r--r-- | project/plugins.sbt | 3 |
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") |