diff --git a/.github/workflows/ci-jetty-testing.yml b/.github/workflows/ci-jetty-testing.yml new file mode 100644 index 00000000..2badbd67 --- /dev/null +++ b/.github/workflows/ci-jetty-testing.yml @@ -0,0 +1,73 @@ +name: "Continuous Integration (Testing with Jetty)" + +on: + pull_request: + branches: ['**', '!update/**', '!pr/**'] + push: + branches: ['**', '!update/**', '!pr/**'] + tags: [v*] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Test + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04] + scala: [2.12, 2.13, 3] + java: [temurin@17] + project: [servletTesting] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@17) + id: setup-java-temurin-17 + if: matrix.java == 'temurin@17' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' + run: sbt +update + + - name: Check headers and formatting + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck + + - name: Test + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test + + - name: Check binary compatibility + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues + + - name: Generate API documentation + if: matrix.java == 'temurin@17' && matrix.os == 'ubuntu-22.04' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc + + - name: Check scalafix lints + if: matrix.java == 'temurin@17' && !startsWith(matrix.scala, '3') + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check' + + - name: Check unused compile dependencies + if: matrix.java == 'temurin@17' + run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' unusedCompileDependenciesTest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1b334a5..5d4bbc34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,28 +30,45 @@ jobs: matrix: os: [ubuntu-22.04] scala: [2.12, 2.13, 3] - java: [temurin@11, temurin@17] + java: [temurin@8, temurin@11, temurin@17] project: [rootJVM] exclude: + - scala: 2.12 + java: temurin@11 - scala: 2.12 java: temurin@17 + - scala: 3 + java: temurin@11 - scala: 3 java: temurin@17 runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup sbt uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 8 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -64,7 +81,7 @@ jobs: - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -78,26 +95,26 @@ jobs: run: sbt githubWorkflowCheck - name: Check headers and formatting - if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-22.04' + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' headerCheckAll scalafmtCheckAll 'project /' scalafmtSbtCheck - name: Test run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' test - name: Check binary compatibility - if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-22.04' + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' mimaReportBinaryIssues - name: Generate API documentation - if: matrix.java == 'temurin@11' && matrix.os == 'ubuntu-22.04' + if: matrix.java == 'temurin@8' && matrix.os == 'ubuntu-22.04' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' doc - name: Check scalafix lints - if: matrix.java == 'temurin@11' && !startsWith(matrix.scala, '3') + if: matrix.java == 'temurin@8' && !startsWith(matrix.scala, '3') run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' 'scalafixAll --check' - name: Check unused compile dependencies - if: matrix.java == 'temurin@11' + if: matrix.java == 'temurin@8' run: sbt 'project ${{ matrix.project }}' '++ ${{ matrix.scala }}' unusedCompileDependenciesTest - name: Make target directories @@ -110,7 +127,7 @@ jobs: - name: Upload target directories if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main') - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: target-${{ matrix.os }}-${{ matrix.java }}-${{ matrix.scala }}-${{ matrix.project }} path: targets.tar @@ -122,21 +139,34 @@ jobs: strategy: matrix: os: [ubuntu-22.04] - java: [temurin@11] + java: [temurin@8] runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup sbt uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 8 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -149,7 +179,7 @@ jobs: - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -160,7 +190,7 @@ jobs: run: sbt +update - name: Download target directories (2.12, rootJVM) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM @@ -170,7 +200,7 @@ jobs: rm targets.tar - name: Download target directories (2.13, rootJVM) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-2.13-rootJVM @@ -180,7 +210,7 @@ jobs: rm targets.tar - name: Download target directories (3, rootJVM) - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v6 with: name: target-${{ matrix.os }}-${{ matrix.java }}-3-rootJVM @@ -219,21 +249,34 @@ jobs: strategy: matrix: os: [ubuntu-22.04] - java: [temurin@11] + java: [temurin@8] runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup sbt uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 8 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -246,7 +289,7 @@ jobs: - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 @@ -259,7 +302,7 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: http4s-servlet-examples_2.12 http4s-servlet-examples_2.13 http4s-servlet-examples_3 rootjs_2.12 rootjs_2.13 rootjs_3 docs_2.12 docs_2.13 docs_3 rootjvm_2.12 rootjvm_2.13 rootjvm_3 rootnative_2.12 rootnative_2.13 rootnative_3 sbt-http4s-org-scalafix-internal_2.12 sbt-http4s-org-scalafix-internal_2.13 sbt-http4s-org-scalafix-internal_3 + modules-ignore: http4s-servlet-examples_2.12 http4s-servlet-examples_2.13 http4s-servlet-examples_3 http4s-servlet-testing_2.12 http4s-servlet-testing_2.13 http4s-servlet-testing_3 rootjs_2.12 rootjs_2.13 rootjs_3 docs_2.12 docs_2.13 docs_3 rootjvm_2.12 rootjvm_2.13 rootjvm_3 rootnative_2.12 rootnative_2.13 rootnative_3 sbt-http4s-org-scalafix-internal_2.12 sbt-http4s-org-scalafix-internal_2.13 sbt-http4s-org-scalafix-internal_3 configs-ignore: test scala-tool scala-doc-tool test-internal validate-steward: @@ -271,12 +314,12 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (fast) - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -296,17 +339,30 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup sbt uses: sbt/setup-sbt@v1 + - name: Setup Java (temurin@8) + id: setup-java-temurin-8 + if: matrix.java == 'temurin@8' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 8 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@8' && steps.setup-java-temurin-8.outputs.cache-hit == 'false' + run: sbt +update + - name: Setup Java (temurin@11) id: setup-java-temurin-11 if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -319,7 +375,7 @@ jobs: - name: Setup Java (temurin@17) id: setup-java-temurin-17 if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 diff --git a/.scalafmt.conf b/.scalafmt.conf index 5cc14cfe..a69696ff 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = 3.9.4 +version = 3.10.1 style = default diff --git a/README.md b/README.md index a69163de..f1cdc73d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ libraryDependencies ++= Seq( | http4s-servlet | http4s-core | servlet-api | Scala 2.12 | Scala 2.13 | Scala 3 | Status | |:---------------|:------------|:------------|------------|------------|---------|:----------| | 0.23.x | 0.23.x | 3.1 | ✅ | ✅ | ✅ | Stable | -| 0.24.x | 0.23.x | 4.0 | ✅ | ✅ | ✅ | Milestone | -| 0.25.x | 0.23.x | 5.0 | ✅ | ✅ | ✅ | Milestone | +| 0.24.x | 0.23.x | 4.0 | ✅ | ✅ | ✅ | RC | +| 0.25.x | 0.23.x | 5.0 | ✅ | ✅ | ✅ | RC | [http4s-jetty]: https://github.com/http4s/http4s-jetty/ [http4s-tomcat]: https://github.com/http4s/http4s-tomcat/ diff --git a/build.sbt b/build.sbt index 9174fe61..d59f1895 100644 --- a/build.sbt +++ b/build.sbt @@ -10,38 +10,64 @@ ThisBuild / developers := List( // publish website from this branch ThisBuild / tlSitePublishBranch := Some("main") -val Scala213 = "2.13.16" -ThisBuild / crossScalaVersions := Seq("2.12.20", Scala213, "3.3.5") +val Scala213 = "2.13.18" +ThisBuild / crossScalaVersions := Seq("2.12.20", Scala213, "3.3.7") ThisBuild / scalaVersion := Scala213 // the default Scala -// Jetty 10+, for testing, requires Java 11. -ThisBuild / githubWorkflowJavaVersions -= JavaSpec.temurin("8") +// Undertow 2 for testing, requires Java 8 or higher +//ThisBuild / githubWorkflowJavaVersions -= JavaSpec.temurin("8") ThisBuild / tlJdkRelease := Some(8) ThisBuild / startYear := Some(2013) lazy val root = tlCrossRootProject.aggregate(servlet, examples) -val asyncHttpClientVersion = "2.12.3" -val jettyVersion = "11.0.25" -val http4sVersion = "0.23.30" +val asyncHttpClientVersion = "2.12.4" +val jettyVersion = "12.0.30" +val http4sVersion = "0.23.31" val munitCatsEffectVersion = "2.1.0" val servletApiVersion = "5.0.0" +val undertowVersion = "2.3.20.Final" lazy val servlet = project .in(file("servlet")) .settings( name := "http4s-servlet", description := "Portable servlet implementation for http4s servers", + fork := true, + Test / javaOptions ++= Seq( + "-Dcats.effect.trackFiberContext=true", + "-Dcats.effect.tracing.mode=full", + "-Dcats.effect.tracing.buffer.size=1024", + ), libraryDependencies ++= Seq( + "org.typelevel" %% "cats-core" % "2.13.0", + "org.typelevel" %% "cats-effect" % "3.6.3", "jakarta.servlet" % "jakarta.servlet-api" % servletApiVersion % Provided, + "io.undertow" % "undertow-core" % undertowVersion % Test, + "io.undertow" % "undertow-servlet" % undertowVersion % Test, + "org.http4s" %% "http4s-dsl" % http4sVersion % Test, + "org.http4s" %% "http4s-server" % http4sVersion, + "org.typelevel" %% "munit-cats-effect" % munitCatsEffectVersion % Test, + "org.asynchttpclient" % "async-http-client" % asyncHttpClientVersion % Test, + ), + ) + +lazy val servletTesting = project + .in(file("servlet-testing")) + .enablePlugins(NoPublishPlugin) + .settings( + name := "http4s-servlet-testing", + description := "Portable servlet implementation for http4s servers", + Test / fork := true, + libraryDependencies ++= Seq( "org.eclipse.jetty" % "jetty-client" % jettyVersion % Test, "org.eclipse.jetty" % "jetty-server" % jettyVersion % Test, - "org.eclipse.jetty" % "jetty-servlet" % jettyVersion % Test, + "org.eclipse.jetty.ee8" % "jetty-ee8-servlet" % jettyVersion % Test, "org.http4s" %% "http4s-dsl" % http4sVersion % Test, - "org.http4s" %% "http4s-server" % http4sVersion, "org.typelevel" %% "munit-cats-effect" % munitCatsEffectVersion % Test, ), ) + .dependsOn(servlet % "compile->compile;test->test") lazy val examples = project .in(file("examples")) @@ -52,7 +78,7 @@ lazy val examples = project description := "Examples for http4s-servlet", startYear := Some(2013), fork := true, - Jetty / containerLibs := List("org.eclipse.jetty" % "jetty-runner" % jettyVersion), + Jetty / containerLibs := List("org.eclipse.jetty.ee8" % "jetty-ee8-runner" % jettyVersion), libraryDependencies ++= Seq( "jakarta.servlet" % "jakarta.servlet-api" % servletApiVersion % Provided ), diff --git a/flake.lock b/flake.lock index 87442fe2..e59d38a3 100644 --- a/flake.lock +++ b/flake.lock @@ -41,11 +41,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1744868846, - "narHash": "sha256-5RJTdUHDmj12Qsv7XOhuospjAjATNiTMElplWnJE9Hs=", + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", "owner": "nixos", "repo": "nixpkgs", - "rev": "ebe4301cbd8f81c4f8d3244b3632338bbeb6d49c", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", "type": "github" }, "original": { @@ -90,11 +90,11 @@ "nixpkgs": "nixpkgs" }, "locked": { - "lastModified": 1745237810, - "narHash": "sha256-UtRNP78v5Z0dnG65qmXUmkzaSVYtyFuJ4je4e86KpnE=", + "lastModified": 1759335335, + "narHash": "sha256-oIpIn9LvSfZMDcGvXj2WXG+voqtAmwArRyqEo+P3GCM=", "owner": "typelevel", "repo": "typelevel-nix", - "rev": "562496d5210995535d09198f4b611a943808c393", + "rev": "fe5825c746692338d0b19e98fb4da0c8fe7ca811", "type": "github" }, "original": { diff --git a/project/build.properties b/project/build.properties index cc68b53f..01a16ed1 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.11 +sbt.version=1.11.7 diff --git a/project/plugins.sbt b/project/plugins.sbt index be9c6e35..d30dc034 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,2 +1,2 @@ addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.2.5") -addSbtPlugin("org.http4s" % "sbt-http4s-org" % "0.18.0") +addSbtPlugin("org.http4s" % "sbt-http4s-org" % "2.0.3") diff --git a/servlet-testing/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala b/servlet-testing/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala new file mode 100644 index 00000000..92c56b4f --- /dev/null +++ b/servlet-testing/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala @@ -0,0 +1,278 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s +package servlet + +import cats.Monoid +import cats.effect.Deferred +import cats.effect.IO +import cats.effect.Resource +import cats.effect.std.Dispatcher +import cats.syntax.all._ +import fs2.Chunk +import fs2.Stream +import munit.CatsEffectSuite +import org.eclipse.jetty.client.AsyncRequestContent +import org.eclipse.jetty.client.BytesRequestContent +import org.eclipse.jetty.client.HttpClient +import org.eclipse.jetty.client.{Response => JResponse} +import org.eclipse.jetty.util.Callback +import org.http4s.dsl.io._ +import org.http4s.syntax.all._ + +import java.nio.ByteBuffer +import scala.concurrent.duration._ + +class AsyncHttp4sServletSuite extends CatsEffectSuite { + private val clientR = Resource.make(IO { + val client = new HttpClient() + client.start() + client + })(client => IO(client.stop())) + + private lazy val service = HttpRoutes + .of[IO] { + case GET -> Root / "simple" => + Ok("simple") + case req @ POST -> Root / "echo" => + Ok(req.body) + case GET -> Root / "shifted" => + // Wait for a bit to make sure we lose the race + (IO.sleep(50.millis) *> + Ok("shifted")).evalOn(munitExecutionContext) + case GET -> Root / "never" => + IO.never + } + .orNotFound + + private def servletServer(asyncTimeout: FiniteDuration = 10.seconds) = + ResourceFunFixture[Int]( + Dispatcher.parallel[IO].flatMap(d => TestEclipseServer(servlet(d, asyncTimeout))) + ) + + private def get(client: HttpClient, serverPort: Int, path: String): IO[String] = + IO.blocking( + client.GET(s"http://127.0.0.1:$serverPort/$path") + ).map(_.getContentAsString) + + servletServer().test("AsyncHttp4sServlet handle GET requests") { server => + clientR.use(get(_, server, "simple")).assertEquals("simple") + } + + // We should handle an empty body + servletServer().test("AsyncHttp4sServlet handle empty POST") { server => + clientR + .use { client => + IO.blocking( + client + .POST(s"http://127.0.0.1:$server/echo") + .send() + ).map(resp => Chunk.array(resp.getContent)) + } + .assertEquals(Chunk.empty) + } + + // We should handle a regular, big body + servletServer().test("AsyncHttp4sServlet handle multiple chunks upfront") { server => + val bytes = Stream.range(0, DefaultChunkSize * 2).map(_.toByte).to(Array) + clientR + .use { client => + IO.blocking( + client + .POST(s"http://127.0.0.1:$server/echo") + .body(new BytesRequestContent(bytes)) + .send() + ).map(resp => Chunk.array(resp.getContent)) + } + .assertEquals(Chunk.array(bytes)) + } + + // We should be able to wake up if we're initially blocked + servletServer().test("AsyncHttp4sServlet handle single-chunk, deferred POST") { server => + val bytes = Stream.range(0, DefaultChunkSize).map(_.toByte).to(Array) + clientR + .use { client => + for { + content <- IO(new AsyncRequestContent()) + bodyFiber <- IO + .async_[Chunk[Byte]] { cb => + var body = Chunk.empty[Byte] + client + .POST(s"http://127.0.0.1:$server/echo") + .body(content) + .send(new JResponse.Listener { + override def onContent(resp: JResponse, bb: ByteBuffer) = { + val buf = new Array[Byte](bb.remaining()) + bb.get(buf) + body ++= Chunk.array(buf) + } + override def onFailure(resp: JResponse, t: Throwable) = + cb(Left(t)) + override def onSuccess(resp: JResponse) = + cb(Right(body)) + }) + } + .start + _ <- IO(content.write(ByteBuffer.wrap(bytes), Callback.NOOP)) + _ <- IO(content.close()) + body <- bodyFiber.joinWithNever + } yield body + } + .assertEquals(Chunk.array(bytes)) + } + + // We should be able to wake up after being blocked + servletServer().test("AsyncHttp4sServlet handle two-chunk, deferred POST") { server => + // Show that we can read, be blocked, and read again + val bytes = Stream.range(0, DefaultChunkSize).map(_.toByte).to(Array) + Dispatcher + .parallel[IO] + .use { dispatcher => + clientR.use { client => + for { + content <- IO(new AsyncRequestContent()) + firstChunkReceived <- Deferred[IO, Unit] + bodyFiber <- IO + .async_[Chunk[Byte]] { cb => + var body = Chunk.empty[Byte] + client + .POST(s"http://127.0.0.1:$server/echo") + .body(content) + .send(new JResponse.Listener { + override def onContent(resp: JResponse, bb: ByteBuffer) = + dispatcher.unsafeRunSync(for { + _ <- firstChunkReceived.complete(()).attempt + buf <- IO(new Array[Byte](bb.remaining())) + _ <- IO(bb.get(buf)) + _ <- IO { body = body ++ Chunk.array(buf) } + } yield ()) + override def onFailure(resp: JResponse, t: Throwable) = + cb(Left(t)) + override def onSuccess(resp: JResponse) = + cb(Right(body)) + }) + } + .start + _ <- IO(content.write(ByteBuffer.wrap(bytes), Callback.NOOP)) + _ <- firstChunkReceived.get + _ <- IO(content.write(ByteBuffer.wrap(bytes), Callback.NOOP)) + _ <- IO(content.close()) + body <- bodyFiber.joinWithNever + } yield body + } + } + .assertEquals(Monoid[Chunk[Byte]].combineN(Chunk.array(bytes), 2)) + } + + // We shouldn't block when we receive less than a chunk at a time + servletServer().test("AsyncHttp4sServlet handle two itsy-bitsy deferred chunk POST") { server => + Dispatcher + .parallel[IO] + .use { dispatcher => + clientR.use { client => + for { + content <- IO(new AsyncRequestContent()) + firstChunkReceived <- Deferred[IO, Unit] + bodyFiber <- IO + .async_[Chunk[Byte]] { cb => + var body = Chunk.empty[Byte] + client + .POST(s"http://127.0.0.1:$server/echo") + .body(content) + .send(new JResponse.Listener { + override def onContent(resp: JResponse, bb: ByteBuffer) = + dispatcher.unsafeRunSync(for { + _ <- firstChunkReceived.complete(()).attempt + buf <- IO(new Array[Byte](bb.remaining())) + _ <- IO(bb.get(buf)) + _ <- IO { body = body ++ Chunk.array(buf) } + } yield ()) + override def onFailure(resp: JResponse, t: Throwable) = + cb(Left(t)) + override def onSuccess(resp: JResponse) = + cb(Right(body)) + }) + } + .start + _ <- IO(content.write(ByteBuffer.wrap(Array[Byte](0.toByte)), Callback.NOOP)) + _ <- firstChunkReceived.get + _ <- IO(content.write(ByteBuffer.wrap(Array[Byte](1.toByte)), Callback.NOOP)) + _ <- IO(content.close()) + body <- bodyFiber.joinWithNever + } yield body + } + } + .assertEquals(Chunk(0.toByte, 1.toByte)) + } + + servletServer().test("AsyncHttp4sServlet should not reorder lots of itsy-bitsy chunks") { + server => + val body = (0 until 4096).map(_.toByte).toArray + Dispatcher + .parallel[IO] + .use { dispatcher => + clientR.use { client => + for { + content <- IO(new AsyncRequestContent()) + bodyFiber <- IO + .async_[Chunk[Byte]] { cb => + var body = Chunk.empty[Byte] + client + .POST(s"http://127.0.0.1:$server/echo") + .body(content) + .send(new JResponse.Listener { + override def onContent(resp: JResponse, bb: ByteBuffer): Unit = + dispatcher.unsafeRunSync(for { + buf <- IO(new Array[Byte](bb.remaining())) + _ <- IO(bb.get(buf)) + _ <- IO { body = body ++ Chunk.array(buf) } + } yield ()) + override def onFailure(resp: JResponse, t: Throwable): Unit = + cb(Left(t)) + override def onSuccess(resp: JResponse): Unit = + cb(Right(body)) + }) + } + .start + _ <- body.toList.traverse_(b => + IO(content.write(ByteBuffer.wrap(Array[Byte](b)), Callback.NOOP)) >> IO( + content.flush() + ) + ) + _ <- IO(content.close()) + body <- bodyFiber.joinWithNever + } yield body + } + } + .assertEquals(Chunk.array(body)) + } + + servletServer().test("AsyncHttp4sServlet work for shifted IO") { server => + clientR.use(get(_, server, "shifted")).assertEquals("shifted") + } + + servletServer(3.seconds).test("AsyncHttp4sServlet timeout fires") { server => + clientR.use(get(_, server, "never")).map(_.contains("Error 500 AsyncContext timeout")) + } + + private def servlet(dispatcher: Dispatcher[IO], asyncTimeout: FiniteDuration) = + AsyncHttp4sServlet + .builder[IO](service, dispatcher) + .withChunkSize(DefaultChunkSize) + .withAsyncTimeout(asyncTimeout) + .build +} diff --git a/servlet-testing/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala b/servlet-testing/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala new file mode 100644 index 00000000..c53b70fb --- /dev/null +++ b/servlet-testing/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala @@ -0,0 +1,94 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.servlet + +import cats.effect.IO +import cats.effect.Resource +import cats.effect.kernel.Temporal +import cats.effect.std.Dispatcher +import munit.CatsEffectSuite +import org.http4s.HttpRoutes +import org.http4s.dsl.io._ + +import java.net.HttpURLConnection +import java.net.URL +import java.nio.charset.StandardCharsets +import scala.concurrent.duration._ +import scala.io.Source + +class BlockingHttp4sServletSuite extends CatsEffectSuite { + + private lazy val service = HttpRoutes + .of[IO] { + case GET -> Root / "simple" => + Ok("simple") + case req @ POST -> Root / "echo" => + Ok(req.body) + case GET -> Root / "shifted" => + // Wait for a bit to make sure we lose the race + Temporal[IO].sleep(50.milli) *> Ok("shifted") + } + .orNotFound + + private val servletServer = ResourceFunFixture( + Dispatcher.parallel[IO].flatMap(d => TestEclipseServer(servlet(d))) + ) + + private def get(serverPort: Int, path: String): IO[String] = + Resource + .make(IO.blocking(Source.fromURL(new URL(s"http://127.0.0.1:$serverPort/$path"))))(source => + IO.blocking(source.close()) + ) + .use { source => + IO.blocking(source.getLines().mkString) + } + + private def post(serverPort: Int, path: String, body: String): IO[String] = + IO { + val url = new URL(s"http://127.0.0.1:$serverPort/$path") + val conn = url.openConnection().asInstanceOf[HttpURLConnection] + val bytes = body.getBytes(StandardCharsets.UTF_8) + conn.setRequestMethod("POST") + conn.setRequestProperty("Content-Length", bytes.size.toString) + conn.setDoOutput(true) + conn.getOutputStream.write(bytes) + conn + }.flatMap { conn => + Resource + .make( + IO.blocking(Source.fromInputStream(conn.getInputStream, StandardCharsets.UTF_8.name)) + )(source => IO.blocking(source.close())) + .use { source => + IO.blocking(source.getLines().mkString) + } + } + + servletServer.test("Http4sBlockingServlet handle GET requests") { server => + get(server, "simple").assertEquals("simple") + } + + servletServer.test("Http4sBlockingServlet handle POST requests") { server => + post(server, "echo", "input data").assertEquals("input data") + } + + servletServer.test("Http4sBlockingServlet work for shifted IO") { server => + get(server, "shifted").assertEquals("shifted") + } + + private def servlet(dispatcher: Dispatcher[IO]) = + BlockingHttp4sServlet.builder[IO](service, dispatcher).build +} diff --git a/servlet-testing/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala b/servlet-testing/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala new file mode 100644 index 00000000..733da33a --- /dev/null +++ b/servlet-testing/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala @@ -0,0 +1,98 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.servlet + +import javax.servlet.ServletInputStream +import javax.servlet.http.HttpServletRequest + +final case class HttpServletRequestStub( + inputStream: ServletInputStream +) extends HttpServletRequest { + def getInputStream(): ServletInputStream = inputStream + + def authenticate(x$1: javax.servlet.http.HttpServletResponse): Boolean = ??? + def changeSessionId(): String = ??? + def getAuthType(): String = ??? + def getContextPath(): String = ??? + def getCookies(): Array[javax.servlet.http.Cookie] = ??? + def getDateHeader(x$1: String): Long = ??? + def getHeader(x$1: String): String = ??? + def getHeaderNames(): java.util.Enumeration[String] = ??? + def getHeaders(x$1: String): java.util.Enumeration[String] = ??? + def getIntHeader(x$1: String): Int = ??? + def getMethod(): String = ??? + def getPart(x$1: String): javax.servlet.http.Part = ??? + def getParts(): java.util.Collection[javax.servlet.http.Part] = ??? + def getPathInfo(): String = ??? + def getPathTranslated(): String = ??? + def getQueryString(): String = ??? + def getRemoteUser(): String = ??? + def getRequestURI(): String = ??? + def getRequestURL(): StringBuffer = ??? + def getRequestedSessionId(): String = ??? + def getServletPath(): String = ??? + def getSession(): javax.servlet.http.HttpSession = ??? + def getSession(x$1: Boolean): javax.servlet.http.HttpSession = ??? + def getUserPrincipal(): java.security.Principal = ??? + def isRequestedSessionIdFromCookie(): Boolean = ??? + def isRequestedSessionIdFromURL(): Boolean = ??? + def isRequestedSessionIdFromUrl(): Boolean = ??? + def isRequestedSessionIdValid(): Boolean = ??? + def isUserInRole(x$1: String): Boolean = ??? + def login(x$1: String, x$2: String): Unit = ??? + def logout(): Unit = ??? + def upgrade[T <: javax.servlet.http.HttpUpgradeHandler](x$1: Class[T]): T = ??? + def getAsyncContext(): javax.servlet.AsyncContext = ??? + def getAttribute(x$1: String): Object = ??? + def getAttributeNames(): java.util.Enumeration[String] = ??? + def getCharacterEncoding(): String = ??? + def getContentLength(): Int = ??? + def getContentLengthLong(): Long = ??? + def getContentType(): String = ??? + def getDispatcherType(): javax.servlet.DispatcherType = ??? + def getLocalAddr(): String = ??? + def getLocalName(): String = ??? + def getLocalPort(): Int = ??? + def getLocale(): java.util.Locale = ??? + def getLocales(): java.util.Enumeration[java.util.Locale] = ??? + def getParameter(x$1: String): String = ??? + def getParameterMap(): java.util.Map[String, Array[String]] = ??? + def getParameterNames(): java.util.Enumeration[String] = ??? + def getParameterValues(x$1: String): Array[String] = ??? + def getProtocol(): String = ??? + def getReader(): java.io.BufferedReader = ??? + def getRealPath(x$1: String): String = ??? + def getRemoteAddr(): String = ??? + def getRemoteHost(): String = ??? + def getRemotePort(): Int = ??? + def getRequestDispatcher(x$1: String): javax.servlet.RequestDispatcher = ??? + def getScheme(): String = ??? + def getServerName(): String = ??? + def getServerPort(): Int = ??? + def getServletContext(): javax.servlet.ServletContext = ??? + def isAsyncStarted(): Boolean = ??? + def isAsyncSupported(): Boolean = ??? + def isSecure(): Boolean = ??? + def removeAttribute(x$1: String): Unit = ??? + def setAttribute(x$1: String, x$2: Object): Unit = ??? + def setCharacterEncoding(x$1: String): Unit = ??? + def startAsync( + x$1: javax.servlet.ServletRequest, + x$2: javax.servlet.ServletResponse, + ): javax.servlet.AsyncContext = ??? + def startAsync(): javax.servlet.AsyncContext = ??? +} diff --git a/servlet-testing/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala b/servlet-testing/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala new file mode 100644 index 00000000..f8de9e15 --- /dev/null +++ b/servlet-testing/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala @@ -0,0 +1,151 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.servlet + +import cats.effect.IO +import cats.effect.Resource +import cats.effect.std.Dispatcher +import munit.CatsEffectSuite +import org.http4s.HttpRoutes +import org.http4s.dsl.io._ +import org.http4s.server.Router + +import java.net.URL +import scala.io.Source + +// Regression tests for #5362 / #5100 +class RouterInServletSuite extends CatsEffectSuite { + + private val mainRoutes = HttpRoutes.of[IO] { + case GET -> Root => Ok("root") + case GET -> Root / "suffix" => Ok("suffix") + } + + private val alternativeRoutes = HttpRoutes.of[IO] { case GET -> Root => + Ok("alternative root") + } + + private val router = Router( + "prefix" -> mainRoutes, + "" -> alternativeRoutes, + ) + + private val serverWithoutRouter = + ResourceFunFixture[Int]( + Dispatcher.parallel[IO].flatMap(d => mkServer(mainRoutes, dispatcher = d)) + ) + private val server = + ResourceFunFixture[Int](Dispatcher.parallel[IO].flatMap(d => mkServer(router, dispatcher = d))) + private val serverWithContextPath = + ResourceFunFixture[Int]( + Dispatcher + .parallel[IO] + .flatMap(d => mkServer(router, contextPath = "/context", dispatcher = d)) + ) + private val serverWithServletPath = + ResourceFunFixture[Int]( + Dispatcher + .parallel[IO] + .flatMap(d => mkServer(router, servletPath = "/servlet/*", dispatcher = d)) + ) + private val serverWithContextAndServletPath = + ResourceFunFixture[Int]( + Dispatcher + .parallel[IO] + .flatMap(d => + mkServer(router, contextPath = "/context", servletPath = "/servlet/*", dispatcher = d) + ) + ) + + serverWithoutRouter.test( + "Http4s servlet without router should handle root request" + )(server => get(server, "").assertEquals("root")) + + serverWithoutRouter.test( + "Http4s servlet without router should handle suffix request" + )(server => get(server, "suffix").assertEquals("suffix")) + + server.test( + "Http4s servlet should handle alternative-root request" + )(server => get(server, "").assertEquals("alternative root")) + + server.test( + "Http4s servlet should handle root request" + )(server => get(server, "prefix").assertEquals("root")) + + server.test( + "Http4s servlet should handle suffix request" + )(server => get(server, "prefix/suffix").assertEquals("suffix")) + + serverWithContextPath.test( + "Http4s servlet with non-empty context path should handle alternative-root request" + )(server => get(server, "context").assertEquals("alternative root")) + + serverWithContextPath.test( + "Http4s servlet with non-empty context path should handle root request" + )(server => get(server, "context/prefix").assertEquals("root")) + + serverWithContextPath.test( + "Http4s servlet with non-empty context path should handle suffix request" + )(server => get(server, "context/prefix/suffix").assertEquals("suffix")) + + serverWithServletPath.test( + "Http4s servlet with non-empty servlet path should handle alternative-root request" + )(server => get(server, "servlet").assertEquals("alternative root")) + + serverWithServletPath.test( + "Http4s servlet with non-empty servlet path should handle root request" + )(server => get(server, "servlet/prefix").assertEquals("root")) + + serverWithServletPath.test( + "Http4s servlet with non-empty servlet path should handle suffix request" + )(server => get(server, "servlet/prefix/suffix").assertEquals("suffix")) + + serverWithContextAndServletPath.test( + "Http4s servlet with non-empty context & servlet path should handle alternative-root request" + )(server => get(server, "context/servlet").assertEquals("alternative root")) + + serverWithContextAndServletPath.test( + "Http4s servlet with non-empty context & servlet path should handle root request" + )(server => get(server, "context/servlet/prefix").assertEquals("root")) + + serverWithContextAndServletPath.test( + "Http4s servlet with non-empty context & servlet path should handle suffix request" + )(server => get(server, "context/servlet/prefix/suffix").assertEquals("suffix")) + + private def get(serverPort: Int, path: String): IO[String] = + Resource + .make(IO.blocking(Source.fromURL(new URL(s"http://127.0.0.1:$serverPort/$path"))))(source => + IO.blocking(source.close()) + ) + .use { source => + IO.blocking(source.getLines().mkString) + } + + private def mkServer( + routes: HttpRoutes[IO], + contextPath: String = "/", + servletPath: String = "/*", + dispatcher: Dispatcher[IO], + ): Resource[IO, Int] = TestEclipseServer(servlet(routes, dispatcher), contextPath, servletPath) + + private def servlet(routes: HttpRoutes[IO], dispatcher: Dispatcher[IO]) = + AsyncHttp4sServlet + .builder[IO](routes.orNotFound, dispatcher) + .build + +} diff --git a/servlet-testing/src/test/scala/org/http4s/servlet/ServletContainerSuite.scala b/servlet-testing/src/test/scala/org/http4s/servlet/ServletContainerSuite.scala new file mode 100644 index 00000000..ef280d27 --- /dev/null +++ b/servlet-testing/src/test/scala/org/http4s/servlet/ServletContainerSuite.scala @@ -0,0 +1,31 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.servlet + +import munit.CatsEffectSuite + +class ServletContainerSuite extends CatsEffectSuite { + import ServletContainer.prefixMapping + + test("prefixMapping should append /* when prefix does not have trailing slash") { + assertEquals(prefixMapping("/foo"), "/foo/*") + } + + test("prefixMapping should append * when prefix has trailing slash") { + assertEquals(prefixMapping("/"), "/*") + } +} diff --git a/servlet/src/test/scala/org/http4s/servlet/TestEclipseServer.scala b/servlet-testing/src/test/scala/org/http4s/servlet/TestEclipseServer.scala similarity index 92% rename from servlet/src/test/scala/org/http4s/servlet/TestEclipseServer.scala rename to servlet-testing/src/test/scala/org/http4s/servlet/TestEclipseServer.scala index d37a16d7..17c01323 100644 --- a/servlet/src/test/scala/org/http4s/servlet/TestEclipseServer.scala +++ b/servlet-testing/src/test/scala/org/http4s/servlet/TestEclipseServer.scala @@ -18,13 +18,12 @@ package org.http4s.servlet import cats.effect.IO import cats.effect.Resource -import jakarta.servlet.Servlet +import org.eclipse.jetty.ee8.servlet.ServletContextHandler +import org.eclipse.jetty.ee8.servlet.ServletHolder import org.eclipse.jetty.server.HttpConfiguration import org.eclipse.jetty.server.HttpConnectionFactory import org.eclipse.jetty.server.ServerConnector import org.eclipse.jetty.server.{Server => EclipseServer} -import org.eclipse.jetty.servlet.ServletContextHandler -import org.eclipse.jetty.servlet.ServletHolder object TestEclipseServer { diff --git a/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala b/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala index 05b9dde9..8ceee763 100644 --- a/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala +++ b/servlet/src/main/scala/org/http4s/servlet/ServletIo.scala @@ -127,7 +127,7 @@ final case class NonBlockingServletIo[F[_]](chunkSize: Int)(implicit F: Async[F] F.unit } - unsafeRunAndForget(go) + unsafeRunAndForget(loopIfReady) } def onAllDataRead(): Unit = diff --git a/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala b/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala index 93034f0b..177fefb1 100644 --- a/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala +++ b/servlet/src/test/scala/org/http4s/servlet/AsyncHttp4sServletSuite.scala @@ -22,26 +22,21 @@ import cats.effect.Deferred import cats.effect.IO import cats.effect.Resource import cats.effect.std.Dispatcher -import cats.syntax.all._ import fs2.Chunk import fs2.Stream import munit.CatsEffectSuite -import org.eclipse.jetty.client.HttpClient -import org.eclipse.jetty.client.api.{Response => JResponse} -import org.eclipse.jetty.client.util.AsyncRequestContent -import org.eclipse.jetty.client.util.BytesRequestContent +import org.asynchttpclient.AsyncHandler +import org.asynchttpclient.AsyncHttpClient +import org.asynchttpclient.Dsl._ +import org.asynchttpclient.HttpResponseBodyPart +import org.asynchttpclient.HttpResponseStatus import org.http4s.dsl.io._ import org.http4s.syntax.all._ -import java.nio.ByteBuffer import scala.concurrent.duration._ class AsyncHttp4sServletSuite extends CatsEffectSuite { - private val clientR = Resource.make(IO { - val client = new HttpClient() - client.start() - client - })(client => IO(client.stop())) + private val clientR = Resource.make(IO(asyncHttpClient()))(client => IO(client.close())) private lazy val service = HttpRoutes .of[IO] { @@ -60,13 +55,17 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { private def servletServer(asyncTimeout: FiniteDuration = 10.seconds) = ResourceFunFixture[Int]( - Dispatcher.parallel[IO].flatMap(d => TestEclipseServer(servlet(d, asyncTimeout))) + Dispatcher.parallel[IO].flatMap(d => TestUndertowServer(servlet(d, asyncTimeout))) ) - private def get(client: HttpClient, serverPort: Int, path: String): IO[String] = - IO.blocking( - client.GET(s"http://127.0.0.1:$serverPort/$path") - ).map(_.getContentAsString) + private def get(client: AsyncHttpClient, serverPort: Int, path: String): IO[String] = + IO.fromCompletableFuture(IO.blocking { + client + .prepareGet(s"http://127.0.0.1:$serverPort/$path") + .execute() + .toCompletableFuture + .thenApply[String](_.getResponseBody) + }) servletServer().test("AsyncHttp4sServlet handle GET requests") { server => clientR.use(get(_, server, "simple")).assertEquals("simple") @@ -76,13 +75,15 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { servletServer().test("AsyncHttp4sServlet handle empty POST") { server => clientR .use { client => - IO.blocking( + IO.fromCompletableFuture(IO.blocking { client - .POST(s"http://127.0.0.1:$server/echo") - .send() - ).map(resp => Chunk.array(resp.getContent)) + .preparePost(s"http://127.0.0.1:$server/echo") + .execute() + .toCompletableFuture + .thenApply[Chunk[Byte]](resp => Chunk.array(resp.getResponseBodyAsBytes)) + }) } - .assertEquals(Chunk.empty) + .assertEquals(Chunk.empty[Byte]) } // We should handle a regular, big body @@ -90,12 +91,14 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { val bytes = Stream.range(0, DefaultChunkSize * 2).map(_.toByte).to(Array) clientR .use { client => - IO.blocking( + IO.fromCompletableFuture(IO.blocking { client - .POST(s"http://127.0.0.1:$server/echo") - .body(new BytesRequestContent(bytes)) - .send() - ).map(resp => Chunk.array(resp.getContent)) + .preparePost(s"http://127.0.0.1:$server/echo") + .setBody(bytes) + .execute() + .toCompletableFuture + .thenApply[Chunk[Byte]](resp => Chunk.array(resp.getResponseBodyAsBytes)) + }) } .assertEquals(Chunk.array(bytes)) } @@ -106,29 +109,41 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { clientR .use { client => for { - content <- IO(new AsyncRequestContent()) - bodyFiber <- IO - .async_[Chunk[Byte]] { cb => - var body = Chunk.empty[Byte] - client - .POST(s"http://127.0.0.1:$server/echo") - .body(content) - .send(new JResponse.Listener { - override def onContent(resp: JResponse, bb: ByteBuffer) = { - val buf = new Array[Byte](bb.remaining()) - bb.get(buf) - body ++= Chunk.array(buf) - } - override def onFailure(resp: JResponse, t: Throwable) = - cb(Left(t)) - override def onSuccess(resp: JResponse) = - cb(Right(body)) - }) + bodyRef <- IO.ref(Chunk.empty[Byte]) + _ <- IO.fromCompletableFuture(IO { + val bodyCollector = new AsyncHandler[Unit] { + override def onStatusReceived( + responseStatus: HttpResponseStatus + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onHeadersReceived( + headers: io.netty.handler.codec.http.HttpHeaders + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onBodyPartReceived( + bodyPart: HttpResponseBodyPart + ): AsyncHandler.State = { + val buf = bodyPart.getBodyByteBuffer + val array = new Array[Byte](buf.remaining()) + buf.get(array) + bodyRef.update(_ ++ Chunk.array(array)).unsafeRunSync() + AsyncHandler.State.CONTINUE + } + + override def onCompleted(): Unit = {} + + override def onThrowable(t: Throwable): Unit = {} } - .start - _ <- IO(content.offer(ByteBuffer.wrap(bytes))) - _ <- IO(content.close()) - body <- bodyFiber.joinWithNever + + client + .preparePost(s"http://127.0.0.1:$server/echo") + .setBody(bytes) + .execute(bodyCollector) + .toCompletableFuture + }) + body <- bodyRef.get } yield body } .assertEquals(Chunk.array(bytes)) @@ -143,34 +158,51 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { .use { dispatcher => clientR.use { client => for { - content <- IO(new AsyncRequestContent()) firstChunkReceived <- Deferred[IO, Unit] - bodyFiber <- IO - .async_[Chunk[Byte]] { cb => - var body = Chunk.empty[Byte] - client - .POST(s"http://127.0.0.1:$server/echo") - .body(content) - .send(new JResponse.Listener { - override def onContent(resp: JResponse, bb: ByteBuffer) = - dispatcher.unsafeRunSync(for { - _ <- firstChunkReceived.complete(()).attempt - buf <- IO(new Array[Byte](bb.remaining())) - _ <- IO(bb.get(buf)) - _ <- IO { body = body ++ Chunk.array(buf) } - } yield ()) - override def onFailure(resp: JResponse, t: Throwable) = - cb(Left(t)) - override def onSuccess(resp: JResponse) = - cb(Right(body)) - }) + bodyRef <- IO.ref(Chunk.empty[Byte]) + _ <- IO.fromCompletableFuture(IO { + val bodyCollector = new AsyncHandler[Unit] { + private var firstChunk = true + + override def onStatusReceived( + responseStatus: HttpResponseStatus + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onHeadersReceived( + headers: io.netty.handler.codec.http.HttpHeaders + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onBodyPartReceived( + bodyPart: HttpResponseBodyPart + ): AsyncHandler.State = dispatcher.unsafeRunSync(for { + _ <- + if (firstChunk) { + firstChunk = false + firstChunkReceived.complete(()).attempt + } else { + IO.unit + } + buf <- IO(bodyPart.getBodyByteBuffer) + array <- IO(new Array[Byte](buf.remaining())) + _ <- IO(buf.get(array)) + _ <- bodyRef.update(_ ++ Chunk.array(array)) + } yield AsyncHandler.State.CONTINUE) + + override def onCompleted(): Unit = {} + + override def onThrowable(t: Throwable): Unit = {} } - .start - _ <- IO(content.offer(ByteBuffer.wrap(bytes))) + + client + .preparePost(s"http://127.0.0.1:$server/echo") + .setBody(bytes ++ bytes) + .execute(bodyCollector) + .toCompletableFuture + }) _ <- firstChunkReceived.get - _ <- IO(content.offer(ByteBuffer.wrap(bytes))) - _ <- IO(content.close()) - body <- bodyFiber.joinWithNever + body <- bodyRef.get } yield body } } @@ -184,34 +216,51 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { .use { dispatcher => clientR.use { client => for { - content <- IO(new AsyncRequestContent()) firstChunkReceived <- Deferred[IO, Unit] - bodyFiber <- IO - .async_[Chunk[Byte]] { cb => - var body = Chunk.empty[Byte] - client - .POST(s"http://127.0.0.1:$server/echo") - .body(content) - .send(new JResponse.Listener { - override def onContent(resp: JResponse, bb: ByteBuffer) = - dispatcher.unsafeRunSync(for { - _ <- firstChunkReceived.complete(()).attempt - buf <- IO(new Array[Byte](bb.remaining())) - _ <- IO(bb.get(buf)) - _ <- IO { body = body ++ Chunk.array(buf) } - } yield ()) - override def onFailure(resp: JResponse, t: Throwable) = - cb(Left(t)) - override def onSuccess(resp: JResponse) = - cb(Right(body)) - }) + bodyRef <- IO.ref(Chunk.empty[Byte]) + _ <- IO.fromCompletableFuture(IO { + val bodyCollector = new AsyncHandler[Unit] { + private var firstChunk = true + + override def onStatusReceived( + responseStatus: HttpResponseStatus + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onHeadersReceived( + headers: io.netty.handler.codec.http.HttpHeaders + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onBodyPartReceived( + bodyPart: HttpResponseBodyPart + ): AsyncHandler.State = dispatcher.unsafeRunSync(for { + _ <- + if (firstChunk) { + firstChunk = false + firstChunkReceived.complete(()).attempt + } else { + IO.unit + } + buf <- IO(bodyPart.getBodyByteBuffer) + array <- IO(new Array[Byte](buf.remaining())) + _ <- IO(buf.get(array)) + _ <- bodyRef.update(_ ++ Chunk.array(array)) + } yield AsyncHandler.State.CONTINUE) + + override def onCompleted(): Unit = {} + + override def onThrowable(t: Throwable): Unit = {} } - .start - _ <- IO(content.offer(ByteBuffer.wrap(Array[Byte](0.toByte)))) + + client + .preparePost(s"http://127.0.0.1:$server/echo") + .setBody(Array[Byte](0.toByte, 1.toByte)) + .execute(bodyCollector) + .toCompletableFuture + }) _ <- firstChunkReceived.get - _ <- IO(content.offer(ByteBuffer.wrap(Array[Byte](1.toByte)))) - _ <- IO(content.close()) - body <- bodyFiber.joinWithNever + body <- bodyRef.get } yield body } } @@ -223,36 +272,45 @@ class AsyncHttp4sServletSuite extends CatsEffectSuite { val body = (0 until 4096).map(_.toByte).toArray Dispatcher .parallel[IO] - .use { dispatcher => + .use { _ => clientR.use { client => for { - content <- IO(new AsyncRequestContent()) - bodyFiber <- IO - .async_[Chunk[Byte]] { cb => - var body = Chunk.empty[Byte] - client - .POST(s"http://127.0.0.1:$server/echo") - .body(content) - .send(new JResponse.Listener { - override def onContent(resp: JResponse, bb: ByteBuffer): Unit = - dispatcher.unsafeRunSync(for { - buf <- IO(new Array[Byte](bb.remaining())) - _ <- IO(bb.get(buf)) - _ <- IO { body = body ++ Chunk.array(buf) } - } yield ()) - override def onFailure(resp: JResponse, t: Throwable): Unit = - cb(Left(t)) - override def onSuccess(resp: JResponse): Unit = - cb(Right(body)) - }) + bodyRef <- IO.ref(Chunk.empty[Byte]) + _ <- IO.fromCompletableFuture(IO { + val bodyCollector = new AsyncHandler[Unit] { + override def onStatusReceived( + responseStatus: HttpResponseStatus + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onHeadersReceived( + headers: io.netty.handler.codec.http.HttpHeaders + ): AsyncHandler.State = + AsyncHandler.State.CONTINUE + + override def onBodyPartReceived( + bodyPart: HttpResponseBodyPart + ): AsyncHandler.State = { + val buf = bodyPart.getBodyByteBuffer + val array = new Array[Byte](buf.remaining()) + buf.get(array) + bodyRef.update(_ ++ Chunk.array(array)).unsafeRunSync() + AsyncHandler.State.CONTINUE + } + + override def onCompleted(): Unit = {} + + override def onThrowable(t: Throwable): Unit = {} } - .start - _ <- body.toList.traverse_(b => - IO(content.offer(ByteBuffer.wrap(Array[Byte](b)))) >> IO(content.flush()) - ) - _ <- IO(content.close()) - body <- bodyFiber.joinWithNever - } yield body + + client + .preparePost(s"http://127.0.0.1:$server/echo") + .setBody(body) + .execute(bodyCollector) + .toCompletableFuture + }) + responseBody <- bodyRef.get + } yield responseBody } } .assertEquals(Chunk.array(body)) diff --git a/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala b/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala index 2a5ee548..e6567554 100644 --- a/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala +++ b/servlet/src/test/scala/org/http4s/servlet/BlockingHttp4sServletSuite.scala @@ -46,7 +46,7 @@ class BlockingHttp4sServletSuite extends CatsEffectSuite { .orNotFound private val servletServer = ResourceFunFixture( - Dispatcher.parallel[IO].flatMap(d => TestEclipseServer(servlet(d))) + Dispatcher.parallel[IO].flatMap(d => TestUndertowServer(servlet(d))) ) private def get(serverPort: Int, path: String): IO[String] = diff --git a/servlet/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala b/servlet/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala index fbbb0dd9..064270c9 100644 --- a/servlet/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala +++ b/servlet/src/test/scala/org/http4s/servlet/Http4sServletRequestStub.scala @@ -74,16 +74,19 @@ final case class HttpServletRequestStub( def getParameterNames(): java.util.Enumeration[String] = ??? def getParameterValues(x$1: String): Array[String] = ??? def getProtocol(): String = ??? + def getProtocolRequestId(): String = ??? def getReader(): java.io.BufferedReader = ??? def getRealPath(x$1: String): String = ??? def getRemoteAddr(): String = ??? def getRemoteHost(): String = ??? def getRemotePort(): Int = ??? def getRequestDispatcher(x$1: String): jakarta.servlet.RequestDispatcher = ??? + def getRequestId(): String = ??? def getScheme(): String = ??? def getServerName(): String = ??? def getServerPort(): Int = ??? def getServletContext(): jakarta.servlet.ServletContext = ??? + def getServletConnection(): jakarta.servlet.ServletConnection = ??? def isAsyncStarted(): Boolean = ??? def isAsyncSupported(): Boolean = ??? def isSecure(): Boolean = ??? diff --git a/servlet/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala b/servlet/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala index f8de9e15..e8bc2c8f 100644 --- a/servlet/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala +++ b/servlet/src/test/scala/org/http4s/servlet/RouterInServletSuite.scala @@ -141,7 +141,7 @@ class RouterInServletSuite extends CatsEffectSuite { contextPath: String = "/", servletPath: String = "/*", dispatcher: Dispatcher[IO], - ): Resource[IO, Int] = TestEclipseServer(servlet(routes, dispatcher), contextPath, servletPath) + ): Resource[IO, Int] = TestUndertowServer(servlet(routes, dispatcher), contextPath, servletPath) private def servlet(routes: HttpRoutes[IO], dispatcher: Dispatcher[IO]) = AsyncHttp4sServlet diff --git a/servlet/src/test/scala/org/http4s/servlet/TestUndertowServer.scala b/servlet/src/test/scala/org/http4s/servlet/TestUndertowServer.scala new file mode 100644 index 00000000..cfb248d9 --- /dev/null +++ b/servlet/src/test/scala/org/http4s/servlet/TestUndertowServer.scala @@ -0,0 +1,91 @@ +/* + * Copyright 2013 http4s.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.http4s.servlet + +import cats.effect.IO +import cats.effect.Resource +import io.undertow.Undertow +import io.undertow.server.HttpHandler +import io.undertow.server.handlers.PathHandler +import io.undertow.servlet.Servlets +import io.undertow.servlet.api.DeploymentManager +import io.undertow.servlet.api.ServletInfo +import io.undertow.servlet.util.ImmediateInstanceFactory + +import jakarta.servlet.Servlet + +object TestUndertowServer { + + def apply( + servlet: Servlet, + contextPath: String = "/", + servletPath: String = "/*", + ): Resource[IO, Int /* port */ ] = + Resource + .make(IO { + /* Create deployment info - use ServletInfo constructor that accepts instance factory */ + val servletInfo = new ServletInfo( + "http4s-servlet", + classOf[Servlet], + new ImmediateInstanceFactory[Servlet](servlet), + ) + servletInfo.addMapping(servletPath) + servletInfo.setAsyncSupported(true) + + val deploymentInfo = Servlets.deployment() + deploymentInfo.setClassLoader(TestUndertowServer.getClass.getClassLoader) + deploymentInfo.setContextPath(contextPath) + deploymentInfo.setDeploymentName("http4s-servlet-test") + deploymentInfo.addServlet(servletInfo) + + /* Create deployment manager */ + val manager: DeploymentManager = Servlets.defaultContainer().addDeployment(deploymentInfo) + manager.deploy() + + val servletHandler = manager.start() + + /* Create path handler */ + val pathHandler: HttpHandler = + if (contextPath == "/") servletHandler + else new PathHandler().addPrefixPath(contextPath, servletHandler) + + /* Create and start Undertow server */ + val server = Undertow + .builder() + .addHttpListener(0, "localhost") // Port 0 = random available port + .setHandler(pathHandler) + .build() + + server.start() + + /* Get the assigned port */ + val port = server.getListenerInfo + .get(0) + .getAddress + .asInstanceOf[java.net.InetSocketAddress] + .getPort + + (server, manager, port) + }) { case (server, manager, _) => + IO { + server.stop() + manager.stop() + manager.undeploy() + } + } + .map { case (_, _, port) => port } +}