diff options
author | Adam Warski <adam@warski.org> | 2017-08-04 16:48:22 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-08-04 16:48:22 +0200 |
commit | 951a975ae80427b3b385a24c9e7a22330868caef (patch) | |
tree | c3fda860423c4154bdc73f26f67fe332ce4b1cc6 | |
parent | 489e591672257d19b3a07198d4d6c9e21be601c7 (diff) | |
parent | e6f0ac0289ad3685e2af5dfc17ee79c3c1170bdf (diff) | |
download | sttp-951a975ae80427b3b385a24c9e7a22330868caef.tar.gz sttp-951a975ae80427b3b385a24c9e7a22330868caef.tar.bz2 sttp-951a975ae80427b3b385a24c9e7a22330868caef.zip |
Merge pull request #16 from softwaremill/responseaspath
Save response as File/Path
11 files changed, 330 insertions, 24 deletions
@@ -443,3 +443,4 @@ and pick a task you'd like to work on! * [Adam Warski](https://github.com/adamw) * [Omar Alejandro Mainegra Sarduy](https://github.com/omainegra) * [Bjørn Madsen](https://github.com/aeons) +* [Piotr Buda](https://github.com/pbuda) 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 index d4ca3d8..0a2a467 100644 --- 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 @@ -1,6 +1,6 @@ package com.softwaremill.sttp.akkahttp -import java.io.UnsupportedEncodingException +import java.io.{File, IOException, UnsupportedEncodingException} import akka.actor.ActorSystem import akka.http.scaladsl.Http @@ -10,7 +10,7 @@ import akka.http.scaladsl.model._ import akka.http.scaladsl.model.headers.{HttpEncodings, `Content-Type`} import akka.http.scaladsl.model.ContentTypes.`application/octet-stream` import akka.stream.ActorMaterializer -import akka.stream.scaladsl.{Source, StreamConverters} +import akka.stream.scaladsl.{FileIO, Source, StreamConverters} import akka.util.ByteString import com.softwaremill.sttp._ import com.softwaremill.sttp.model._ @@ -67,6 +67,18 @@ class AkkaHttpSttpHandler private (actorSystem: ActorSystem, .runFold(ByteString(""))(_ ++ _) .map(_.toArray[Byte]) + def saved(file: File, overwrite: Boolean) = { + if (!file.exists()) { + file.getParentFile.mkdirs() + file.createNewFile() + } else if (!overwrite) { + throw new IOException( + s"File ${file.getAbsolutePath} exists - overwriting prohibited") + } + + hr.entity.dataBytes.runWith(FileIO.toPath(file.toPath)) + } + rr match { case MappedResponseAs(raw, g) => bodyFromAkka(raw, hr).map(g) @@ -82,6 +94,9 @@ class AkkaHttpSttpHandler private (actorSystem: ActorSystem, case r @ ResponseAsStream() => Future.successful(r.responseIsStream(hr.entity.dataBytes)) + + case ResponseAsFile(file, overwrite) => + saved(file, overwrite).map(_ => file) } } diff --git a/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala b/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala index 9043882..8f75030 100644 --- a/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala +++ b/async-http-client-handler/src/main/scala/com/softwaremill/sttp/asynchttpclient/AsyncHttpClientHandler.scala @@ -222,6 +222,11 @@ abstract class AsyncHttpClientHandler[R[_], S](asyncHttpClient: AsyncHttpClient, Failure( new IllegalStateException( "Requested a streaming response, trying to read eagerly.")) + + case ResponseAsFile(file, overwrite) => + Try( + ResponseAs + .saveFile(file, response.getResponseBodyAsStream, overwrite)) } } diff --git a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala index d5c2ccd..29da886 100644 --- a/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala +++ b/core/src/main/scala/com/softwaremill/sttp/HttpURLConnectionSttpHandler.scala @@ -115,19 +115,8 @@ object HttpURLConnectionSttpHandler extends SttpHandler[Id, Nothing] { case ResponseAsByteArray => val os = new ByteArrayOutputStream - var read = 0 - val buf = new Array[Byte](1024) - - @tailrec - def transfer(): Unit = { - read = is.read(buf, 0, buf.length) - if (read != -1) { - os.write(buf, 0, read) - transfer() - } - } - transfer() + transfer(is, os) os.toByteArray @@ -135,6 +124,10 @@ object HttpURLConnectionSttpHandler extends SttpHandler[Id, Nothing] { // only possible when the user requests the response as a stream of // Nothing. Oh well ... throw new IllegalStateException() + + case ResponseAsFile(input, overwrite) => + ResponseAs.saveFile(input, is, overwrite) + } } diff --git a/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala b/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala index 3862483..24b9b2b 100644 --- a/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala +++ b/core/src/main/scala/com/softwaremill/sttp/model/ResponseAs.scala @@ -1,8 +1,10 @@ package com.softwaremill.sttp.model +import java.io.{File, FileOutputStream, IOException, InputStream} import java.net.URLDecoder +import java.nio.file.Path -import com.softwaremill.sttp.MonadError +import com.softwaremill.sttp.{MonadError, transfer} import scala.collection.immutable.Seq import scala.language.higherKinds @@ -38,6 +40,9 @@ case class MappedResponseAs[T, T2, S](raw: BasicResponseAs[T, S], g: T => T2) MappedResponseAs[T, T3, S](raw, g andThen f) } +case class ResponseAsFile(input: File, overwrite: Boolean) + extends BasicResponseAs[File, Nothing] + object ResponseAs { private[sttp] def parseParams(s: String, encoding: String): Seq[(String, String)] = { @@ -52,6 +57,23 @@ object ResponseAs { }) } + private[sttp] def saveFile(file: File, + is: InputStream, + overwrite: Boolean): File = { + if (!file.exists()) { + file.getParentFile.mkdirs() + file.createNewFile() + } else if (!overwrite) { + throw new IOException( + s"File ${file.getAbsolutePath} exists - overwriting prohibited") + } + + val os = new FileOutputStream(file) + + transfer(is, os) + file + } + /** * Handles responses according to the given specification when basic * response specifications can be handled eagerly, that is without diff --git a/core/src/main/scala/com/softwaremill/sttp/package.scala b/core/src/main/scala/com/softwaremill/sttp/package.scala index daba574..884d2f9 100644 --- a/core/src/main/scala/com/softwaremill/sttp/package.scala +++ b/core/src/main/scala/com/softwaremill/sttp/package.scala @@ -1,12 +1,12 @@ package com.softwaremill -import java.io.{File, InputStream} +import java.io._ import java.nio.ByteBuffer import java.nio.file.Path import com.softwaremill.sttp.model._ -import scala.annotation.implicitNotFound +import scala.annotation.{implicitNotFound, tailrec} import scala.language.higherKinds import scala.collection.immutable.Seq @@ -88,6 +88,14 @@ package object sttp { def asStream[S]: ResponseAs[S, S] = ResponseAsStream[S, S]() + def asFile(file: File, + overwrite: Boolean = false): ResponseAs[File, Nothing] = + ResponseAsFile(file, overwrite) + + def asPath(path: Path, + overwrite: Boolean = false): ResponseAs[Path, Nothing] = + ResponseAsFile(path.toFile, overwrite).map(_.toPath) + // multi part factory methods /** @@ -159,6 +167,22 @@ package object sttp { private[sttp] def contentTypeWithEncoding(ct: String, enc: String) = s"$ct; charset=$enc" + private[sttp] def transfer(is: InputStream, os: OutputStream) { + var read = 0 + val buf = new Array[Byte](1024) + + @tailrec + def transfer(): Unit = { + read = is.read(buf, 0, buf.length) + if (read != -1) { + os.write(buf, 0, read) + transfer() + } + } + + transfer() + } + // uri interpolator implicit class UriContext(val sc: StringContext) extends AnyVal { diff --git a/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala b/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala index f57487f..79ca98b 100644 --- a/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala +++ b/okhttp-client-handler/src/main/scala/com/softwaremill/sttp/okhttp/OkHttpClientHandler.scala @@ -89,6 +89,8 @@ abstract class OkHttpClientHandler[R[_], S](client: OkHttpClient) case ResponseAsByteArray => Try(res.body().bytes()) case ResponseAsStream() => Failure(new IllegalStateException("Streaming isn't supported")) + case ResponseAsFile(file, overwrite) => + Try(ResponseAs.saveFile(file, res.body().byteStream(), overwrite)) } } } @@ -125,7 +127,8 @@ class OkHttpFutureClientHandler private (client: OkHttpClient)( promise.failure(e) override def onResponse(call: Call, response: OkHttpResponse): Unit = - promise.success(readResponse(response, r.responseAs)) + try promise.success(readResponse(response, r.responseAs)) + catch { case e: Exception => promise.failure(e) } }) responseMonad.flatten(promise.future) diff --git a/tests/src/test/resources/binaryfile.jpg b/tests/src/test/resources/binaryfile.jpg Binary files differnew file mode 100644 index 0000000..b9f5c5a --- /dev/null +++ b/tests/src/test/resources/binaryfile.jpg diff --git a/tests/src/test/resources/textfile.txt b/tests/src/test/resources/textfile.txt new file mode 100644 index 0000000..9904f90 --- /dev/null +++ b/tests/src/test/resources/textfile.txt @@ -0,0 +1,100 @@ +- Lorem ipsum dolor sit amet +- Vivamus sem ipsum. +- Ut molestie. +- Donec. +- Fusce non porta. +- Nulla ac metus. Morbi mattis. +- Etiam varius. +- Nulla. +- Phasellus id mollis. +- Suspendisse at. +- Quisque nec leo velit. +- Fusce. +- Maecenas nec tristique senectus et. +- Integer vestibulum lorem fermentum. +- Vestibulum consectetuer dolor. +- Lorem ipsum in. +- Fusce condimentum auctor scelerisque, wisi. +- Quisque. +- Curae. +- Curae, Nullam. +- Curae, Integer. +- Vestibulum dignissim massa. Donec. +- Pellentesque sed sem. +- Vivamus est. +- Maecenas elit sed est. +- Quisque sed tellus. +- Cum sociis natoque penatibus et. +- Fusce aliquam. +- Donec. +- Sed elementum, sapien accumsan odio. +- Nam mattis, magna lectus, tincidunt. +- Pellentesque scelerisque a, sodales. +- Sed sed condimentum. +- Curae, In nonummy. Phasellus adipiscing. +- Vestibulum quis diam mollis. +- Sed eros. Duis ipsum. +- Aenean pellentesque at, mollis tempus. +- Cras ornare facilisis sodales. Aenean. +- Cum sociis natoque penatibus. +- Donec id nulla. +- Quisque ut sapien. +- Phasellus purus. Proin ultricies. +- Aliquam auctor neque. Nunc. +- Nam nunc fringilla non, vehicula. +- Morbi molestie, felis ut lobortis. +- Nulla quis. +- In. +- Phasellus laoreet urna. +- Lorem ipsum. +- Phasellus. +- Class aptent taciti sociosqu ad. +- Sed lacinia. +- Pellentesque dapibus diam. Duis. +- Suspendisse est. Curabitur. +- Fusce condimentum justo. +- Aenean congue quis, faucibus. +- Ut pharetra leo. Donec. +- Fusce. +- Donec porta. +- Pellentesque orci. +- Sed. +- Quisque rutrum, wisi vulputate wisi. +- Cum sociis. +- Cras. +- Sed eros. Curabitur. +- Proin in velit wisi, tempor. +- Quisque eu. +- Proin. +- Nam pellentesque sed, imperdiet aliquam. +- Mauris euismod. Sed euismod orci. +- Etiam. +- Donec. +- Fusce wisi a metus. Proin. +- Phasellus quis. +- Donec non imperdiet. +- Aenean vel bibendum a, laoreet. +- Fusce non enim. Phasellus vulputate. +- Donec urna elit, sit. +- Pellentesque habitant morbi. +- Nulla ante. Curabitur elit. Donec. +- Cum sociis natoque penatibus. +- Maecenas eget leo at. +- Cum sociis natoque penatibus et. +- Vivamus lacus. +- Integer. +- Curae. +- Maecenas rhoncus. Morbi. +- Aenean posuere. +- Duis. +- Suspendisse a odio fermentum libero. +- Nam enim. Fusce enim. In. +- Maecenas. +- Lorem ipsum primis. +- Curabitur ac turpis semper sed. +- Quisque condimentum. Donec sit. +- Integer convallis non, posuere. +- Etiam vulputate, odio. +- Proin id lorem. Donec quis. +- Curae, Sed nec augue. +- Aliquam ut turpis.
\ No newline at end of file diff --git a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala index 36c82ef..28e210f 100644 --- a/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala +++ b/tests/src/test/scala/com/softwaremill/sttp/BasicTests.scala @@ -1,7 +1,8 @@ package com.softwaremill.sttp -import java.io.ByteArrayInputStream +import java.io.{ByteArrayInputStream, IOException} import java.nio.ByteBuffer +import java.nio.file.Paths import java.time.{ZoneId, ZonedDateTime} import akka.http.scaladsl.coding.{Deflate, Gzip, NoCoding} @@ -14,7 +15,7 @@ import akka.http.scaladsl.server.directives.Credentials import com.softwaremill.sttp.akkahttp.AkkaHttpSttpHandler import com.typesafe.scalalogging.StrictLogging import org.scalatest.concurrent.{IntegrationPatience, ScalaFutures} -import org.scalatest.{BeforeAndAfterAll, FlatSpec, Matchers} +import org.scalatest.{path => _, _} import better.files._ import com.softwaremill.sttp.asynchttpclient.cats.CatsAsyncHttpClientHandler import com.softwaremill.sttp.asynchttpclient.future.FutureAsyncHttpClientHandler @@ -32,14 +33,27 @@ class BasicTests with Matchers with BeforeAndAfterAll with ScalaFutures + with OptionValues with StrictLogging with IntegrationPatience with TestHttpServer - with ForceWrapped { + with ForceWrapped + with BeforeAndAfterEach { + + override def afterEach() { + val file = File(outPath) + if (file.exists) file.delete() + } private def paramsToString(m: Map[String, String]): String = m.toList.sortBy(_._1).map(p => s"${p._1}=${p._2}").mkString(" ") + private val textFile = + new java.io.File("tests/src/test/resources/textfile.txt") + private val binaryFile = + new java.io.File("tests/src/test/resources/binaryfile.jpg") + private val outPath = Paths.get("out") + override val serverRoutes: Route = pathPrefix("echo") { pathPrefix("form_params") { @@ -115,6 +129,12 @@ class BasicTests encodeResponseWith(Gzip, Deflate, NoCoding) { complete("I'm compressed!") } + } ~ pathPrefix("download") { + path("binary") { + getFromFile(binaryFile) + } ~ path("text") { + getFromFile(textFile) + } } override def port = 51823 @@ -402,6 +422,95 @@ class BasicTests resp.body should be(decompressedBody) } } + + def downloadFileTests(): Unit = { + import CustomMatchers._ + + name should "download a binary file using asFile" in { + val file = outPath.resolve("binaryfile.jpg").toFile + val req = + sttp.get(uri"$endpoint/download/binary").response(asFile(file)) + val resp = req.send().force() + + resp.body shouldBe file + file should exist + file should haveSameContentAs(binaryFile) + } + + name should "download a text file using asFile" in { + val file = outPath.resolve("textfile.txt").toFile + val req = + sttp.get(uri"$endpoint/download/text").response(asFile(file)) + val resp = req.send().force() + + resp.body shouldBe file + file should exist + file should haveSameContentAs(textFile) + } + + name should "download a binary file using asPath" in { + val path = outPath.resolve("binaryfile.jpg") + val req = + sttp.get(uri"$endpoint/download/binary").response(asPath(path)) + val resp = req.send().force() + + resp.body shouldBe path + path.toFile should exist + path.toFile should haveSameContentAs(binaryFile) + } + + name should "download a text file using asPath" in { + val path = outPath.resolve("textfile.txt") + val req = + sttp.get(uri"$endpoint/download/text").response(asPath(path)) + val resp = req.send().force() + + resp.body shouldBe path + path.toFile should exist + path.toFile should haveSameContentAs(textFile) + } + + name should "fail at trying to save file to a restricted location" in { + val path = Paths.get("/").resolve("textfile.txt") + val req = + sttp.get(uri"$endpoint/download/text").response(asPath(path)) + val caught = intercept[IOException] { + req.send().force() + } + + caught.getMessage shouldBe "Permission denied" + } + + name should "fail when file exists and overwrite flag is false" in { + val path = outPath.resolve("textfile.txt") + path.toFile.getParentFile.mkdirs() + path.toFile.createNewFile() + val req = + sttp.get(uri"$endpoint/download/text").response(asPath(path)) + + val caught = intercept[IOException] { + req.send().force() + } + + caught.getMessage shouldBe s"File ${path.toFile.getAbsolutePath} exists - overwriting prohibited" + + } + + name should "not fail when file exists and overwrite flag is true" in { + val path = outPath.resolve("textfile.txt") + path.toFile.getParentFile.mkdirs() + path.toFile.createNewFile() + val req = + sttp + .get(uri"$endpoint/download/text") + .response(asPath(path, overwrite = true)) + val resp = req.send().force() + + resp.body shouldBe path + path.toFile should exist + path.toFile should haveSameContentAs(textFile) + } + } } override protected def afterAll(): Unit = { diff --git a/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala b/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala index 558e9dd..59e1612 100644 --- a/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala +++ b/tests/src/test/scala/com/softwaremill/sttp/testHelpers.scala @@ -1,14 +1,20 @@ package com.softwaremill.sttp +import java.nio.file.{Files, Paths} +import java.{io, util} + import akka.actor.ActorSystem import akka.http.scaladsl.Http import akka.http.scaladsl.server.Route import akka.stream.ActorMaterializer import org.scalatest.{BeforeAndAfterAll, Suite} import org.scalatest.concurrent.ScalaFutures +import org.scalatest.exceptions.TestFailedException +import org.scalatest.matchers.{MatchResult, Matcher} import scala.concurrent.Future import scala.language.higherKinds +import scalaz._ trait TestHttpServer extends BeforeAndAfterAll with ScalaFutures { this: Suite => @@ -41,23 +47,35 @@ trait ForceWrapped extends ScalaFutures { this: Suite => } val future = new ForceWrappedValue[Future] { override def force[T](wrapped: Future[T]): T = - wrapped.futureValue + try { + wrapped.futureValue + } catch { + case e: TestFailedException if e.getCause != null => throw e.getCause + } } val scalazTask = new ForceWrappedValue[scalaz.concurrent.Task] { override def force[T](wrapped: scalaz.concurrent.Task[T]): T = - wrapped.unsafePerformSync + wrapped.unsafePerformSyncAttempt match { + case -\/(error) => throw error + case \/-(value) => value + } } val monixTask = new ForceWrappedValue[monix.eval.Task] { import monix.execution.Scheduler.Implicits.global override def force[T](wrapped: monix.eval.Task[T]): T = - wrapped.runAsync.futureValue + try { + wrapped.runAsync.futureValue + } catch { + case e: TestFailedException => throw e.getCause + } } val catsIo = new ForceWrappedValue[cats.effect.IO] { override def force[T](wrapped: cats.effect.IO[T]): T = wrapped.unsafeRunSync } } + implicit class ForceDecorator[R[_], T](wrapped: R[T]) { def force()(implicit fwv: ForceWrappedValue[R]): T = fwv.force(wrapped) } @@ -72,3 +90,19 @@ object EvalScala { tb.eval(tb.parse(code)) } } + +object CustomMatchers { + class FileContentsMatch(file: java.io.File) extends Matcher[java.io.File] { + override def apply(left: io.File): MatchResult = { + val inBA = Files.readAllBytes(Paths.get(left.getAbsolutePath)) + val expectedBA = Files.readAllBytes(Paths.get(file.getAbsolutePath)) + MatchResult( + util.Arrays.equals(inBA, expectedBA), + "The files' contents are not the same", + "The files' contents are the same" + ) + } + } + + def haveSameContentAs(file: io.File) = new FileContentsMatch(file) +} |