From 4679d5fadffa233a42e3831d5b191c9dacb4fbdc Mon Sep 17 00:00:00 2001 From: Alexandre Archambault Date: Sat, 29 Apr 2017 17:33:26 +0200 Subject: [PATCH] Switch to latest http4s Travis Mac CI now seems to be on Java 8, no need to compile the http-server module against the latest Java 7 compatible http4s --- build.sbt | 20 +- .../main/scala-2.11/coursier/HttpServer.scala | 274 ------------------ .../src/main/scala/coursier/HttpServer.scala | 271 +++++++++++++++++ project/Deps.scala | 3 +- project/SharedVersions.scala | 5 +- scripts/launch-test-repo.sh | 5 +- scripts/start-it-auth-server.ps1 | 2 +- scripts/start-it-no-listing-server.ps1 | 2 +- 8 files changed, 286 insertions(+), 296 deletions(-) delete mode 100644 http-server/src/main/scala-2.11/coursier/HttpServer.scala create mode 100644 http-server/src/main/scala/coursier/HttpServer.scala diff --git a/build.sbt b/build.sbt index 8aece00de..ef39bd37a 100644 --- a/build.sbt +++ b/build.sbt @@ -191,19 +191,13 @@ lazy val `http-server` = project .settings( shared, generatePack, - dontPublishIn("2.10", "2.12"), - name := "http-server-java7", - libs ++= { - if (scalaBinaryVersion.value == "2.11") - Seq( - Deps.http4sBlazeServer, - Deps.http4sDsl, - Deps.slf4jNop, - Deps.caseApp - ) - else - Seq() - } + name := "http-server", + libs ++= Seq( + Deps.http4sBlazeServer, + Deps.http4sDsl, + Deps.slf4jNop, + Deps.caseApp12 + ) ) lazy val okhttp = project diff --git a/http-server/src/main/scala-2.11/coursier/HttpServer.scala b/http-server/src/main/scala-2.11/coursier/HttpServer.scala deleted file mode 100644 index 283cbedb4..000000000 --- a/http-server/src/main/scala-2.11/coursier/HttpServer.scala +++ /dev/null @@ -1,274 +0,0 @@ -package coursier - -import java.io.{ File, FileOutputStream } -import java.net.NetworkInterface -import java.nio.channels.{ FileLock, OverlappingFileLockException } - -import org.http4s.dsl._ -import org.http4s.headers.{ Authorization, `Content-Type` } -import org.http4s.server.HttpService -import org.http4s.server.blaze.BlazeBuilder -import org.http4s.{ BasicCredentials, Challenge, EmptyBody, MediaType, Request, Response } - -import caseapp._ - -import scala.collection.JavaConverters._ - -import scalaz.concurrent.Task - -final case class HttpServerApp( - @ExtraName("d") - @ValueDescription("served directory") - directory: String, - @ExtraName("h") - @ValueDescription("host") - host: String = "0.0.0.0", - @ExtraName("p") - @ValueDescription("port") - port: Int = 8080, - @ExtraName("s") - acceptPost: Boolean, - @ExtraName("t") - acceptPut: Boolean, - @ExtraName("w") - @HelpMessage("Accept write requests. Equivalent to -s -t") - acceptWrite: Boolean, - @ExtraName("v") - verbose: Int @@ Counter, - @ExtraName("q") - quiet: Boolean, - @ExtraName("u") - @ValueDescription("user") - user: String, - @ExtraName("P") - @ValueDescription("password") - password: String, - @ExtraName("r") - @ValueDescription("realm") - realm: String, - @ExtraName("l") - @HelpMessage("Generate content listing pages for directories") - listPages: Boolean -) extends App { - - val baseDir = new File(if (directory.isEmpty) "." else directory) - - val verbosityLevel = Tag.unwrap(verbose) - (if (quiet) 1 else 0) - - def write(path: Seq[String], req: Request): Boolean = { - - val f = new File(baseDir, path.toList.mkString("/")) - f.getParentFile.mkdirs() - - var os: FileOutputStream = null - var lock: FileLock = null - try { - os = new FileOutputStream(f) - lock = - try os.getChannel.tryLock() - catch { - case _: OverlappingFileLockException => - null - } - - if (lock == null) - false - else { - req.body.runLog.run.foreach { b => - b.copyToStream(os) - } - - true - } - } finally { - if (lock != null) - lock.release() - if (os != null) - os.close() - } - } - - if (user.nonEmpty && password.isEmpty) - Console.err.println( - "Warning: authentication enabled but no password specified. " + - "Specify one with the --password or -P option." - ) - - if (password.nonEmpty && user.isEmpty) - Console.err.println( - "Warning: authentication enabled but no user specified. " + - "Specify one with the --user or -u option." - ) - - if ((user.nonEmpty || password.nonEmpty) && realm.isEmpty) - Console.err.println( - "Warning: authentication enabled but no realm specified. " + - "Specify one with the --realm or -r option." - ) - - val unauthorized = Unauthorized(Challenge("Basic", realm)) - - def authenticated(pf: PartialFunction[Request, Task[Response]]): HttpService = - authenticated0(HttpService(pf)) - - def authenticated0(service: HttpService): HttpService = - if (user.isEmpty && password.isEmpty) - service - else - HttpService { - case req => - def warn(msg: => String) = - if (verbosityLevel >= 1) - Console.err.println(s"${req.method.name} ${req.uri.path}: $msg") - - req.headers.get(Authorization) match { - case None => - warn("no authentication provided") - unauthorized - case Some(auth) => - auth.credentials match { - case basic: BasicCredentials => - if (basic.username == user && basic.password == password) - service.run(req).flatMap { - case Some(v) => Task.now(v) - case None => NotFound() - } - else { - warn { - val msg = - if (basic.username == user) - "wrong password" - else - s"unrecognized user ${basic.username}" - - s"authentication failed ($msg)" - } - unauthorized - } - case _ => - warn("no basic credentials found") - unauthorized - } - } - } - - def putService = authenticated { - case req @ PUT -> path => - if (verbosityLevel >= 1) - Console.err.println(s"PUT $path") - - if (write(path.toList, req)) - Ok() - else - Locked() - } - - def postService = authenticated { - case req @ POST -> path => - if (verbosityLevel >= 1) - Console.err.println(s"POST $path") - - if (write(path.toList, req)) - Ok() - else - Locked() - } - - def isDirectory(f: File): Task[Option[Boolean]] = - Task { - if (f.isDirectory) - Some(true) - else if (f.isFile) - Some(false) - else - None - } - - def directoryListingPage(dir: File, title: String): Task[String] = - Task { - val entries = dir - .listFiles() - .flatMap { f => - def name = f.getName - if (f.isDirectory) - Seq(name + "/") - else if (f.isFile) - Seq(name) - else - Nil - } - - // meh escaping - // TODO Use to scalatags to generate that - s""" - | - | - |$title - | - | - | - | - | - """.stripMargin - } - - def getService = authenticated { - case (method @ (GET | HEAD)) -> path => - if (verbosityLevel >= 1) - Console.err.println(s"${method.name} $path") - - val relPath = path.toList.mkString("/") - val f = new File(baseDir, relPath) - val resp = - for { - isDirOpt <- isDirectory(f) - resp <- isDirOpt match { - case Some(true) if listPages => - directoryListingPage(f, relPath).flatMap(page => - Ok(page).withContentType(Some(`Content-Type`(MediaType.`text/html`))) - ) - case Some(false) => Ok(f) - case _ => NotFound() - } - } yield resp - - method match { - case HEAD => - resp.map(_.copy(body = EmptyBody)) - case _ => - resp - } - } - - val builder = { - var b = BlazeBuilder.bindHttp(port, host) - - if (acceptWrite || acceptPut) - b = b.mountService(putService) - if (acceptWrite || acceptPost) - b = b.mountService(postService) - - b = b.mountService(getService) - - b - } - - if (verbosityLevel >= 0) { - Console.err.println(s"Listening on http://$host:$port") - - if (verbosityLevel >= 1 && host == "0.0.0.0") { - Console.err.println(s"Listening on addresses") - for (itf <- NetworkInterface.getNetworkInterfaces.asScala; addr <- itf.getInetAddresses.asScala) - Console.err.println(s" ${addr.getHostAddress} (${itf.getName})") - } - } - - builder - .run - .awaitShutdown() - -} - -object HttpServer extends AppOf[HttpServerApp] diff --git a/http-server/src/main/scala/coursier/HttpServer.scala b/http-server/src/main/scala/coursier/HttpServer.scala new file mode 100644 index 000000000..fbef7f8a9 --- /dev/null +++ b/http-server/src/main/scala/coursier/HttpServer.scala @@ -0,0 +1,271 @@ +package coursier + +import java.io.{File, FileOutputStream} +import java.net.NetworkInterface +import java.nio.channels.{FileLock, OverlappingFileLockException} + +import org.http4s._ +import org.http4s.dsl._ +import org.http4s.headers.{Authorization, `Content-Type`} +import org.http4s.server.blaze.BlazeBuilder + +import caseapp._ + +import scala.collection.JavaConverters._ +import scalaz.concurrent.Task + +final case class HttpServerOptions( + @ExtraName("d") + @ValueDescription("served directory") + directory: String, + @ExtraName("h") + @ValueDescription("host") + host: String = "0.0.0.0", + @ExtraName("p") + @ValueDescription("port") + port: Int = 8080, + @ExtraName("s") + acceptPost: Boolean, + @ExtraName("t") + acceptPut: Boolean, + @ExtraName("w") + @HelpMessage("Accept write requests. Equivalent to -s -t") + acceptWrite: Boolean, + @ExtraName("v") + verbose: Int @@ Counter, + @ExtraName("q") + quiet: Boolean, + @ExtraName("u") + @ValueDescription("user") + user: String, + @ExtraName("P") + @ValueDescription("password") + password: String, + @ExtraName("r") + @ValueDescription("realm") + realm: String, + @ExtraName("l") + @HelpMessage("Generate content listing pages for directories") + listPages: Boolean +) + +object HttpServer extends CaseApp[HttpServerOptions] { + def run(options: HttpServerOptions, args: RemainingArgs): Unit = { + + val baseDir = new File(if (options.directory.isEmpty) "." else options.directory) + + val verbosityLevel = Tag.unwrap(options.verbose) - (if (options.quiet) 1 else 0) + + def write(path: Seq[String], req: Request): Boolean = { + + val f = new File(baseDir, path.toList.mkString("/")) + f.getParentFile.mkdirs() + + var os: FileOutputStream = null + var lock: FileLock = null + try { + os = new FileOutputStream(f) + lock = + try os.getChannel.tryLock() + catch { + case _: OverlappingFileLockException => + null + } + + if (lock == null) + false + else { + req.body.runLog.unsafePerformSync.foreach { b => + b.copyToStream(os) + } + + true + } + } finally { + if (lock != null) + lock.release() + if (os != null) + os.close() + } + } + + if (options.user.nonEmpty && options.password.isEmpty) + Console.err.println( + "Warning: authentication enabled but no password specified. " + + "Specify one with the --password or -P option." + ) + + if (options.password.nonEmpty && options.user.isEmpty) + Console.err.println( + "Warning: authentication enabled but no user specified. " + + "Specify one with the --user or -u option." + ) + + if ((options.user.nonEmpty || options.password.nonEmpty) && options.realm.isEmpty) + Console.err.println( + "Warning: authentication enabled but no realm specified. " + + "Specify one with the --realm or -r option." + ) + + val unauthorized = Unauthorized(Challenge("Basic", options.realm)) + + def authenticated(pf: PartialFunction[Request, Task[Response]]): HttpService = + authenticated0(HttpService(pf)) + + def authenticated0(service: HttpService): HttpService = + if (options.user.isEmpty && options.password.isEmpty) + service + else + HttpService { + case req => + def warn(msg: => String) = + if (verbosityLevel >= 1) + Console.err.println(s"${req.method.name} ${req.uri.path}: $msg") + + req.headers.get(Authorization) match { + case None => + warn("no authentication provided") + unauthorized + case Some(auth) => + auth.credentials match { + case basic: BasicCredentials => + if (basic.username == options.user && basic.password == options.password) + service.run(req) + else { + warn { + val msg = + if (basic.username == options.user) + "wrong password" + else + s"unrecognized user ${basic.username}" + + s"authentication failed ($msg)" + } + unauthorized + } + case _ => + warn("no basic credentials found") + unauthorized + } + } + } + + def putService = authenticated { + case req @ PUT -> path => + if (verbosityLevel >= 1) + Console.err.println(s"PUT $path") + + if (write(path.toList, req)) + Ok() + else + Locked() + } + + def postService = authenticated { + case req @ POST -> path => + if (verbosityLevel >= 1) + Console.err.println(s"POST $path") + + if (write(path.toList, req)) + Ok() + else + Locked() + } + + def isDirectory(f: File): Task[Option[Boolean]] = + Task { + if (f.isDirectory) + Some(true) + else if (f.isFile) + Some(false) + else + None + } + + def directoryListingPage(dir: File, title: String): Task[String] = + Task { + val entries = dir + .listFiles() + .flatMap { f => + def name = f.getName + if (f.isDirectory) + Seq(name + "/") + else if (f.isFile) + Seq(name) + else + Nil + } + + // meh escaping + // TODO Use to scalatags to generate that + s""" + | + | + |$title + | + | + | + | + | + """.stripMargin + } + + def getService = authenticated { + case (method @ (GET | HEAD)) -> path => + if (verbosityLevel >= 1) + Console.err.println(s"${method.name} $path") + + val relPath = path.toList.mkString("/") + val f = new File(baseDir, relPath) + val resp = + for { + isDirOpt <- isDirectory(f) + resp <- isDirOpt match { + case Some(true) if options.listPages => + directoryListingPage(f, relPath).flatMap(page => + Ok(page).withContentType(Some(`Content-Type`(MediaType.`text/html`))) + ) + case Some(false) => Ok(f) + case _ => NotFound() + } + } yield resp + + method match { + case HEAD => + resp.map(_.copy(body = EmptyBody)) + case _ => + resp + } + } + + val builder = { + var b = BlazeBuilder.bindHttp(options.port, options.host) + + if (options.acceptWrite || options.acceptPut) + b = b.mountService(putService) + if (options.acceptWrite || options.acceptPost) + b = b.mountService(postService) + + b = b.mountService(getService) + + b + } + + if (verbosityLevel >= 0) { + Console.err.println(s"Listening on http://${options.host}:${options.port}") + + if (verbosityLevel >= 1 && options.host == "0.0.0.0") { + Console.err.println(s"Listening on addresses") + for (itf <- NetworkInterface.getNetworkInterfaces.asScala; addr <- itf.getInetAddresses.asScala) + Console.err.println(s" ${addr.getHostAddress} (${itf.getName})") + } + } + + builder + .run + .awaitShutdown() + } + +} diff --git a/project/Deps.scala b/project/Deps.scala index 4f0461186..d85772737 100644 --- a/project/Deps.scala +++ b/project/Deps.scala @@ -10,7 +10,8 @@ object Deps { def scalaXml = "org.scala-lang.modules" %% "scala-xml" % "1.0.6" def scalazConcurrent = "org.scalaz" %% "scalaz-concurrent" % SharedVersions.scalaz def caseApp = "com.github.alexarchambault" %% "case-app" % "1.1.3" - def http4sBlazeServer = "org.http4s" %% "http4s-blazeserver" % SharedVersions.http4s + def caseApp12 = "com.github.alexarchambault" %% "case-app" % "1.2.0-M2" + def http4sBlazeServer = "org.http4s" %% "http4s-blaze-server" % SharedVersions.http4s def http4sDsl = "org.http4s" %% "http4s-dsl" % SharedVersions.http4s def slf4jNop = "org.slf4j" % "slf4j-nop" % "1.7.22" def okhttpUrlConnection = "com.squareup.okhttp" % "okhttp-urlconnection" % "2.7.5" diff --git a/project/SharedVersions.scala b/project/SharedVersions.scala index 3442e7b89..1eecde8b2 100644 --- a/project/SharedVersions.scala +++ b/project/SharedVersions.scala @@ -2,8 +2,7 @@ object SharedVersions { val scalaz = "7.2.8" - - // last http4s version compatible with Java 7 (Travis-based Mac CI still on Java 7...) - val http4s = "0.8.6" + + val http4s = "0.15.9a" } \ No newline at end of file diff --git a/scripts/launch-test-repo.sh b/scripts/launch-test-repo.sh index 0e0685a7b..fca2ec105 100755 --- a/scripts/launch-test-repo.sh +++ b/scripts/launch-test-repo.sh @@ -8,12 +8,11 @@ cd "$(dirname "$0")/.." # synchronously fill cache so that two runs of this script don't try to download # a same file at the same time (and one of them fail because of locks) ./coursier fetch \ - "io.get-coursier:http-server-java7_2.11:$VERSION" \ + "io.get-coursier:http-server_2.11:$VERSION" \ -r https://dl.bintray.com/scalaz/releases ./coursier launch \ - "io.get-coursier:http-server-java7_2.11:$VERSION" \ - -r https://dl.bintray.com/scalaz/releases \ + "io.get-coursier:http-server_2.11:$VERSION" \ -- \ -d tests/jvm/src/test/resources/test-repo/http/abc.com \ -u user -P pass -r realm \ diff --git a/scripts/start-it-auth-server.ps1 b/scripts/start-it-auth-server.ps1 index d9ffdda0d..d1c8456c9 100644 --- a/scripts/start-it-auth-server.ps1 +++ b/scripts/start-it-auth-server.ps1 @@ -1,3 +1,3 @@ # see https://stackoverflow.com/questions/2224350/powershell-start-job-working-directory/2246542#2246542 Set-Location $args[0] -& java -jar -noverify coursier launch -r https://dl.bintray.com/scalaz/releases io.get-coursier:http-server-java7_2.11:1.0.0-SNAPSHOT -- -d tests/jvm/src/test/resources/test-repo/http/abc.com -u user -P pass -r realm --port 8080 --list-pages -v +& java -jar -noverify coursier launch io.get-coursier:http-server_2.11:1.0.0-SNAPSHOT -- -d tests/jvm/src/test/resources/test-repo/http/abc.com -u user -P pass -r realm --port 8080 --list-pages -v diff --git a/scripts/start-it-no-listing-server.ps1 b/scripts/start-it-no-listing-server.ps1 index 0e217bd7a..8d0275bc1 100644 --- a/scripts/start-it-no-listing-server.ps1 +++ b/scripts/start-it-no-listing-server.ps1 @@ -1,3 +1,3 @@ # see https://stackoverflow.com/questions/2224350/powershell-start-job-working-directory/2246542#2246542 Set-Location $args[0] -& java -jar -noverify coursier launch -r https://dl.bintray.com/scalaz/releases io.get-coursier:http-server-java7_2.11:1.0.0-SNAPSHOT -- -d tests/jvm/src/test/resources/test-repo/http/abc.com -u user -P pass -r realm --port 8081 -v +& java -jar -noverify coursier launch io.get-coursier:http-server_2.11:1.0.0-SNAPSHOT -- -d tests/jvm/src/test/resources/test-repo/http/abc.com -u user -P pass -r realm --port 8081 -v