From 11f2d06aa6b784e72f6bca2aaa3d997ab7755ac9 Mon Sep 17 00:00:00 2001 From: Nikolay Artamonov Date: Sun, 28 Aug 2022 16:50:58 +0300 Subject: [PATCH] Feature: Add the ability to get resources from JAR archives (#1393) * Add the ability to get resources from JAR archives * Update zio-http/src/main/scala/zhttp/http/Http.scala * Use comma to combine unit tests * Optimize method determineMediaType to eliminate extra flatMap Co-authored-by: Tushar Mathur --- zio-http/src/main/scala/zhttp/http/Http.scala | 61 +++++++++++++-- zio-http/src/test/resources/TestArchive.jar | Bin 0 -> 459 bytes .../zhttp/service/StaticFileServerSpec.scala | 73 +++++++++++++----- 3 files changed, 105 insertions(+), 29 deletions(-) create mode 100644 zio-http/src/test/resources/TestArchive.jar diff --git a/zio-http/src/main/scala/zhttp/http/Http.scala b/zio-http/src/main/scala/zhttp/http/Http.scala index 851e3d0ea4..70cad1dc4f 100644 --- a/zio-http/src/main/scala/zhttp/http/Http.scala +++ b/zio-http/src/main/scala/zhttp/http/Http.scala @@ -808,17 +808,11 @@ object Http { val response = Response(headers = length, body = Body.fromFile(file)) val pathName = file.toPath.toString - // Extract file extension - val ext = pathName.lastIndexOf(".") match { - case -1 => None - case i => Some(pathName.substring(i + 1)) - } - // Set MIME type in the response headers. This is only relevant in // case of RandomAccessFile transfers as browsers use the MIME type, // not the file extension, to determine how to process a URL. // {{{https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type}}} - Http.succeed(ext.flatMap(MediaType.forFileExtension).fold(response)(response.withMediaType)) + Http.succeed(determineMediaType(pathName).fold(response)(response.withMediaType)) } else Http.empty } } @@ -869,7 +863,58 @@ object Http { * Creates an Http app from a resource path */ def fromResource(path: String): HttpApp[Any, Throwable] = - Http.getResource(path).flatMap(url => Http.fromFile(new File(url.getPath))) + Http.getResource(path).flatMap(url => Http.fromResourceWithURL(url)) + + private[zhttp] def fromResourceWithURL(url: java.net.URL): HttpApp[Any, Throwable] = { + url.getProtocol match { + case "file" => + Http.fromFile(new File(url.getPath)) + case "jar" => + val path = new java.net.URI(url.getPath).getPath // remove "file:" prefix and normalize whitespace + val bangIndex = path.indexOf('!') + val filePath = path.substring(0, bangIndex) + val resourcePath = path.substring(bangIndex + 2) + val appZIO = + ZIO + .attemptBlockingIO(new java.util.zip.ZipFile(filePath)) + .asSomeError + .flatMap { jar => + val closeJar = ZIO.attemptBlocking(jar.close()).ignoreLogged + (for { + entry <- ZIO.attemptBlocking(Option(jar.getEntry(resourcePath))).some + _ <- ZIO.when(entry.isDirectory)(ZIO.fail(None)) + contentLength = entry.getSize + inStream <- ZIO.attemptBlockingIO(jar.getInputStream(entry)).asSomeError + mediaType = determineMediaType(resourcePath) + app = + Http + .fromStream( + ZStream + .fromInputStream(inStream) + .ensuring(closeJar), + ) + .withContentLength(contentLength) + } yield mediaType.fold(app)(t => app.withMediaType(t))).onError(_ => closeJar) + } + + Http.fromZIO(appZIO).flatten.catchAll { + case Some(e) => Http.fail(e) + case None => Http.empty + } + case proto => + Http.fail(new IllegalArgumentException(s"Unsupported protocol: $proto")) + } + } + + private def determineMediaType(filePath: String): Option[MediaType] = { + filePath.lastIndexOf(".") match { + case -1 => None + case i => + // Extract file extension + val ext = filePath.substring(i + 1) + MediaType.forFileExtension(ext) + } + } /** * Creates a Http that always succeeds with a 200 status code and the provided diff --git a/zio-http/src/test/resources/TestArchive.jar b/zio-http/src/test/resources/TestArchive.jar new file mode 100644 index 0000000000000000000000000000000000000000..0e941b3f77ee52ea87b9d6375d9a94ebe368a7bb GIT binary patch literal 459 zcmWIWW@Zs#;Nak3u$sjm%76qo8CV#6T|*poJ^kGD|D9rBU}gyLX6FE@V1g1I|hdb!=o|rwRl{+hTf^E-Qw#?85Xtpg7BW%|}b2<-DHYByU#4R%? zRj;I?#QXI5=U!eqOkgAO{7x2f0u``>jbLOFVL>kYy!v!$PNSf00DLbnaKVL@MdKL$ua}sb|C!^#9;sc3;|S{ literal 0 HcmV?d00001 diff --git a/zio-http/src/test/scala/zhttp/service/StaticFileServerSpec.scala b/zio-http/src/test/scala/zhttp/service/StaticFileServerSpec.scala index 0a070c8a26..d93fb8285e 100644 --- a/zio-http/src/test/scala/zhttp/service/StaticFileServerSpec.scala +++ b/zio-http/src/test/scala/zhttp/service/StaticFileServerSpec.scala @@ -15,36 +15,43 @@ object StaticFileServerSpec extends HttpRunnableSpec { private val env = EventLoopGroup.nio() ++ ChannelFactory.nio ++ ServerChannelFactory.nio ++ DynamicServer.live + private val fileOk = Http.fromResource("TestFile.txt").deploy + private val fileNotFound = Http.fromResource("Nothing").deploy + + private val testArchivePath = getClass.getResource("/TestArchive.jar").getPath + private val resourceOk = + Http.fromResourceWithURL(new java.net.URL(s"jar:file:$testArchivePath!/TestFile.txt")).deploy + private val resourceNotFound = + Http.fromResourceWithURL(new java.net.URL(s"jar:file:$testArchivePath!/NonExistent.txt")).deploy + override def spec = suite("StaticFileServer") { ZIO.scoped(serve(DynamicServer.app).as(List(staticSpec))) }.provideLayerShared(env) @@ timeout(5 seconds) private def staticSpec = suite("Static RandomAccessFile Server")( suite("fromResource")( - suite("file") { - val fileOk = Http.fromResource("TestFile.txt").deploy - val fileNotFound = Http.fromResource("Nothing").deploy + suite("file")( test("should have 200 status code") { val res = fileOk.run().map(_.status) assertZIO(res)(equalTo(Status.Ok)) - } + - test("should have content-length") { - val res = fileOk.run().map(_.contentLength) - assertZIO(res)(isSome(equalTo(7L))) - } + - test("should have content") { - val res = fileOk.run().flatMap(_.body.asString) - assertZIO(res)(equalTo("foo\nbar")) - } + - test("should have content-type") { - val res = fileOk.run().map(_.mediaType) - assertZIO(res)(isSome(equalTo(MediaType.text.plain))) - } + - test("should respond with empty") { - val res = fileNotFound.run().map(_.status) - assertZIO(res)(equalTo(Status.NotFound)) - } - }, + }, + test("should have content-length") { + val res = fileOk.run().map(_.contentLength) + assertZIO(res)(isSome(equalTo(7L))) + }, + test("should have content") { + val res = fileOk.run().flatMap(_.body.asString) + assertZIO(res)(equalTo("foo\nbar")) + }, + test("should have content-type") { + val res = fileOk.run().map(_.mediaType) + assertZIO(res)(isSome(equalTo(MediaType.text.plain))) + }, + test("should respond with empty") { + val res = fileNotFound.run().map(_.status) + assertZIO(res)(equalTo(Status.NotFound)) + }, + ), ), suite("fromFile")( suite("failure on construction")( @@ -64,6 +71,30 @@ object StaticFileServerSpec extends HttpRunnableSpec { }, ), ), + suite("fromResourceWithURL")( + suite("with 'jar' protocol")( + test("should have 200 status code") { + val res = resourceOk.run().map(_.status) + assertZIO(res)(equalTo(Status.Ok)) + }, + test("should have content-length") { + val res = resourceOk.run().map(_.contentLength) + assertZIO(res)(isSome(equalTo(7L))) + }, + test("should have content") { + val res = resourceOk.run().flatMap(_.body.asString) + assertZIO(res)(equalTo("foo\nbar")) + }, + test("should have content-type") { + val res = resourceOk.run().map(_.mediaType) + assertZIO(res)(isSome(equalTo(MediaType.text.plain))) + }, + test("should respond with empty") { + val res = resourceNotFound.run().map(_.status) + assertZIO(res)(equalTo(Status.NotFound)) + }, + ), + ), ) }