diff --git a/.gitignore b/.gitignore index ac61bde5..5dd457cd 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,3 @@ project/plugins/project/ # Log Output /log/ - -# Built gui -/src/main/resources/chatoverflow-gui \ No newline at end of file diff --git a/build.sbt b/build.sbt index b10c28c8..ee3c78f6 100644 --- a/build.sbt +++ b/build.sbt @@ -99,6 +99,13 @@ bs := BootstrapUtility.bootstrapGenTask(streams.value.log, s"$scalaMajorVersion$ deploy := BootstrapUtility.prepareDeploymentTask(streams.value.log, scalaMajorVersion) gui := BuildUtility(streams.value.log).guiTask(guiProjectPath.value, streams.value.cacheDirectory / "gui") +Compile / packageBin := { + BuildUtility(streams.value.log).packageGUITask(guiProjectPath.value, scalaMajorVersion, crossTarget.value) + (Compile / packageBin).value +} + +Compile / unmanagedJars := (crossTarget.value ** "chatoverflow-gui*.jar").classpath + // --------------------------------------------------------------------------------------------------------------------- // UTIL // --------------------------------------------------------------------------------------------------------------------- @@ -117,4 +124,3 @@ lazy val getDependencyList = Def.task[List[ModuleID]] { // Clears the built GUI dirs on clean cleanFiles += baseDirectory.value / guiProjectPath.value / "dist" -cleanFiles += baseDirectory.value / "src" / "main" / "resources" / "chatoverflow-gui" \ No newline at end of file diff --git a/project/BootstrapUtility.scala b/project/BootstrapUtility.scala index d7ebfee8..a162602c 100644 --- a/project/BootstrapUtility.scala +++ b/project/BootstrapUtility.scala @@ -165,19 +165,15 @@ object BootstrapUtility { } /** - * Copies ONE jar file from the source to all target directories. Useful for single packaged jar files. - */ + * Copies all jar files from the source to all target directories. + */ private def copyJars(sourceDirectory: String, targetDirectories: List[String], logger: ManagedLogger): Unit = { val candidates = new File(sourceDirectory) .listFiles().filter(f => f.isFile && f.getName.toLowerCase.endsWith(".jar")) - if (candidates.length != 1) { - logger warn s"Unable to identify jar file in $sourceDirectory" - } else { - for (targetDirectory <- targetDirectories) { - Files.copy(Paths.get(candidates.head.getAbsolutePath), - Paths.get(s"$targetDirectory/${candidates.head.getName}")) - logger info s"Finished copying file '${candidates.head.getAbsolutePath}' to '$targetDirectory'." - } + for (targetDirectory <- targetDirectories; file <- candidates) { + Files.copy(Paths.get(file.getAbsolutePath), + Paths.get(s"$targetDirectory/${file.getName}")) + logger info s"Finished copying file '${file.getAbsolutePath}' to '$targetDirectory'." } } } \ No newline at end of file diff --git a/project/BuildUtility.scala b/project/BuildUtility.scala index dc408456..774126f1 100644 --- a/project/BuildUtility.scala +++ b/project/BuildUtility.scala @@ -1,9 +1,13 @@ import java.io.{File, IOException} import java.nio.file.{Files, StandardCopyOption} +import java.util.jar.Manifest +import com.fasterxml.jackson.databind.ObjectMapper import sbt.internal.util.ManagedLogger import sbt.util.{FileFunction, FilesInfo} +import scala.io.Source + /** * A build utility instance handles build tasks and prints debug information using the managed logger. * @@ -139,90 +143,64 @@ class BuildUtility(logger: ManagedLogger) { return } - if (installGuiDeps(guiDir, cacheDir).isEmpty) - return // Early return on failure, error has already been displayed - - val outDir = buildGui(guiDir, cacheDir) - if (outDir.isEmpty) - return // Again early return on failure - - // Copy built gui into resources, will be included in the classpath on execution of the framework - sbt.IO.copyDirectory(outDir.get, new File("src/main/resources/chatoverflow-gui")) - } - } - - /** - * Download the dependencies of the gui using npm. - * - * @param guiDir the directory of the gui. - * @param cacheDir a dir, where sbt can store files for caching in the "install" sub-dir. - * @return None, if a error occurs which will be displayed, otherwise the output directory with the built gui. - */ - private def installGuiDeps(guiDir: File, cacheDir: File): Option[File] = { - // Check buildGui for a explanation, it's almost the same. + val packageJson = new File(guiDir, "package.json") - val install = FileFunction.cached(new File(cacheDir, "install"), FilesInfo.hash)(_ => { - - logger info "Installing GUI dependencies." - - val exitCode = new ProcessBuilder(getNpmCommand :+ "install": _*) - .inheritIO() - .directory(guiDir) - .start() - .waitFor() - - if (exitCode != 0) { - logger error "GUI dependencies couldn't be installed, please check above log for further details." - return None - } else { - logger info "GUI dependencies successfully installed." - Set(new File(guiDir, "node_modules")) + if (!executeNpmCommand(guiDir, cacheDir, Set(packageJson), "install", + () => logger error "GUI dependencies couldn't be installed, please check above log for further details.", + () => new File(guiDir, "node_modules") + )) { + return // early return on failure, error has already been displayed } - }) - val input = new File(guiDir, "package.json") - install(Set(input)).headOption + val srcFiles = recursiveFileListing(new File(guiDir, "src")) + val outDir = new File(guiDir, "dist") + + executeNpmCommand(guiDir, cacheDir, srcFiles + packageJson, "run build", + () => logger error "GUI couldn't be built, please check above log for further details.", + () => outDir + ) + } } /** - * Builds the gui using npm. - * - * @param guiDir the directory of the gui. - * @param cacheDir a dir, where sbt can store files for caching in the "build" sub-dir. - * @return None, if a error occurs which will be displayed, otherwise the output directory with the built gui. - */ - private def buildGui(guiDir: File, cacheDir: File): Option[File] = { + * Executes a npm command in the given directory and skips executing the given command + * if no input files have changed and the output file still exists. + * + * @param workDir the directory in which npm should be executed + * @param cacheDir a directory required for caching using sbt + * @param inputs the input files, which will be used for caching. + * If any one of these files change the cache is invalidated. + * @param command the npm command to execute + * @param failed called if npm returned an non-zero exit code + * @param success called if npm returned successfully. Needs to return a file for caching. + * If the returned file doesn't exist the npm command will ignore the cache. + * @return true if npm returned zero as a exit code and false otherwise + */ + private def executeNpmCommand(workDir: File, cacheDir: File, inputs: Set[File], command: String, + failed: () => Unit, success: () => File): Boolean = { // sbt allows easily to cache our external build using FileFunction.cached // sbt will only invoke the passed function when at least one of the input files (passed in the last line of this method) // has been modified. For the gui these input files are all files in the src directory of the gui and the package.json. // sbt passes these input files to the passed function, but they aren't used, we just instruct npm to build the gui. // sbt invalidates the cache as well if any of the output files (returned by the passed function) doesn't exist anymore. - - val build = FileFunction.cached(new File(cacheDir, "build"), FilesInfo.hash)(_ => { - - logger info "Building GUI." - - val buildExitCode = new ProcessBuilder(getNpmCommand :+ "run" :+ "build": _*) + val cachedFn = FileFunction.cached(new File(cacheDir, command), FilesInfo.hash) { _ => { + val exitCode = new ProcessBuilder(getNpmCommand ++ command.split("\\s+"): _*) .inheritIO() - .directory(guiDir) + .directory(workDir) .start() .waitFor() - if (buildExitCode != 0) { - logger error "GUI couldn't be built, please check above log for further details." - return None + if (exitCode != 0) { + failed() + return false } else { - logger info "GUI successfully built." - Set(new File(guiDir, "dist")) + Set(success()) } - }) - - - val srcDir = new File(guiDir, "src") - val packageJson = new File(guiDir, "package.json") - val inputs = recursiveFileListing(srcDir) + packageJson + } + } - build(inputs).headOption + cachedFn(inputs) + true } private def getNpmCommand: List[String] = { @@ -233,6 +211,43 @@ class BuildUtility(logger: ManagedLogger) { } } + def packageGUITask(guiProjectPath: String, scalaMajorVersion: String, crossTargetDir: File): Unit = { + val dir = new File(guiProjectPath, "dist") + if (!dir.exists()) { + logger info "GUI hasn't been compiled. Won't create a jar for it." + return + } + + val files = recursiveFileListing(dir) + + // contains tuples with the actual file as the first value and the name with directory in the jar as the second value + val jarEntries = files.map(file => file -> s"/chatoverflow-gui/${dir.toURI.relativize(file.toURI).toString}") + + val guiVersion = getGUIVersion(guiProjectPath).getOrElse("unknown") + + sbt.IO.jar(jarEntries, new File(crossTargetDir, s"chatoverflow-gui_$scalaMajorVersion-$guiVersion.jar"), new Manifest()) + } + + private def getGUIVersion(guiProjectPath: String): Option[String] = { + val packageJson = new File(s"$guiProjectPath/package.json") + if (!packageJson.exists()) { + logger error "The package.json file of the GUI doesn't exist. Have you cloned the GUI in the correct directory?" + return None + } + + val content = Source.fromFile(packageJson) + val version = new ObjectMapper().reader().readTree(content.mkString).get("version").asText() + + content.close() + + if (version.isEmpty) { + logger warn "The GUI version couldn't be loaded from the package.json." + None + } else { + Option(version) + } + } + /** * Creates a file listing with all files including files in any sub-dir. * diff --git a/project/plugins.sbt b/project/plugins.sbt index 02d779bd..9e16c189 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1 +1,4 @@ -addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2") \ No newline at end of file +addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2") + +// JSON lib (Jackson) used for parsing the GUI version in the package.json file +libraryDependencies += "org.json4s" %% "json4s-jackson" % "3.5.2" \ No newline at end of file diff --git a/src/main/scala/ScalatraBootstrap.scala b/src/main/scala/ScalatraBootstrap.scala index 098487d2..d8d37d81 100644 --- a/src/main/scala/ScalatraBootstrap.scala +++ b/src/main/scala/ScalatraBootstrap.scala @@ -4,7 +4,7 @@ import org.codeoverflow.chatoverflow.ui.web.rest.connector.ConnectorController import org.codeoverflow.chatoverflow.ui.web.rest.events.{EventsController, EventsDispatcher} import org.codeoverflow.chatoverflow.ui.web.rest.plugin.PluginInstanceController import org.codeoverflow.chatoverflow.ui.web.rest.types.TypeController -import org.codeoverflow.chatoverflow.ui.web.{CodeOverflowSwagger, OpenAPIServlet} +import org.codeoverflow.chatoverflow.ui.web.{CodeOverflowSwagger, GUIServlet, OpenAPIServlet} import org.scalatra._ /** @@ -30,5 +30,7 @@ class ScalatraBootstrap extends LifeCycle { context.mount(new PluginInstanceController(), "/instances/*", "instances") context.mount(new ConnectorController(), "/connectors/*", "connectors") context.mount(new OpenAPIServlet(), "/api-docs") + + context.mount(new GUIServlet(), "/*") } } \ No newline at end of file diff --git a/src/main/scala/org/codeoverflow/chatoverflow/ui/web/GUIServlet.scala b/src/main/scala/org/codeoverflow/chatoverflow/ui/web/GUIServlet.scala new file mode 100644 index 00000000..76687f22 --- /dev/null +++ b/src/main/scala/org/codeoverflow/chatoverflow/ui/web/GUIServlet.scala @@ -0,0 +1,62 @@ +package org.codeoverflow.chatoverflow.ui.web + +import java.io.File +import java.net.URI +import java.util.jar.JarFile + +import org.codeoverflow.chatoverflow.WithLogger +import org.eclipse.jetty.http.MimeTypes +import org.eclipse.jetty.util.Loader +import org.scalatra.{ActionResult, ScalatraServlet} + +import scala.io.Source + +/** + * A servlet to serve the GUI files of the chatoverflow-gui dir from the classpath. + * This directory is provided if the gui jar is added on the classpath. + * Responds with an error if the gui jar isn't on the classpath. + */ +class GUIServlet extends ScalatraServlet with WithLogger { + + private val jarFilePath = { + val res = Loader.getResource(s"/chatoverflow-gui/") + + // directory couldn't be found + if (res == null) { + logger error "GUI couldn't be found on the classpath! Has the GUI been built?" + None + } else { + // remove the path inside the jar and only keep the file path to the jar file + val jarPath = res.getFile.split("!").head + logger info s"GUI jar file found at ${new File(".").toURI.relativize(new URI(jarPath))}" + + Some(jarPath) + } + } + + get("/*") { + if (jarFilePath.isEmpty) { + ActionResult(500, "GUI couldn't be found on the classpath! Has the GUI been built?", Map()) + } else { + val jarFile = new JarFile(new File(new URI(jarFilePath.get))) + + val path = if (requestPath == "/") + "/index.html" + else + requestPath + + val entry = jarFile.getEntry(s"/chatoverflow-gui$path") + + val res = if (entry == null) { + ActionResult(404, s"Requested file '$path' couldn't be found in the GUI jar!", Map()) + } else { + contentType = MimeTypes.getDefaultMimeByExtension(entry.getName) + Source.fromInputStream(jarFile.getInputStream(entry)).mkString + } + + response.setHeader("Cache-Control", "no-cache,no-store") + jarFile.close() + res + } + } +} diff --git a/src/main/scala/org/codeoverflow/chatoverflow/ui/web/Server.scala b/src/main/scala/org/codeoverflow/chatoverflow/ui/web/Server.scala index 7e46e95e..7f75c796 100644 --- a/src/main/scala/org/codeoverflow/chatoverflow/ui/web/Server.scala +++ b/src/main/scala/org/codeoverflow/chatoverflow/ui/web/Server.scala @@ -1,7 +1,6 @@ package org.codeoverflow.chatoverflow.ui.web import org.codeoverflow.chatoverflow.{ChatOverflow, WithLogger} -import org.eclipse.jetty.util.resource.Resource import org.eclipse.jetty.webapp.WebAppContext import org.scalatra.servlet.ScalatraListener @@ -16,9 +15,8 @@ class Server(val chatOverflow: ChatOverflow, val port: Int) extends WithLogger { private val server = new org.eclipse.jetty.server.Server(port) private val context = new WebAppContext() context.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed", "false") - context.setInitParameter("org.eclipse.jetty.servlet.Default.cacheControl", "no-cache,no-store") context setContextPath "/" - context.setBaseResource(Resource.newClassPathResource("/chatoverflow-gui/")) + context setResourceBase "/" context.addEventListener(new ScalatraListener) server.setHandler(context)