diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5eb9685e..86828b7a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,5 +11,5 @@ jobs: - uses: actions/checkout@v1 - uses: actions/setup-java@v1 with: - java-version: 11 + java-version: 17 - run: sbt javafmtCheckAll scalafmtCheckAll "scalafixAll --check" test publishLocal scripted diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0eaa10..c9d699c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,3 +100,13 @@ $ git push --tags origin Update the [Giter8 template](https://github.com/earldouglas/sbt-war.g8) to use the new version. + +## Reference + +* Servlet version history: + +* Jetty version history: + +* Tomcat version history: + + diff --git a/build.sbt b/build.sbt index b0e8a27d..e827f6b3 100644 --- a/build.sbt +++ b/build.sbt @@ -20,14 +20,14 @@ lazy val warRunner_3_0 = Compile / compile / javacOptions ++= Seq( "-source", - "11", + "17", "-target", - "11", + "17", "-g:lines" ), crossPaths := false, // exclude Scala suffix from artifact names autoScalaLibrary := false, // exclude scala-library from dependencies - libraryDependencies += "com.github.jsimone" % "webapp-runner" % "7.0.91.0" + libraryDependencies += "org.eclipse.jetty" % "jetty-webapp" % "8.1.22.v20160922" ) lazy val warRunner_3_1 = @@ -39,14 +39,14 @@ lazy val warRunner_3_1 = Compile / compile / javacOptions ++= Seq( "-source", - "11", + "17", "-target", - "11", + "17", "-g:lines" ), crossPaths := false, // exclude Scala suffix from artifact names autoScalaLibrary := false, // exclude scala-library from dependencies - libraryDependencies += "com.heroku" % "webapp-runner" % "8.5.68.1" + libraryDependencies += "org.eclipse.jetty" % "jetty-webapp" % "9.4.56.v20240826" ) lazy val warRunner_4_0 = @@ -58,14 +58,35 @@ lazy val warRunner_4_0 = Compile / compile / javacOptions ++= Seq( "-source", - "11", + "17", "-target", - "11", + "17", "-g:lines" ), crossPaths := false, // exclude Scala suffix from artifact names autoScalaLibrary := false, // exclude scala-library from dependencies - libraryDependencies += "com.heroku" % "webapp-runner" % "9.0.96.0" + libraryDependencies += "com.heroku" % "webapp-runner" % "9.0.96.0", + libraryDependencies += "org.eclipse.jetty" % "jetty-webapp" % "10.0.24" + ) + +lazy val warRunner_5_0 = + project + .in(file("runners/5.0")) + .settings( + name := "war-runner", + version := version.value + "_5.0", + Compile / compile / javacOptions ++= + Seq( + "-source", + "17", + "-target", + "17", + "-g:lines" + ), + crossPaths := false, // exclude Scala suffix from artifact names + autoScalaLibrary := false, // exclude scala-library from dependencies + libraryDependencies += "com.heroku" % "webapp-runner" % "10.1.31.0", + libraryDependencies += "org.eclipse.jetty" % "jetty-webapp" % "11.0.24" ) lazy val warRunner_6_0 = @@ -77,14 +98,15 @@ lazy val warRunner_6_0 = Compile / compile / javacOptions ++= Seq( "-source", - "11", + "17", "-target", - "11", + "17", "-g:lines" ), crossPaths := false, // exclude Scala suffix from artifact names autoScalaLibrary := false, // exclude scala-library from dependencies - libraryDependencies += "com.heroku" % "webapp-runner" % "10.1.31.0" + libraryDependencies += "com.heroku" % "webapp-runner" % "10.1.31.0", + libraryDependencies += "org.eclipse.jetty.ee10" % "jetty-ee10-webapp" % "12.0.15" ) lazy val sbtWar = @@ -113,7 +135,8 @@ lazy val sbtWar = warRunner_3_0, warRunner_3_1, warRunner_4_0, - warRunner_6_0 + warRunner_5_0, + warRunner_6_0, ) // Publish to Sonatype, https://www.scala-sbt.org/release/docs/Using-Sonatype.html diff --git a/runners/3.0/src/main/java/com/earldouglas/WarRunner.java b/runners/3.0/src/main/java/com/earldouglas/WarRunner.java index a9b9f444..61565245 100644 --- a/runners/3.0/src/main/java/com/earldouglas/WarRunner.java +++ b/runners/3.0/src/main/java/com/earldouglas/WarRunner.java @@ -1,5 +1,12 @@ package com.earldouglas; +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.server.Server; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + public class WarRunner { /** @@ -12,11 +19,21 @@ public static void main(final String[] args) throws Exception { final WarConfiguration warConfiguration = WarConfiguration.load(args[0]); - final String[] warRunnerArgs = - new String[] { - "--port", Integer.toString(warConfiguration.port), warConfiguration.warFile.getPath(), - }; + final Path warPath = + Paths + .get(warConfiguration.warFile.getPath()) + .toAbsolutePath() + .normalize(); + + final Server server = new Server(warConfiguration.port); + + final WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setWar(warPath.toUri().toASCIIString()); + + server.setHandler(webapp); - webapp.runner.launch.Main.main(warRunnerArgs); + server.start(); + server.join(); } } diff --git a/runners/3.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml b/runners/3.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..d6a9c866 --- /dev/null +++ b/runners/3.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + Hello, Servlet 3.0 + + + HelloServlet + com.earldouglas.HelloServlet + + + + HelloServlet + /hello + + + + default + / + + + diff --git a/runners/3.0/src/test/java/com/earldouglas/HelloServlet.java b/runners/3.0/src/test/java/com/earldouglas/HelloServlet.java new file mode 100644 index 00000000..9799d9f2 --- /dev/null +++ b/runners/3.0/src/test/java/com/earldouglas/HelloServlet.java @@ -0,0 +1,21 @@ +package com.earldouglas; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + + @Override + protected void doGet( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + response.setContentType("text/plain"); + response.getWriter().println("Hello, world!"); + } +} diff --git a/runners/3.0/src/test/scala/com/earldouglas/WarRunnerTest.scala b/runners/3.0/src/test/scala/com/earldouglas/WarRunnerTest.scala index 955f1e4b..02ef8ea3 100644 --- a/runners/3.0/src/test/scala/com/earldouglas/WarRunnerTest.scala +++ b/runners/3.0/src/test/scala/com/earldouglas/WarRunnerTest.scala @@ -11,6 +11,8 @@ class WarRunnerTest override def beforeAll(): Unit = { + new com.earldouglas.HelloServlet() + val thread: Thread = new Thread { override def run(): Unit = { @@ -44,7 +46,7 @@ class WarRunnerTest awaitOpen(8988) } - test("/foo.html") { + test("GET /foo.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -71,7 +73,7 @@ class WarRunnerTest ) shouldBe expected } - test("/bar.html") { + test("GET /bar.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -98,7 +100,7 @@ class WarRunnerTest ) shouldBe expected } - test("/baz/raz.css") { + test("GET /baz/raz.css") { val expected: HttpClient.Response = HttpClient.Response( @@ -124,4 +126,37 @@ class WarRunnerTest } ) shouldBe expected } + + test("GET /hello") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/plain" + ), + body = """|Hello, world! + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/hello", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = + obtained + .headers + .filter { case (k, _) => + k == "Content-Type" + } + .map { case (k, v) => + (k, v.replaceAll(";charset=.*", "")) + } + ) shouldBe expected + } } diff --git a/runners/3.1/src/main/java/com/earldouglas/WarRunner.java b/runners/3.1/src/main/java/com/earldouglas/WarRunner.java index a9b9f444..61565245 100644 --- a/runners/3.1/src/main/java/com/earldouglas/WarRunner.java +++ b/runners/3.1/src/main/java/com/earldouglas/WarRunner.java @@ -1,5 +1,12 @@ package com.earldouglas; +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.server.Server; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + public class WarRunner { /** @@ -12,11 +19,21 @@ public static void main(final String[] args) throws Exception { final WarConfiguration warConfiguration = WarConfiguration.load(args[0]); - final String[] warRunnerArgs = - new String[] { - "--port", Integer.toString(warConfiguration.port), warConfiguration.warFile.getPath(), - }; + final Path warPath = + Paths + .get(warConfiguration.warFile.getPath()) + .toAbsolutePath() + .normalize(); + + final Server server = new Server(warConfiguration.port); + + final WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setWar(warPath.toUri().toASCIIString()); + + server.setHandler(webapp); - webapp.runner.launch.Main.main(warRunnerArgs); + server.start(); + server.join(); } } diff --git a/runners/3.1/src/main/java/com/earldouglas/WebappComponentsRunner.java b/runners/3.1/src/main/java/com/earldouglas/WebappComponentsRunner.java index dbd59381..519d0007 100644 --- a/runners/3.1/src/main/java/com/earldouglas/WebappComponentsRunner.java +++ b/runners/3.1/src/main/java/com/earldouglas/WebappComponentsRunner.java @@ -1,48 +1,10 @@ package com.earldouglas; -import java.io.File; import java.io.IOException; -import java.nio.file.FileAlreadyExistsException; -import java.nio.file.FileSystems; -import java.nio.file.Files; -import java.nio.file.Path; -import org.apache.catalina.Context; -import org.apache.catalina.LifecycleException; -import org.apache.catalina.WebResourceRoot; -import org.apache.catalina.connector.Connector; -import org.apache.catalina.startup.Tomcat; -import org.apache.catalina.webresources.DirResourceSet; -import org.apache.catalina.webresources.FileResourceSet; -import org.apache.catalina.webresources.StandardRoot; -/** - * Launches a webapp composed of in-place resources, classes, and libraries. - * - *

emptyWebappDir and emptyClassesDir need to point to one or two empty directories. They're not - * used to serve any content, but they are required by Tomcat's internals. - * - *

To use a root context path (i.e. /), set contextPath to the empty string for some reason. - */ +/** Launches a webapp composed of in-place resources, classes, and libraries. */ public class WebappComponentsRunner { - private static File mkdir(final File file) throws IOException { - if (file.exists()) { - if (!file.isDirectory()) { - throw new FileAlreadyExistsException(file.getPath()); - } else { - return file; - } - } else { - final Path path = FileSystems.getDefault().getPath(file.getPath()); - try { - Files.createDirectory(path); - return file; - } catch (FileAlreadyExistsException e) { - return file; - } - } - } - /** * Load configuration from the file in the first argument, and use it to start a new * WebappComponentsRunner. @@ -51,105 +13,6 @@ private static File mkdir(final File file) throws IOException { * @throws IOException if something goes wrong */ public static void main(final String[] args) throws IOException { - - final WebappComponentsConfiguration webappComponentsConfiguration = - WebappComponentsConfiguration.load(args[0]); - - final WebappComponentsRunner webappComponentsRunner = - new WebappComponentsRunner(webappComponentsConfiguration); - - webappComponentsRunner.start.run(); - webappComponentsRunner.join.run(); - } - - /** A handle for starting the instance's server. */ - public final Runnable start; - - /** A handle for joining the instance's server. */ - public final Runnable join; - - /** A handle for stopping the instance's server. */ - public final Runnable stop; - - /** - * Construct a new WebappComponentsRunner using the given configuration. - * - * @param configuration the configuration to run - * @throws IOException if something goes wrong - */ - public WebappComponentsRunner(final WebappComponentsConfiguration configuration) - throws IOException { - - mkdir(configuration.emptyWebappDir); - mkdir(configuration.emptyClassesDir); - - final Tomcat tomcat = new Tomcat(); - tomcat.setHostname(configuration.hostname); - - final Connector connector = new Connector(); - connector.setPort(configuration.port); - tomcat.setConnector(connector); - - final Context context = - tomcat.addWebapp(configuration.contextPath, configuration.emptyWebappDir.getAbsolutePath()); - - final WebResourceRoot webResourceRoot = new StandardRoot(context); - - webResourceRoot.addJarResources( - new DirResourceSet( - webResourceRoot, - "/WEB-INF/classes", - configuration.emptyClassesDir.getAbsolutePath(), - "/")); - - configuration.resourceMap.forEach( - (path, file) -> { - if (file.exists() && file.isFile()) { - webResourceRoot.addJarResources( - new FileResourceSet(webResourceRoot, "/" + path, file.getAbsolutePath(), "/")); - } - }); - - context.setResources(webResourceRoot); - - start = - new Runnable() { - @Override - public void run() { - try { - tomcat.start(); - } catch (final LifecycleException e) { - throw new RuntimeException(e); - } - } - }; - - join = - new Runnable() { - @Override - public void run() { - tomcat.getServer().await(); - } - }; - - stop = - new Runnable() { - @Override - public void run() { - try { - - connector.stop(); - context.stop(); - tomcat.stop(); - - connector.destroy(); - context.destroy(); - tomcat.destroy(); - - } catch (final LifecycleException e) { - throw new RuntimeException(e); - } - } - }; + throw new RuntimeException("unsupported"); } } diff --git a/runners/3.1/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml b/runners/3.1/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..b67be7a2 --- /dev/null +++ b/runners/3.1/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + Hello, Servlet 3.1 + + + HelloServlet + com.earldouglas.HelloServlet + + + + HelloServlet + /hello + + + + default + / + + + diff --git a/runners/3.1/src/test/java/com/earldouglas/HelloServlet.java b/runners/3.1/src/test/java/com/earldouglas/HelloServlet.java new file mode 100644 index 00000000..9799d9f2 --- /dev/null +++ b/runners/3.1/src/test/java/com/earldouglas/HelloServlet.java @@ -0,0 +1,21 @@ +package com.earldouglas; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + + @Override + protected void doGet( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + response.setContentType("text/plain"); + response.getWriter().println("Hello, world!"); + } +} diff --git a/runners/3.1/src/test/scala/com/earldouglas/WarRunnerTest.scala b/runners/3.1/src/test/scala/com/earldouglas/WarRunnerTest.scala index 955f1e4b..02ef8ea3 100644 --- a/runners/3.1/src/test/scala/com/earldouglas/WarRunnerTest.scala +++ b/runners/3.1/src/test/scala/com/earldouglas/WarRunnerTest.scala @@ -11,6 +11,8 @@ class WarRunnerTest override def beforeAll(): Unit = { + new com.earldouglas.HelloServlet() + val thread: Thread = new Thread { override def run(): Unit = { @@ -44,7 +46,7 @@ class WarRunnerTest awaitOpen(8988) } - test("/foo.html") { + test("GET /foo.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -71,7 +73,7 @@ class WarRunnerTest ) shouldBe expected } - test("/bar.html") { + test("GET /bar.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -98,7 +100,7 @@ class WarRunnerTest ) shouldBe expected } - test("/baz/raz.css") { + test("GET /baz/raz.css") { val expected: HttpClient.Response = HttpClient.Response( @@ -124,4 +126,37 @@ class WarRunnerTest } ) shouldBe expected } + + test("GET /hello") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/plain" + ), + body = """|Hello, world! + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/hello", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = + obtained + .headers + .filter { case (k, _) => + k == "Content-Type" + } + .map { case (k, v) => + (k, v.replaceAll(";charset=.*", "")) + } + ) shouldBe expected + } } diff --git a/runners/4.0/src/main/java/com/earldouglas/WarRunner.java b/runners/4.0/src/main/java/com/earldouglas/WarRunner.java index a9b9f444..61565245 100644 --- a/runners/4.0/src/main/java/com/earldouglas/WarRunner.java +++ b/runners/4.0/src/main/java/com/earldouglas/WarRunner.java @@ -1,5 +1,12 @@ package com.earldouglas; +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.server.Server; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + public class WarRunner { /** @@ -12,11 +19,21 @@ public static void main(final String[] args) throws Exception { final WarConfiguration warConfiguration = WarConfiguration.load(args[0]); - final String[] warRunnerArgs = - new String[] { - "--port", Integer.toString(warConfiguration.port), warConfiguration.warFile.getPath(), - }; + final Path warPath = + Paths + .get(warConfiguration.warFile.getPath()) + .toAbsolutePath() + .normalize(); + + final Server server = new Server(warConfiguration.port); + + final WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setWar(warPath.toUri().toASCIIString()); + + server.setHandler(webapp); - webapp.runner.launch.Main.main(warRunnerArgs); + server.start(); + server.join(); } } diff --git a/runners/4.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml b/runners/4.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..b59892b6 --- /dev/null +++ b/runners/4.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + Hello, Servlet 4.0 + + + HelloServlet + com.earldouglas.HelloServlet + + + + HelloServlet + /hello + + + + default + / + + + diff --git a/runners/4.0/src/test/java/com/earldouglas/HelloServlet.java b/runners/4.0/src/test/java/com/earldouglas/HelloServlet.java new file mode 100644 index 00000000..9799d9f2 --- /dev/null +++ b/runners/4.0/src/test/java/com/earldouglas/HelloServlet.java @@ -0,0 +1,21 @@ +package com.earldouglas; + +import javax.servlet.ServletConfig; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + + @Override + protected void doGet( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + response.setContentType("text/plain"); + response.getWriter().println("Hello, world!"); + } +} diff --git a/runners/4.0/src/test/scala/com/earldouglas/WarRunnerTest.scala b/runners/4.0/src/test/scala/com/earldouglas/WarRunnerTest.scala index 955f1e4b..02ef8ea3 100644 --- a/runners/4.0/src/test/scala/com/earldouglas/WarRunnerTest.scala +++ b/runners/4.0/src/test/scala/com/earldouglas/WarRunnerTest.scala @@ -11,6 +11,8 @@ class WarRunnerTest override def beforeAll(): Unit = { + new com.earldouglas.HelloServlet() + val thread: Thread = new Thread { override def run(): Unit = { @@ -44,7 +46,7 @@ class WarRunnerTest awaitOpen(8988) } - test("/foo.html") { + test("GET /foo.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -71,7 +73,7 @@ class WarRunnerTest ) shouldBe expected } - test("/bar.html") { + test("GET /bar.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -98,7 +100,7 @@ class WarRunnerTest ) shouldBe expected } - test("/baz/raz.css") { + test("GET /baz/raz.css") { val expected: HttpClient.Response = HttpClient.Response( @@ -124,4 +126,37 @@ class WarRunnerTest } ) shouldBe expected } + + test("GET /hello") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/plain" + ), + body = """|Hello, world! + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/hello", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = + obtained + .headers + .filter { case (k, _) => + k == "Content-Type" + } + .map { case (k, v) => + (k, v.replaceAll(";charset=.*", "")) + } + ) shouldBe expected + } } diff --git a/runners/5.0/src/main/java/com/earldouglas/WarConfiguration.java b/runners/5.0/src/main/java/com/earldouglas/WarConfiguration.java new file mode 100644 index 00000000..974c12e7 --- /dev/null +++ b/runners/5.0/src/main/java/com/earldouglas/WarConfiguration.java @@ -0,0 +1,71 @@ +package com.earldouglas; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class WarConfiguration { + + /** The port to use for the server, e.g. 8080 */ + public final int port; + + /** The .war file to serve. */ + public final File warFile; + + /** + * Read configuration from a file at the specified location. + * + * @param configurationFilename the configuration filename to load + * @throws IOException if something goes wrong + * @return WarConfiguration a loaded configuration + */ + public static WarConfiguration load(final String configurationFilename) throws IOException { + return WarConfiguration.load(new File(configurationFilename)); + } + + /** + * Read configuration from a file at the specified location. + * + *

The format of the file is a Properties file with the following fields: + * + *

    + *
  • port + *
  • warFile + *
+ * + *

Example: + * + *

+   * port=8989
+   * warFile=path/to/warfile.war
+   * 
+ * + * @param configurationFile the configuration file to load + * @throws IOException if something goes wrong + * @return WarConfiguration a loaded configuration + */ + public static WarConfiguration load(final File configurationFile) throws IOException { + + final InputStream inputStream = new FileInputStream(configurationFile); + + final Properties properties = new Properties(); + properties.load(inputStream); + + return new WarConfiguration( + Integer.parseInt(properties.getProperty("port")), + new File(properties.getProperty("warFile"))); + } + + /** + * Construct a new configuration from the given parameters. + * + * @param port the port to use for the server, e.g. 8080 + * @param warFile the .war file to serve + */ + public WarConfiguration(final int port, final File warFile) { + this.port = port; + this.warFile = warFile; + } +} diff --git a/runners/5.0/src/main/java/com/earldouglas/WarRunner.java b/runners/5.0/src/main/java/com/earldouglas/WarRunner.java new file mode 100644 index 00000000..61565245 --- /dev/null +++ b/runners/5.0/src/main/java/com/earldouglas/WarRunner.java @@ -0,0 +1,39 @@ +package com.earldouglas; + +import org.eclipse.jetty.webapp.WebAppContext; +import org.eclipse.jetty.server.Server; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class WarRunner { + + /** + * Load configuration from the file in the first argument, and use it to start a new WarRunner. + * + * @param args the configuration filename to load and run + * @throws Exception if something goes wrong + */ + public static void main(final String[] args) throws Exception { + + final WarConfiguration warConfiguration = WarConfiguration.load(args[0]); + + final Path warPath = + Paths + .get(warConfiguration.warFile.getPath()) + .toAbsolutePath() + .normalize(); + + final Server server = new Server(warConfiguration.port); + + final WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setWar(warPath.toUri().toASCIIString()); + + server.setHandler(webapp); + + server.start(); + server.join(); + } +} diff --git a/runners/5.0/src/main/java/com/earldouglas/WebappComponentsConfiguration.java b/runners/5.0/src/main/java/com/earldouglas/WebappComponentsConfiguration.java new file mode 100644 index 00000000..df85305b --- /dev/null +++ b/runners/5.0/src/main/java/com/earldouglas/WebappComponentsConfiguration.java @@ -0,0 +1,154 @@ +package com.earldouglas; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** Specifies server settings and components locations for running a webapp in-place. */ +public class WebappComponentsConfiguration { + + /** The hostname to use for the server, e.g. "localhost" */ + public final String hostname; + + /** The port to use for the server, e.g. 8080 */ + public final int port; + + /** + * The context path to use for the webapp. + * + *

For the root context path, use the empty string "". + */ + public final String contextPath; + + /** + * An empty directory that Tomcat requires to run. Represents the resources directory, but it can + * be any empty directory. + */ + public final File emptyWebappDir; + + /** + * An empty directory that Tomcat requires to run. Represents the WEB-INF/classes directory, but + * it can be any empty directory. + */ + public final File emptyClassesDir; + + /** + * The map of resources to serve. + * + *

The mapping is from source to destination, where: + * + *

    + *
  • source is the relative path within the webapp (e.g. index.html, WEB-INF/web.xml) + *
  • destination is the file on disk to serve + *
+ */ + public final Map resourceMap; + + /** + * Read configuration from a file at the specified location. + * + * @param configurationFilename the configuration filename to load + * @throws IOException if something goes wrong + * @return WebappComponentsConfiguration a loaded configuration + */ + public static WebappComponentsConfiguration load(final String configurationFilename) + throws IOException { + return WebappComponentsConfiguration.load(new File(configurationFilename)); + } + + private static Map parseResourceMap(final String raw) throws IOException { + + final Map resourceMap = new HashMap(); + + final String[] rows = raw.split(","); + + for (int rowIndex = 0; rowIndex < rows.length; rowIndex++) { + final String[] columns = rows[rowIndex].split("->"); + resourceMap.put(columns[0], new File(columns[1])); + } + + return resourceMap; + } + + /** + * Read configuration from a file at the specified location. + * + *

The format of the file is a Properties file with the following fields: + * + *

    + *
  • hostname + *
  • port + *
  • contextPath + *
  • emptyWebappDir + *
  • emptyClassesDir + *
  • resourceMap + *
+ * + * The resourceMap field is a list, concatenated by commas, of source/destination pairs, delimited + * by ->. + * + *

Example: + * + *

+   * hostname=localhost
+   * port=8989
+   * contextPath=
+   * emptyWebappDir=target/empty
+   * emptyClassesDir=target/empty
+   * resourceMap=bar.html->src/test/fakeproject/src/main/webapp/bar.html,\
+   *             foo.html->src/test/fakeproject/src/main/webapp/foo.html,\
+   *             baz/raz.css->src/test/fakeproject/src/main/webapp/baz/raz.css
+   * 
+ * + * @param configurationFile the configuration file to load + * @throws IOException if something goes wrong + * @return WebappComponentsConfiguration a loaded configuration + */ + public static WebappComponentsConfiguration load(final File configurationFile) + throws IOException { + + final InputStream inputStream = new FileInputStream(configurationFile); + + final Properties properties = new Properties(); + properties.load(inputStream); + + final Map resourceMap = parseResourceMap(properties.getProperty("resourceMap")); + + return new WebappComponentsConfiguration( + properties.getProperty("hostname"), + Integer.parseInt(properties.getProperty("port")), + properties.getProperty("contextPath"), + new File(properties.getProperty("emptyWebappDir")), + new File(properties.getProperty("emptyClassesDir")), + resourceMap); + } + + /** + * Construct a new configuration from the given parameters. + * + * @param hostname the hostname to use for the server, e.g. "localhost" + * @param port the port to use for the server, e.g. 8080 + * @param contextPath the context path to use for the webapp, e.g. "" + * @param emptyWebappDir an empty directory that Tomcat requires to run + * @param emptyClassesDir an empty directory that Tomcat requires to run + * @param resourceMap the map of resources to serve + */ + public WebappComponentsConfiguration( + final String hostname, + final int port, + final String contextPath, + final File emptyWebappDir, + final File emptyClassesDir, + final Map resourceMap) { + this.hostname = hostname; + this.port = port; + this.contextPath = contextPath; + this.emptyWebappDir = emptyWebappDir; + this.emptyClassesDir = emptyClassesDir; + this.resourceMap = resourceMap; + } +} diff --git a/runners/5.0/src/main/java/com/earldouglas/WebappComponentsRunner.java b/runners/5.0/src/main/java/com/earldouglas/WebappComponentsRunner.java new file mode 100644 index 00000000..519d0007 --- /dev/null +++ b/runners/5.0/src/main/java/com/earldouglas/WebappComponentsRunner.java @@ -0,0 +1,18 @@ +package com.earldouglas; + +import java.io.IOException; + +/** Launches a webapp composed of in-place resources, classes, and libraries. */ +public class WebappComponentsRunner { + + /** + * Load configuration from the file in the first argument, and use it to start a new + * WebappComponentsRunner. + * + * @param args the configuration filename to load and run + * @throws IOException if something goes wrong + */ + public static void main(final String[] args) throws IOException { + throw new RuntimeException("unsupported"); + } +} diff --git a/runners/5.0/src/test/fakeproject/classes/bar.class b/runners/5.0/src/test/fakeproject/classes/bar.class new file mode 100644 index 00000000..e69de29b diff --git a/runners/5.0/src/test/fakeproject/classes/foo.class b/runners/5.0/src/test/fakeproject/classes/foo.class new file mode 100644 index 00000000..e69de29b diff --git a/runners/5.0/src/test/fakeproject/lib/baz.jar b/runners/5.0/src/test/fakeproject/lib/baz.jar new file mode 100644 index 00000000..e69de29b diff --git a/runners/5.0/src/test/fakeproject/lib/raz.jar b/runners/5.0/src/test/fakeproject/lib/raz.jar new file mode 100644 index 00000000..e69de29b diff --git a/runners/5.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml b/runners/5.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..c2558bde --- /dev/null +++ b/runners/5.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + Hello, Servlet 5.0 + + + HelloServlet + com.earldouglas.HelloServlet + + + + HelloServlet + /hello + + + + default + / + + + diff --git a/runners/5.0/src/test/fakeproject/src/main/webapp/bar.html b/runners/5.0/src/test/fakeproject/src/main/webapp/bar.html new file mode 100644 index 00000000..b685fe79 --- /dev/null +++ b/runners/5.0/src/test/fakeproject/src/main/webapp/bar.html @@ -0,0 +1 @@ +bar diff --git a/runners/5.0/src/test/fakeproject/src/main/webapp/baz/raz.css b/runners/5.0/src/test/fakeproject/src/main/webapp/baz/raz.css new file mode 100644 index 00000000..9ae483b4 --- /dev/null +++ b/runners/5.0/src/test/fakeproject/src/main/webapp/baz/raz.css @@ -0,0 +1 @@ +div.raz { font-weight: bold; } diff --git a/runners/5.0/src/test/fakeproject/src/main/webapp/foo.html b/runners/5.0/src/test/fakeproject/src/main/webapp/foo.html new file mode 100644 index 00000000..f2a826f7 --- /dev/null +++ b/runners/5.0/src/test/fakeproject/src/main/webapp/foo.html @@ -0,0 +1 @@ +foo diff --git a/runners/5.0/src/test/java/com/earldouglas/HelloServlet.java b/runners/5.0/src/test/java/com/earldouglas/HelloServlet.java new file mode 100644 index 00000000..cc37c14a --- /dev/null +++ b/runners/5.0/src/test/java/com/earldouglas/HelloServlet.java @@ -0,0 +1,21 @@ +package com.earldouglas; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + + @Override + protected void doGet( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + response.setContentType("text/plain"); + response.getWriter().println("Hello, world!"); + } +} diff --git a/runners/5.0/src/test/resources/war.properties b/runners/5.0/src/test/resources/war.properties new file mode 100644 index 00000000..a9c2d083 --- /dev/null +++ b/runners/5.0/src/test/resources/war.properties @@ -0,0 +1,2 @@ +port=8988 +warFile=src/test/fakeproject/src/main/webapp diff --git a/runners/5.0/src/test/resources/webapp-components.properties b/runners/5.0/src/test/resources/webapp-components.properties new file mode 100644 index 00000000..14c2e0d7 --- /dev/null +++ b/runners/5.0/src/test/resources/webapp-components.properties @@ -0,0 +1,8 @@ +hostname=localhost +port=8989 +contextPath= +emptyWebappDir=target/empty +emptyClassesDir=target/empty +resourceMap=bar.html->src/test/fakeproject/src/main/webapp/bar.html,\ + foo.html->src/test/fakeproject/src/main/webapp/foo.html,\ + baz/raz.css->src/test/fakeproject/src/main/webapp/baz/raz.css diff --git a/runners/5.0/src/test/scala/com/earldouglas/HttpClient.scala b/runners/5.0/src/test/scala/com/earldouglas/HttpClient.scala new file mode 100644 index 00000000..61789759 --- /dev/null +++ b/runners/5.0/src/test/scala/com/earldouglas/HttpClient.scala @@ -0,0 +1,64 @@ +package com.earldouglas + +import java.net.HttpURLConnection +import java.net.URI +import scala.collection.JavaConverters._ +import scala.io.Source + +object HttpClient { + + case class Response( + status: Int, + headers: Map[String, String], + body: String + ) + + def request( + method: String, + url: String, + headers: Map[String, String], + body: Option[String] + ): Response = { + + val c = + new URI(url) + .toURL() + .openConnection() + .asInstanceOf[HttpURLConnection] + + c.setInstanceFollowRedirects(false) + c.setRequestMethod(method) + c.setDoInput(true) + c.setDoOutput(body.isDefined) + + headers foreach { case (k, v) => + c.setRequestProperty(k, v) + } + + body foreach { b => + c.getOutputStream.write(b.getBytes("UTF-8")) + } + + val response = + Response( + status = c.getResponseCode(), + headers = c + .getHeaderFields() + .asScala + .filter({ case (k, _) => k != null }) + .map({ case (k, v) => (k, v.asScala.mkString(",")) }) + .toMap - "Date" - "Content-Length" - "Server", + body = Source.fromInputStream { + if (c.getResponseCode() < 400) { + c.getInputStream + } else { + c.getErrorStream + } + }.mkString + ) + + c.disconnect() + + response + } +} diff --git a/runners/5.0/src/test/scala/com/earldouglas/WarConfigurationTest.scala b/runners/5.0/src/test/scala/com/earldouglas/WarConfigurationTest.scala new file mode 100644 index 00000000..1b04488f --- /dev/null +++ b/runners/5.0/src/test/scala/com/earldouglas/WarConfigurationTest.scala @@ -0,0 +1,24 @@ +package com.earldouglas + +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.io.File + +class WarConfigurationTest + extends AnyFunSuite + with Matchers + with BeforeAndAfterAll { + + test("load") { + + val configuration: WarConfiguration = + WarConfiguration.load("src/test/resources/war.properties") + + configuration.port shouldBe 8988 + + configuration.warFile shouldBe + new File("src/test/fakeproject/src/main/webapp") + } +} diff --git a/runners/5.0/src/test/scala/com/earldouglas/WarRunnerTest.scala b/runners/5.0/src/test/scala/com/earldouglas/WarRunnerTest.scala new file mode 100644 index 00000000..02ef8ea3 --- /dev/null +++ b/runners/5.0/src/test/scala/com/earldouglas/WarRunnerTest.scala @@ -0,0 +1,162 @@ +package com.earldouglas + +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class WarRunnerTest + extends AnyFunSuite + with Matchers + with BeforeAndAfterAll { + + override def beforeAll(): Unit = { + + new com.earldouglas.HelloServlet() + + val thread: Thread = + new Thread { + override def run(): Unit = { + WarRunner.main(Array("src/test/resources/war.properties")) + } + } + thread.start() + + def isOpen(port: Int): Boolean = + try { + import java.net.Socket + import java.net.InetSocketAddress + val socket: Socket = new Socket() + socket.connect(new InetSocketAddress("localhost", port)) + socket.close() + true + } catch { + case e: Exception => false + } + + def awaitOpen(port: Int, retries: Int = 40): Unit = + if (!isOpen(port)) { + if (retries > 0) { + Thread.sleep(250) + awaitOpen(port, retries - 1) + } else { + throw new Exception(s"expected port $port to be open") + } + } + + awaitOpen(8988) + } + + test("GET /foo.html") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/html" + ), + body = """|foo + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/foo.html", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = obtained.headers.filter { case (k, _) => + k == "Content-Type" + } + ) shouldBe expected + } + + test("GET /bar.html") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/html" + ), + body = """|bar + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/bar.html", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = obtained.headers.filter { case (k, _) => + k == "Content-Type" + } + ) shouldBe expected + } + + test("GET /baz/raz.css") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/css" + ), + body = """|div.raz { font-weight: bold; } + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/baz/raz.css", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = obtained.headers.filter { case (k, _) => + k == "Content-Type" + } + ) shouldBe expected + } + + test("GET /hello") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/plain" + ), + body = """|Hello, world! + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/hello", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = + obtained + .headers + .filter { case (k, _) => + k == "Content-Type" + } + .map { case (k, v) => + (k, v.replaceAll(";charset=.*", "")) + } + ) shouldBe expected + } +} diff --git a/runners/3.1/src/test/scala/com/earldouglas/WebappComponentsConfigurationTest.scala b/runners/5.0/src/test/scala/com/earldouglas/WebappComponentsConfigurationTest.scala similarity index 100% rename from runners/3.1/src/test/scala/com/earldouglas/WebappComponentsConfigurationTest.scala rename to runners/5.0/src/test/scala/com/earldouglas/WebappComponentsConfigurationTest.scala diff --git a/runners/3.1/src/test/scala/com/earldouglas/WebappComponentsRunnerTest.scala b/runners/5.0/src/test/scala/com/earldouglas/WebappComponentsRunnerTest.scala similarity index 100% rename from runners/3.1/src/test/scala/com/earldouglas/WebappComponentsRunnerTest.scala rename to runners/5.0/src/test/scala/com/earldouglas/WebappComponentsRunnerTest.scala diff --git a/runners/6.0/src/main/java/com/earldouglas/WarRunner.java b/runners/6.0/src/main/java/com/earldouglas/WarRunner.java index a9b9f444..81d4f7f0 100644 --- a/runners/6.0/src/main/java/com/earldouglas/WarRunner.java +++ b/runners/6.0/src/main/java/com/earldouglas/WarRunner.java @@ -1,5 +1,12 @@ package com.earldouglas; +import org.eclipse.jetty.ee10.webapp.WebAppContext; +import org.eclipse.jetty.server.Server; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + public class WarRunner { /** @@ -12,11 +19,21 @@ public static void main(final String[] args) throws Exception { final WarConfiguration warConfiguration = WarConfiguration.load(args[0]); - final String[] warRunnerArgs = - new String[] { - "--port", Integer.toString(warConfiguration.port), warConfiguration.warFile.getPath(), - }; + final Path warPath = + Paths + .get(warConfiguration.warFile.getPath()) + .toAbsolutePath() + .normalize(); + + final Server server = new Server(warConfiguration.port); + + final WebAppContext webapp = new WebAppContext(); + webapp.setContextPath("/"); + webapp.setWar(warPath.toUri().toASCIIString()); + + server.setHandler(webapp); - webapp.runner.launch.Main.main(warRunnerArgs); + server.start(); + server.join(); } } diff --git a/runners/6.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml b/runners/6.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..bfb9a991 --- /dev/null +++ b/runners/6.0/src/test/fakeproject/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,23 @@ + + + Hello, Servlet 6.0 + + + HelloServlet + com.earldouglas.HelloServlet + + + + HelloServlet + /hello + + + + default + / + + + diff --git a/runners/6.0/src/test/java/com/earldouglas/HelloServlet.java b/runners/6.0/src/test/java/com/earldouglas/HelloServlet.java new file mode 100644 index 00000000..cc37c14a --- /dev/null +++ b/runners/6.0/src/test/java/com/earldouglas/HelloServlet.java @@ -0,0 +1,21 @@ +package com.earldouglas; + +import jakarta.servlet.ServletConfig; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +public class HelloServlet extends HttpServlet { + + @Override + protected void doGet( + final HttpServletRequest request, + final HttpServletResponse response + ) throws IOException { + response.setContentType("text/plain"); + response.getWriter().println("Hello, world!"); + } +} diff --git a/runners/6.0/src/test/scala/com/earldouglas/WarRunnerTest.scala b/runners/6.0/src/test/scala/com/earldouglas/WarRunnerTest.scala index 955f1e4b..02ef8ea3 100644 --- a/runners/6.0/src/test/scala/com/earldouglas/WarRunnerTest.scala +++ b/runners/6.0/src/test/scala/com/earldouglas/WarRunnerTest.scala @@ -11,6 +11,8 @@ class WarRunnerTest override def beforeAll(): Unit = { + new com.earldouglas.HelloServlet() + val thread: Thread = new Thread { override def run(): Unit = { @@ -44,7 +46,7 @@ class WarRunnerTest awaitOpen(8988) } - test("/foo.html") { + test("GET /foo.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -71,7 +73,7 @@ class WarRunnerTest ) shouldBe expected } - test("/bar.html") { + test("GET /bar.html") { val expected: HttpClient.Response = HttpClient.Response( @@ -98,7 +100,7 @@ class WarRunnerTest ) shouldBe expected } - test("/baz/raz.css") { + test("GET /baz/raz.css") { val expected: HttpClient.Response = HttpClient.Response( @@ -124,4 +126,37 @@ class WarRunnerTest } ) shouldBe expected } + + test("GET /hello") { + + val expected: HttpClient.Response = + HttpClient.Response( + status = 200, + headers = Map( + "Content-Type" -> "text/plain" + ), + body = """|Hello, world! + |""".stripMargin + ) + + val obtained: HttpClient.Response = + HttpClient.request( + method = "GET", + url = "http://localhost:8988/hello", + headers = Map.empty, + body = None + ) + + obtained.copy( + headers = + obtained + .headers + .filter { case (k, _) => + k == "Content-Type" + } + .map { case (k, v) => + (k, v.replaceAll(";charset=.*", "")) + } + ) shouldBe expected + } } diff --git a/shell.nix b/shell.nix index 3d4d62d0..5b81715f 100644 --- a/shell.nix +++ b/shell.nix @@ -1,6 +1,6 @@ { pkgs ? import {} }: let - jdk = pkgs.jdk11; + jdk = pkgs.jdk17; in pkgs.mkShell { nativeBuildInputs = [ diff --git a/src/main/scala/com/earldouglas/sbt/war/WebappComponentsPlugin.scala b/src/main/scala/com/earldouglas/sbt/war/WebappComponentsPlugin.scala index e828f903..4518c1f7 100644 --- a/src/main/scala/com/earldouglas/sbt/war/WebappComponentsPlugin.scala +++ b/src/main/scala/com/earldouglas/sbt/war/WebappComponentsPlugin.scala @@ -64,6 +64,8 @@ object WebappComponentsPlugin extends AutoPlugin { "javax.servlet" % "javax.servlet-api" % "3.1.0" case "4.0" => "jakarta.servlet" % "jakarta.servlet-api" % "4.0.4" + case "5.0" => + "jakarta.servlet" % "jakarta.servlet-api" % "5.0.0" case "6.0" => "jakarta.servlet" % "jakarta.servlet-api" % "6.0.0" }